Files
papercrate/frontend/src/documents/SelectionFolderMenu.tsx
T

310 lines
12 KiB
TypeScript

import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import useFloatingMenu from '../ui/useFloatingMenu';
import {
ArrowLeftIcon,
FolderIcon,
FolderMoveIcon,
} from '../ui/icons';
import type { FolderTreeNode } from '../lib/apiTypes';
import type { DocumentId } from '../types/identifiers';
export interface SelectionFolderMenuProps {
label: React.ReactNode;
folderTree?: FolderTreeNode[];
onSelectFolder?: (folderId: DocumentId | null) => Promise<void> | void;
disabled?: boolean;
className?: string;
triggerContent?: React.ReactNode;
triggerClassName?: string;
placeholder?: string;
emptyMessage?: string;
onOpenMenu?: () => void;
positionStrategy?: 'absolute' | 'fixed';
rootTitle?: string;
}
const SelectionFolderMenu: React.FC<SelectionFolderMenuProps> = ({
label,
folderTree = [],
onSelectFolder,
disabled = false,
className,
triggerContent = null,
triggerClassName = 'quick-add__chip quick-add__trigger panel-floating-actions__trigger',
placeholder = 'Search folders…',
emptyMessage = 'No folders',
onOpenMenu,
positionStrategy = 'absolute',
rootTitle = 'Folders',
}) => {
const anchorRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState('');
const [currentFolderId, setCurrentFolderId] = useState<DocumentId | null>(null);
const [pending, setPending] = useState(false);
const {
isOpen,
toggle,
close,
menuRef,
menuStyle,
updatePosition,
} = useFloatingMenu({
anchorRef,
align: 'center',
positionStrategy,
minWidth: 260,
}) as {
isOpen: boolean;
toggle: () => void;
close: () => void;
menuRef: React.MutableRefObject<HTMLDivElement | null>;
menuStyle: CSSProperties | null;
updatePosition: () => void;
};
useEffect(() => {
if (disabled && isOpen) {
close();
}
}, [disabled, isOpen, close]);
useEffect(() => {
if (!isOpen) {
return undefined;
}
setQuery('');
setCurrentFolderId(null);
setPending(false);
const frame = requestAnimationFrame(() => {
updatePosition();
if (inputRef.current) {
inputRef.current.focus();
}
});
return () => cancelAnimationFrame(frame);
}, [isOpen, updatePosition]);
// Build a flat map for easy lookup
const { nodeMap, parentMap } = useMemo(() => {
const nMap = new Map<DocumentId, FolderTreeNode>();
const pMap = new Map<DocumentId, DocumentId>();
const traverse = (nodes: FolderTreeNode[], parentId: DocumentId | null) => {
nodes.forEach((node) => {
nMap.set(node.id, node);
if (parentId) {
pMap.set(node.id, parentId);
}
if (node.children) {
traverse(node.children, node.id);
}
});
};
traverse(folderTree, null);
return { nodeMap: nMap, parentMap: pMap };
}, [folderTree]);
const currentChildren = useMemo(() => {
const currentFolder = currentFolderId ? nodeMap.get(currentFolderId) : null;
return currentFolder ? currentFolder.children || [] : folderTree;
}, [currentFolderId, nodeMap, folderTree]);
const currentFolder = currentFolderId ? nodeMap.get(currentFolderId) : null;
// Filter items based on search query
// If searching, we might want to show flattened results matching the query?
// Or just filter current level?
// Usually, search implies searching the whole tree.
const isSearching = query.trim().length > 0;
const displayedItems = useMemo(() => {
if (isSearching) {
const search = query.trim().toLowerCase();
const results: FolderTreeNode[] = [];
nodeMap.forEach((node) => {
if (node.name.toLowerCase().includes(search)) {
results.push(node);
}
});
return results;
}
return currentChildren;
}, [isSearching, query, currentChildren, nodeMap]);
const handleTriggerClick = useCallback(() => {
if (disabled) {
return;
}
if (!isOpen) {
onOpenMenu?.();
}
toggle();
}, [disabled, isOpen, onOpenMenu, toggle]);
const handleSelect = useCallback(
async (folderId: DocumentId | null) => {
if (!onSelectFolder) return;
setPending(true);
try {
await onSelectFolder(folderId);
close();
} catch (error) {
console.error('Failed to move to folder', error);
} finally {
setPending(false);
}
},
[onSelectFolder, close]
);
const handleNavigate = (folderId: DocumentId) => {
setCurrentFolderId(folderId);
setQuery(''); // Clear search on navigation
if (inputRef.current) {
inputRef.current.focus();
}
};
const handleUp = () => {
if (!currentFolderId) return;
const parentId = parentMap.get(currentFolderId) || null;
setCurrentFolderId(parentId);
};
return (
<div className={className ? `selection-assignment ${className}` : 'selection-assignment'}>
<button
type="button"
ref={anchorRef}
className={triggerClassName}
onClick={handleTriggerClick}
aria-haspopup="menu"
aria-expanded={isOpen}
disabled={disabled}
>
{triggerContent ? triggerContent : (
<span className="quick-add__chip-label">
{label}
</span>
)}
</button>
{isOpen ? (
<div
className="menu menu--floating selection-assignment__menu"
ref={menuRef}
style={menuStyle || undefined}
role="menu"
data-floating-position
>
<div className="selection-assignment__header">
{/* Search Bar */}
<div className="selection-assignment__form">
<input
ref={inputRef}
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
aria-label={placeholder}
disabled={pending}
/>
</div>
{/* Navigation Header (only if not searching) */}
{!isSearching && (
<div className="selection-assignment__header-nav">
<div className="selection-assignment__nav-title">
{currentFolderId ? (
<button
type="button"
className="icon-button"
onClick={handleUp}
aria-label="Go up"
title="Go up"
>
<ArrowLeftIcon size="1em" />
</button>
) : null}
<span
className="selection-assignment__folder-name"
>
{currentFolder ? currentFolder.name : rootTitle}
</span>
</div>
<div className="selection-assignment__nav-actions">
<button
type="button"
className="icon-button"
onClick={() => handleSelect(currentFolderId)}
disabled={pending}
title="Move here"
aria-label="Move here"
>
<FolderMoveIcon size="1em" />
</button>
</div>
</div>
)}
</div>
<div className="selection-assignment__list" role="presentation">
{displayedItems.length ? (
displayedItems.map((item) => {
const hasChildren = item.children && item.children.length > 0;
return (
<div
key={item.id}
className="menu__item selection-assignment__item"
role="menuitem"
>
{/* Clickable area to navigate down */}
<button
type="button"
className="selection-assignment__item-content"
onClick={() => hasChildren && handleNavigate(item.id)}
style={{ cursor: hasChildren ? 'pointer' : 'default' }}
>
<FolderIcon className="selection-assignment__icon" aria-hidden="true" />
<span className="selection-assignment__folder-name">
{item.name}
{isSearching && parentMap.get(item.id) && (
<span className="selection-assignment__folder-path">
(in {nodeMap.get(parentMap.get(item.id)!)?.name})
</span>
)}
</span>
</button>
{/* Move Button for this specific folder */}
<div className="selection-assignment__item-actions">
<button
type="button"
className="icon-button"
onClick={(e) => {
e.stopPropagation();
handleSelect(item.id);
}}
title={`Move to ${item.name}`}
aria-label={`Move to ${item.name}`}
>
<FolderMoveIcon size="1em" />
</button>
</div>
</div>
);
})
) : (
<div className="menu__empty selection-assignment__empty">{emptyMessage}</div>
)}
</div>
</div>
) : null}
</div>
);
};
export default SelectionFolderMenu;