This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* ファイルブラウザのツールバー部品群(スペース / タスク共有)。
|
||||
* いずれも状態を持たない純プレゼンテーショナル。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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user