Files
papercrate/frontend/src/documents/SelectionAssignmentMenu.tsx
T
2025-11-16 00:39:37 +01:00

370 lines
11 KiB
TypeScript

import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import useFloatingMenu from '../ui/useFloatingMenu';
import { CheckIcon, CircleDashedCheckIcon, PlusIcon } from '../ui/icons';
export type AssignmentState = 'all' | 'partial' | 'none';
export interface SelectionAssignmentMenuItem {
id?: string | number;
label?: string;
state?: AssignmentState;
count?: number | null;
total?: number | null;
color?: string | null;
value?: string | number;
payload?: unknown;
}
export interface NormalizedSelectionAssignmentItem {
id: string | number;
label: string;
state: AssignmentState;
count: number | null;
total: number | null;
payload: unknown;
}
export interface SelectionAssignmentMenuProps {
label: React.ReactNode;
items?: SelectionAssignmentMenuItem[];
placeholder?: string;
emptyMessage?: string;
createLabel?: string;
onToggle?: (item: NormalizedSelectionAssignmentItem) => Promise<void> | void;
onCreate?: (value: string) => Promise<void> | void;
disabled?: boolean;
className?: string;
triggerContent?: React.ReactNode;
triggerClassName?: string;
showStateIndicators?: boolean;
showCounts?: boolean;
onOpenMenu?: () => void;
renderItemLabel?: (item: NormalizedSelectionAssignmentItem) => React.ReactNode;
positionStrategy?: 'absolute' | 'fixed';
closeOnSelection?: boolean;
sortByState?: boolean;
freezeSortOnOpen?: boolean;
}
const STATE_ORDER: Record<AssignmentState, number> = {
all: 0,
partial: 1,
none: 2,
};
const normalizeItems = (items?: SelectionAssignmentMenuItem[]): NormalizedSelectionAssignmentItem[] =>
(Array.isArray(items) ? items : [])
.map<NormalizedSelectionAssignmentItem | null>((item) => {
if (!item) {
return null;
}
const trimmedLabel = item.label?.trim?.() || '';
if (!trimmedLabel) {
return null;
}
const state: AssignmentState = item.state === 'all'
? 'all'
: item.state === 'partial'
? 'partial'
: 'none';
const numericCount = Number.isFinite(item.count) ? Number(item.count) : null;
const numericTotal = Number.isFinite(item.total) ? Number(item.total) : null;
return {
id: item.id ?? trimmedLabel,
label: trimmedLabel,
state,
count: numericCount,
total: numericTotal,
payload: item.payload ?? item,
};
})
.filter((item): item is NormalizedSelectionAssignmentItem => Boolean(item));
const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
label,
items = [],
placeholder = 'Search…',
emptyMessage = 'No entries',
createLabel = 'Add',
onToggle,
onCreate,
disabled = false,
className,
triggerContent = null,
triggerClassName = 'quick-add__chip quick-add__trigger panel-floating-actions__trigger',
showStateIndicators = true,
showCounts = true,
onOpenMenu,
renderItemLabel,
positionStrategy = 'absolute',
closeOnSelection = true,
sortByState = true,
freezeSortOnOpen = false,
}) => {
const anchorRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState('');
const [pending, setPending] = useState(false);
const [sortSnapshot, setSortSnapshot] = useState<Array<string | number> | null>(null);
const {
isOpen,
toggle,
close,
menuRef,
menuStyle,
updatePosition,
} = useFloatingMenu({
anchorRef,
align: 'center',
positionStrategy,
minWidth: 220,
}) 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('');
setPending(false);
const frame = requestAnimationFrame(() => {
updatePosition();
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select?.();
}
});
return () => cancelAnimationFrame(frame);
}, [isOpen, updatePosition]);
const normalizedItems = useMemo(() => normalizeItems(items), [items]);
const sortedByStateItems = useMemo(() => {
if (!sortByState) {
return normalizedItems;
}
return normalizedItems.slice().sort((a, b) => {
const stateDiff = STATE_ORDER[a.state] - STATE_ORDER[b.state];
if (stateDiff !== 0) {
return stateDiff;
}
return a.label.localeCompare(b.label);
});
}, [normalizedItems, sortByState]);
useEffect(() => {
if (!isOpen || !freezeSortOnOpen || !sortByState) {
setSortSnapshot(null);
return;
}
setSortSnapshot((prev) => prev ?? sortedByStateItems.map((item) => item.id));
}, [isOpen, freezeSortOnOpen, sortByState, sortedByStateItems]);
const orderedItems = useMemo(() => {
if (freezeSortOnOpen && sortSnapshot && sortByState) {
const itemMap = new Map<string | number, NormalizedSelectionAssignmentItem>(
sortedByStateItems.map((item) => [item.id, item]),
);
const seen = new Set<string | number>();
const fromSnapshot = sortSnapshot
.map((id) => {
const entry = itemMap.get(id);
if (entry) {
seen.add(entry.id);
}
return entry || null;
})
.filter((entry): entry is NormalizedSelectionAssignmentItem => Boolean(entry));
const remaining = sortedByStateItems.filter((item) => !seen.has(item.id));
return [...fromSnapshot, ...remaining];
}
return sortedByStateItems;
}, [freezeSortOnOpen, sortSnapshot, sortByState, sortedByStateItems]);
const filteredItems = useMemo(() => {
const search = query.trim().toLowerCase();
if (!search) {
return orderedItems;
}
return orderedItems.filter((item) => item.label.toLowerCase().includes(search));
}, [orderedItems, query]);
const handleToggle = useCallback(
async (item: NormalizedSelectionAssignmentItem) => {
if (!onToggle) {
return;
}
setPending(true);
try {
await onToggle(item);
setPending(false);
if (closeOnSelection) {
close();
}
} catch (error) {
setPending(false);
console.error('[selection-assignment] toggle failed', error);
}
},
[onToggle, close, closeOnSelection],
);
const handleCreate = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault?.();
if (!onCreate) {
return;
}
const value = query.trim();
if (!value) {
return;
}
setPending(true);
try {
await onCreate(value);
setPending(false);
close();
} catch (error) {
setPending(false);
console.error('[selection-assignment] creation failed', error);
}
},
[onCreate, query, close],
);
const existingLabels = useMemo(
() => new Set(normalizedItems.map((item) => item.label.toLowerCase())),
[normalizedItems],
);
const canCreate = Boolean(onCreate);
const trimmedQuery = query.trim();
const queryKey = trimmedQuery.toLowerCase();
const canSubmitCreate = canCreate
&& trimmedQuery.length > 0
&& !existingLabels.has(queryKey)
&& !pending;
const handleTriggerClick = useCallback(() => {
if (disabled) {
return;
}
if (!isOpen) {
onOpenMenu?.();
}
toggle();
}, [disabled, isOpen, onOpenMenu, toggle]);
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">
<form className="selection-assignment__form" onSubmit={handleCreate}>
<input
ref={inputRef}
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
aria-label={placeholder}
disabled={pending}
/>
{canCreate ? (
<button
type="submit"
className="icon-button selection-assignment__add"
disabled={!canSubmitCreate}
aria-label={createLabel}
title={createLabel}
>
<PlusIcon aria-hidden="true" />
</button>
) : null}
</form>
</div>
<div className="selection-assignment__list" role="presentation">
{filteredItems.length ? (
filteredItems.map((item) => {
const isAll = item.state === 'all';
const isPartial = item.state === 'partial';
const icon = showStateIndicators
? isAll
? <CheckIcon className="selection-assignment__icon" aria-hidden="true" />
: isPartial
? <CircleDashedCheckIcon className="selection-assignment__icon" aria-hidden="true" />
: <span className="selection-assignment__icon selection-assignment__icon--empty" aria-hidden="true" />
: null;
const countLabel = showCounts && item.total && (isPartial || isAll)
? `${item.count ?? 0}/${item.total}`
: null;
const labelContent = renderItemLabel ? renderItemLabel(item) : item.label;
const labelClassName = [
'selection-assignment__label',
(!showStateIndicators || !icon) ? 'selection-assignment__label--nowrap' : null,
].filter(Boolean).join(' ');
return (
<button
key={item.id}
type="button"
className={`menu__item selection-assignment__item selection-assignment__item--${item.state}`}
onClick={() => handleToggle(item)}
disabled={pending}
role="menuitem"
>
{icon}
<span className={labelClassName}>
{labelContent}
</span>
{countLabel ? (
<span className="selection-assignment__count">{countLabel}</span>
) : null}
</button>
);
})
) : (
<div className="menu__empty selection-assignment__empty">{emptyMessage}</div>
)}
</div>
</div>
) : null}
</div>
);
};
export default SelectionAssignmentMenu;