Files
maestro/ui/src/components/files/FileToolbar.tsx
T
oss-sync 29ccaf1e92
CI / build-and-test (push) Has been cancelled
sync: update from private repo (dfadcd5f)
2026-06-23 06:38:48 +00:00

211 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ファイルブラウザのツールバー部品群(スペース / タスク共有)。
* いずれも状態を持たない純プレゼンテーショナル。drag 状態だけ FileDropzone が内部に持つ。
*
* - FileActions … 「追加」ボタン + 隠し input + 再読み込み
* - FileSelectionBar … すべて選択 + 選択件数 + 削除(複数選択)
* - FileDropzone … 子をラップし、ドラッグ&ドロップでアップロードを受ける
*/
import { useEffect, useRef, useState } from 'react';
export { filesToBase64 } from '../../lib/fileBase64';
const ICON_BTN =
'w-8 h-8 flex items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 hover:text-slate-900 hover:bg-surface transition-colors';
export function FileActions({
idPrefix,
canUpload,
onUploadFiles,
onRefresh,
isRefreshing,
isUploading,
}: {
idPrefix: string;
canUpload: boolean;
onUploadFiles: (files: File[]) => void;
onRefresh: () => void;
isRefreshing?: boolean;
isUploading?: boolean;
}) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className="flex shrink-0 items-center gap-1.5">
{canUpload && (
<>
<input
ref={inputRef}
type="file"
multiple
data-testid={`${idPrefix}-files-upload-input`}
className="hidden"
onChange={e => {
const files = Array.from(e.target.files ?? []);
e.target.value = '';
onUploadFiles(files);
}}
/>
<button
type="button"
data-testid={`${idPrefix}-files-upload-btn`}
onClick={() => inputRef.current?.click()}
disabled={isUploading}
className="inline-flex h-8 items-center gap-1 rounded-md border border-hairline bg-canvas px-2.5 text-2xs font-medium text-slate-600 transition-colors hover:bg-surface hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:opacity-50"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 3.5v9M3.5 8h9" />
</svg>
追加
</button>
</>
)}
<button
type="button"
onClick={onRefresh}
disabled={isRefreshing}
className={`${ICON_BTN} disabled:opacity-50`}
title="再読み込み"
aria-label="再読み込み"
>
<svg className={`h-3.5 w-3.5 ${isRefreshing ? 'animate-spin' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 8a6 6 0 0110.5-4M14 8a6 6 0 01-10.5 4" />
<path d="M12 2v3h-3M4 14v-3h3" />
</svg>
</button>
</div>
);
}
export function FileSelectionBar({
idPrefix,
allSelected,
onToggleSelectAll,
selectedCount,
onDeleteSelected,
onDownloadSelected,
isDeleting,
isDownloading,
}: {
idPrefix: string;
allSelected: boolean;
onToggleSelectAll: () => void;
selectedCount: number;
onDeleteSelected: () => void;
onDownloadSelected: () => void;
isDeleting?: boolean;
isDownloading?: boolean;
}) {
return (
<div className="flex flex-wrap items-center gap-2 text-2xs text-slate-500">
<label className="inline-flex cursor-pointer items-center gap-1.5 select-none">
<input
type="checkbox"
data-testid={`${idPrefix}-files-select-all`}
checked={allSelected}
onChange={onToggleSelectAll}
className="h-3.5 w-3.5 rounded border-hairline accent-[var(--brand-primary)]"
/>
すべて選択
</label>
<span data-testid={`${idPrefix}-files-selected-count`} className="text-slate-400">
{selectedCount} 件選択中
</span>
{selectedCount > 0 && (
<div className="ml-auto flex items-center gap-1.5">
<button
type="button"
data-testid={`${idPrefix}-files-download-selected`}
onClick={onDownloadSelected}
disabled={isDownloading}
className="inline-flex h-7 items-center gap-1 rounded-md border border-hairline bg-canvas px-2.5 text-2xs font-medium text-slate-600 transition-colors hover:bg-surface hover:text-slate-900 disabled:opacity-50"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 2v8M4.5 6.5L8 10l3.5-3.5M2.5 13h11" />
</svg>
ダウンロード
</button>
<button
type="button"
data-testid={`${idPrefix}-files-delete-selected`}
onClick={onDeleteSelected}
disabled={isDeleting}
className="inline-flex h-7 items-center gap-1 rounded-md border border-red-200 bg-red-50 px-2.5 text-2xs font-bold text-red-700 transition-colors hover:bg-red-100 disabled:opacity-50 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" />
</svg>
削除
</button>
</div>
)}
</div>
);
}
export function FileDropzone({
idPrefix,
enabled,
isUploading,
onDropFiles,
onRejectFolder,
children,
}: {
idPrefix: string;
/** false のときドロップを受け付けない(オーバーレイも出さない)。 */
enabled: boolean;
isUploading?: boolean;
onDropFiles: (files: File[]) => void;
/** フォルダがドロップされたときのコールバック(未対応メッセージ表示用)。 */
onRejectFolder?: () => void;
children: React.ReactNode;
}) {
const [dragDepth, setDragDepth] = useState(0);
// ウィンドウ外への drag 離脱や drop 取りこぼしで dragDepth が戻らずオーバーレイが
// 残るのを防ぐ。window レベルの dragend/drop で必ず 0 に戻す。
useEffect(() => {
const reset = () => setDragDepth(0);
window.addEventListener('dragend', reset);
window.addEventListener('drop', reset);
return () => {
window.removeEventListener('dragend', reset);
window.removeEventListener('drop', reset);
};
}, []);
// testid と要素は常に出し、enabled=falseread-only)のときは DnD ハンドラと
// オーバーレイだけ無効化する(既存 e2e の dropzone testid を温存する)。
return (
<div
data-testid={`${idPrefix}-files-dropzone`}
className="relative min-h-[6rem] rounded-lg"
onDragEnter={enabled ? (e => { e.preventDefault(); setDragDepth(d => d + 1); }) : undefined}
onDragOver={enabled ? (e => { e.preventDefault(); }) : undefined}
onDragLeave={enabled ? (e => { e.preventDefault(); if (!e.relatedTarget) { setDragDepth(0); } else { setDragDepth(d => Math.max(0, d - 1)); } }) : undefined}
onDrop={enabled ? (e => {
e.preventDefault();
setDragDepth(0);
const hasDirectory = Array.from(e.dataTransfer.items ?? []).some(
it => it.webkitGetAsEntry?.()?.isDirectory,
);
const files = Array.from(e.dataTransfer.files);
if (hasDirectory) { onRejectFolder?.(); return; }
if (files.length === 0) return;
onDropFiles(files);
}) : undefined}
>
{enabled && dragDepth > 0 && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-lg border-2 border-dashed border-slate-400 bg-canvas/85 text-sm font-medium text-slate-600">
ここにドロップして追加
</div>
)}
{enabled && isUploading && (
<div className="pointer-events-none absolute right-1 top-1 z-10 rounded bg-slate-800/80 px-2 py-0.5 text-2xs text-white">
アップロード中…
</div>
)}
{children}
</div>
);
}