87 lines
3.8 KiB
TypeScript
87 lines
3.8 KiB
TypeScript
/**
|
|
* FileBreadcrumb — ファイルブラウザのパンくず(ルート / seg / seg …)。
|
|
* スペースのファイルタブとタスクのファイルタブで共有する。
|
|
*/
|
|
import { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { DND_FILES_MIME, readDragSources } from '../../lib/fileDnd';
|
|
|
|
interface FileBreadcrumbProps {
|
|
/** 現在パスのセグメント配列(空配列 = ルート)。 */
|
|
pathSegments: string[];
|
|
/** セグメントをクリックしたときに移動するパス('' = ルート)。 */
|
|
onNavigate: (path: string) => void;
|
|
/** ルートラベル左に出す任意のプレフィックス(例: '/files')。 */
|
|
testid?: string;
|
|
/**
|
|
* Phase 2: パンくずの祖先セグメント(やルート)へドラッグ&ドロップしたときの移動。
|
|
* 指定すると祖先がドロップ先になり、上の階層へファイルを移せる。
|
|
*/
|
|
onMoveDrop?: (sourcePaths: string[], destDir: string) => void;
|
|
}
|
|
|
|
export function FileBreadcrumb({ pathSegments, onNavigate, testid, onMoveDrop }: FileBreadcrumbProps) {
|
|
const { t } = useTranslation('files');
|
|
const [dropPath, setDropPath] = useState<string | null>(null);
|
|
// ドロップ先になれるのは「現在地ではない」祖先のみ(現在地=最後のセグメント)。
|
|
const dropProps = (path: string, isCurrent: boolean) =>
|
|
onMoveDrop && !isCurrent
|
|
? {
|
|
onDragOver: (e: React.DragEvent) => {
|
|
if (!e.dataTransfer.types.includes(DND_FILES_MIME)) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move' as const;
|
|
if (dropPath !== path) setDropPath(path);
|
|
},
|
|
onDragLeave: () => setDropPath(p => (p === path ? null : p)),
|
|
onDrop: (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setDropPath(null);
|
|
const sources = readDragSources(e.dataTransfer);
|
|
if (sources.length) onMoveDrop(sources, path);
|
|
},
|
|
'data-drop-target': 'true' as const,
|
|
}
|
|
: {};
|
|
const dropRing = (path: string) =>
|
|
dropPath === path ? ' ring-2 ring-[var(--brand-primary)] text-slate-900' : '';
|
|
return (
|
|
<nav
|
|
data-testid={testid}
|
|
aria-label={t('breadcrumb.nav')}
|
|
className="flex flex-wrap items-center gap-0.5 text-2xs text-slate-500"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => onNavigate('')}
|
|
{...dropProps('', pathSegments.length === 0)}
|
|
className={`rounded px-1 py-0.5 transition-colors hover:bg-surface hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:text-slate-700${dropRing('')}`}
|
|
disabled={pathSegments.length === 0}
|
|
aria-current={pathSegments.length === 0 ? 'location' : undefined}
|
|
>
|
|
{t('breadcrumb.root')}
|
|
</button>
|
|
{pathSegments.map((seg, i) => {
|
|
const prefix = pathSegments.slice(0, i + 1).join('/');
|
|
const isLast = i === pathSegments.length - 1;
|
|
return (
|
|
<span key={prefix} className="flex items-center gap-0.5">
|
|
<span className="text-slate-300" aria-hidden>/</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => onNavigate(prefix)}
|
|
{...dropProps(prefix, isLast)}
|
|
className={`max-w-[14ch] truncate rounded px-1 py-0.5 transition-colors hover:bg-surface hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:text-slate-700${dropRing(prefix)}`}
|
|
disabled={isLast}
|
|
aria-current={isLast ? 'location' : undefined}
|
|
title={seg}
|
|
>
|
|
{seg}
|
|
</button>
|
|
</span>
|
|
);
|
|
})}
|
|
</nav>
|
|
);
|
|
}
|