370 lines
11 KiB
TypeScript
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;
|