211 lines
8.1 KiB
TypeScript
211 lines
8.1 KiB
TypeScript
/**
|
||
* ファイルブラウザのツールバー部品群(スペース / タスク共有)。
|
||
* いずれも状態を持たない純プレゼンテーショナル。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=false(read-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>
|
||
);
|
||
}
|