typescript
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
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 | null;
|
||||
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';
|
||||
}
|
||||
|
||||
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';
|
||||
return {
|
||||
id: item.id ?? trimmedLabel,
|
||||
label: trimmedLabel,
|
||||
state,
|
||||
count: typeof item.count === 'number' ? item.count : null,
|
||||
total: typeof item.total === 'number' ? item.total : null,
|
||||
payload: item.payload ?? item,
|
||||
};
|
||||
})
|
||||
.filter((item): item is NormalizedSelectionAssignmentItem => Boolean(item));
|
||||
|
||||
const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
|
||||
label,
|
||||
items = [],
|
||||
placeholder = 'Search…',
|
||||
emptyMessage = 'No entries',
|
||||
createLabel = null,
|
||||
onToggle,
|
||||
onCreate,
|
||||
disabled = false,
|
||||
className,
|
||||
triggerContent = null,
|
||||
triggerClassName = 'quick-add__chip quick-add__trigger panel-floating-actions__trigger',
|
||||
showStateIndicators = true,
|
||||
showCounts = true,
|
||||
onOpenMenu = null,
|
||||
renderItemLabel = null,
|
||||
positionStrategy = 'absolute',
|
||||
}) => {
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
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 filteredItems = useMemo(() => {
|
||||
const search = query.trim().toLowerCase();
|
||||
const sorted = 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);
|
||||
});
|
||||
if (!search) {
|
||||
return sorted;
|
||||
}
|
||||
return sorted.filter((item) => item.label.toLowerCase().includes(search));
|
||||
}, [normalizedItems, query]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (item: NormalizedSelectionAssignmentItem) => {
|
||||
if (!item || typeof onToggle !== 'function') {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
try {
|
||||
await onToggle(item);
|
||||
setPending(false);
|
||||
close();
|
||||
} catch (error) {
|
||||
setPending(false);
|
||||
console.error('[selection-assignment] toggle failed', error);
|
||||
}
|
||||
},
|
||||
[onToggle, close],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault?.();
|
||||
if (typeof onCreate !== 'function') {
|
||||
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 || 'Add'}
|
||||
title={createLabel || 'Add'}
|
||||
>
|
||||
<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;
|
||||
Reference in New Issue
Block a user