mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
179 lines
6.7 KiB
TypeScript
179 lines
6.7 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useRef, useState } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { ChevronDown, ChevronRight, Folder, FolderOpen, HardDrive } from "@/components/icons";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Dialog } from "@/components/ui/dialog";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface PickerFolder {
|
|
id: string;
|
|
name: string;
|
|
parentId: string | null;
|
|
}
|
|
|
|
interface FolderPickerDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
/** Called with the chosen folder; null is the root ("My files"). */
|
|
onPick: (folderId: string | null) => void;
|
|
title: string;
|
|
confirmText: string;
|
|
/** Every folder of the drive; the tree is built from their parent links. */
|
|
folders: PickerFolder[];
|
|
/** Folders that can't be picked, with what is below them (what is being moved). */
|
|
excludeIds?: Set<string>;
|
|
/** The folder that is pre-selected and can't be picked (where the items already are). */
|
|
currentId?: string | null;
|
|
}
|
|
|
|
const ROOT = "";
|
|
|
|
/**
|
|
* Picks a folder of the user's drive, e.g. to move items there: the folder
|
|
* tree with My files at the top, browsed with the mouse or the arrow keys.
|
|
*/
|
|
export function FolderPickerDialog({
|
|
isOpen, onClose, onPick, title, confirmText, folders, excludeIds, currentId = null,
|
|
}: FolderPickerDialogProps) {
|
|
const t = useTranslations("files");
|
|
const [selected, setSelected] = useState<string | null>(null);
|
|
const [expanded, setExpanded] = useState<Set<string>>(() => new Set([ROOT]));
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
|
|
const childrenOf = useMemo(() => {
|
|
const map = new Map<string, PickerFolder[]>();
|
|
for (const folder of folders) {
|
|
if (excludeIds?.has(folder.id)) continue;
|
|
const key = folder.parentId ?? ROOT;
|
|
const list = map.get(key) ?? [];
|
|
list.push(folder);
|
|
map.set(key, list);
|
|
}
|
|
for (const list of map.values()) list.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
|
return map;
|
|
}, [folders, excludeIds]);
|
|
|
|
// The rows on screen: the root, then every folder whose parents are open.
|
|
const rows = useMemo(() => {
|
|
const out: { id: string; name: string; depth: number; hasChildren: boolean }[] = [
|
|
{ id: ROOT, name: t("my_files"), depth: 0, hasChildren: (childrenOf.get(ROOT)?.length ?? 0) > 0 },
|
|
];
|
|
const walk = (parent: string, depth: number) => {
|
|
if (!expanded.has(parent)) return;
|
|
for (const folder of childrenOf.get(parent) ?? []) {
|
|
out.push({ id: folder.id, name: folder.name, depth, hasChildren: (childrenOf.get(folder.id)?.length ?? 0) > 0 });
|
|
walk(folder.id, depth + 1);
|
|
}
|
|
};
|
|
walk(ROOT, 1);
|
|
return out;
|
|
}, [childrenOf, expanded, t]);
|
|
|
|
const current = currentId ?? ROOT;
|
|
const canPick = selected !== null && selected !== current;
|
|
|
|
const toggle = (id: string, open?: boolean) => setExpanded((prev) => {
|
|
const next = new Set(prev);
|
|
if (open ?? !next.has(id)) next.add(id);
|
|
else next.delete(id);
|
|
return next;
|
|
});
|
|
|
|
const focusRow = (id: string) => {
|
|
setSelected(id);
|
|
listRef.current?.querySelector<HTMLElement>(`[data-folder-id="${CSS.escape(id)}"]`)?.focus();
|
|
};
|
|
|
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
const index = rows.findIndex((r) => r.id === (selected ?? current));
|
|
const row = rows[index];
|
|
const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
|
|
const openKey = rtl ? "ArrowLeft" : "ArrowRight";
|
|
const closeKey = rtl ? "ArrowRight" : "ArrowLeft";
|
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
const next = rows[Math.max(0, Math.min(rows.length - 1, index + (e.key === "ArrowDown" ? 1 : -1)))];
|
|
if (next) focusRow(next.id);
|
|
} else if (e.key === openKey && row?.hasChildren) {
|
|
e.preventDefault();
|
|
toggle(row.id, true);
|
|
} else if (e.key === closeKey && row) {
|
|
e.preventDefault();
|
|
if (row.hasChildren && expanded.has(row.id)) toggle(row.id, false);
|
|
}
|
|
};
|
|
|
|
const pick = () => {
|
|
if (!canPick) return;
|
|
onPick(selected === ROOT ? null : selected);
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<Dialog
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
title={title}
|
|
onSubmit={(e) => { e.preventDefault(); pick(); }}
|
|
footer={
|
|
<>
|
|
<Button type="button" variant="outline" onClick={onClose}>{t("cancel")}</Button>
|
|
<Button type="submit" disabled={!canPick}>{confirmText}</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div
|
|
ref={listRef}
|
|
role="tree"
|
|
aria-label={t("folders")}
|
|
onKeyDown={onKeyDown}
|
|
className="mt-4 max-h-80 overflow-y-auto rounded-md border border-border py-1"
|
|
>
|
|
{rows.map((row) => {
|
|
const isOpen = expanded.has(row.id);
|
|
const isSelected = (selected ?? current) === row.id;
|
|
const isCurrent = row.id === current;
|
|
const Icon = row.id === ROOT ? HardDrive : isOpen && row.hasChildren ? FolderOpen : Folder;
|
|
return (
|
|
<div
|
|
key={row.id || "root"}
|
|
role="treeitem"
|
|
aria-selected={isSelected}
|
|
aria-expanded={row.hasChildren ? isOpen : undefined}
|
|
aria-disabled={isCurrent || undefined}
|
|
data-folder-id={row.id}
|
|
tabIndex={isSelected ? 0 : -1}
|
|
onClick={() => setSelected(row.id)}
|
|
onDoubleClick={() => { if (row.id !== current) { onPick(row.id === ROOT ? null : row.id); onClose(); } }}
|
|
className={cn(
|
|
"flex items-center gap-1.5 pe-3 h-9 text-sm cursor-default select-none outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
|
isSelected ? "bg-selection" : "hover:bg-muted",
|
|
isCurrent && "text-muted-foreground",
|
|
)}
|
|
style={{ paddingInlineStart: `${8 + row.depth * 16}px` }}
|
|
>
|
|
{row.hasChildren ? (
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
aria-label={isOpen ? t("collapse_folder", { name: row.name }) : t("expand_folder", { name: row.name })}
|
|
onClick={(e) => { e.stopPropagation(); toggle(row.id); }}
|
|
className="p-0.5 rounded hover:bg-foreground/10 text-muted-foreground shrink-0"
|
|
>
|
|
{isOpen ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
|
</button>
|
|
) : (
|
|
<span className="w-[18px] shrink-0" />
|
|
)}
|
|
<Icon className="w-4 h-4 shrink-0 text-primary" />
|
|
<span className="truncate" title={row.name}>{row.name}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Dialog>
|
|
);
|
|
}
|