278 lines
7.6 KiB
TypeScript
278 lines
7.6 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { CSSProperties, ReactNode, FormEvent, MutableRefObject } from 'react';
|
|
import { PlusIcon } from './icons';
|
|
|
|
import useFloatingMenu from './useFloatingMenu';
|
|
|
|
type QuickAddOption = string | { id?: string; label?: string; name?: string;[key: string]: unknown };
|
|
|
|
interface NormalizedOption {
|
|
id?: string;
|
|
label: string;
|
|
original: QuickAddOption;
|
|
index: number;
|
|
}
|
|
|
|
const normalizeOption = (option: QuickAddOption | null, index: number): NormalizedOption | null => {
|
|
if (option == null) {
|
|
return null;
|
|
}
|
|
if (typeof option === 'object') {
|
|
const label = option.label ?? option.name;
|
|
if (label == null) {
|
|
return null;
|
|
}
|
|
return {
|
|
id: option.id ?? label,
|
|
label: String(label),
|
|
original: option,
|
|
index,
|
|
};
|
|
}
|
|
const label = String(option);
|
|
return {
|
|
id: label,
|
|
label,
|
|
original: option,
|
|
index,
|
|
};
|
|
};
|
|
|
|
interface FloatingMenuState {
|
|
isOpen: boolean;
|
|
toggle: () => void;
|
|
close: () => void;
|
|
menuRef: MutableRefObject<HTMLDivElement | null>;
|
|
menuStyle: CSSProperties | null;
|
|
updatePosition: () => void;
|
|
}
|
|
|
|
type CSSVarStyle = CSSProperties & Record<string, string>;
|
|
|
|
interface QuickAddMenuProps {
|
|
onSelectOption?: (value: QuickAddOption, normalized: NormalizedOption) => Promise<void> | void;
|
|
onCreate?: (value: string) => Promise<void> | void;
|
|
options?: QuickAddOption[];
|
|
placeholder?: string;
|
|
createLabel?: string;
|
|
emptyMessage?: string;
|
|
className?: string;
|
|
triggerAriaLabel?: string;
|
|
triggerTitle?: string;
|
|
renderOption?: (value: QuickAddOption, normalized: NormalizedOption) => ReactNode;
|
|
menuMinWidth?: number;
|
|
triggerClassName?: string;
|
|
triggerContent?: ReactNode;
|
|
disabled?: boolean;
|
|
align?: 'start' | 'center' | 'end' | (string & {});
|
|
positionStrategy?: 'fixed' | 'absolute' | (string & {});
|
|
}
|
|
|
|
const QuickAddMenu = ({
|
|
onSelectOption,
|
|
onCreate,
|
|
options = [],
|
|
placeholder = 'Search or create…',
|
|
createLabel = 'Add',
|
|
emptyMessage = 'No matches',
|
|
className,
|
|
triggerAriaLabel = 'Add item',
|
|
triggerTitle = 'Add',
|
|
renderOption,
|
|
menuMinWidth = 220,
|
|
triggerClassName = 'icon-button quick-add__trigger',
|
|
triggerContent = null,
|
|
disabled = false,
|
|
align = 'start',
|
|
positionStrategy = 'fixed',
|
|
}: QuickAddMenuProps) => {
|
|
const anchorRef = useRef(null);
|
|
const inputRef = useRef(null);
|
|
const [query, setQuery] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const {
|
|
isOpen,
|
|
toggle,
|
|
close,
|
|
menuRef,
|
|
menuStyle,
|
|
updatePosition,
|
|
} = useFloatingMenu({
|
|
anchorRef,
|
|
minWidth: menuMinWidth,
|
|
matchAnchorWidth: false,
|
|
align,
|
|
positionStrategy,
|
|
}) as FloatingMenuState;
|
|
|
|
useEffect(() => {
|
|
if (disabled && isOpen) {
|
|
close();
|
|
}
|
|
}, [disabled, isOpen, close]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
return undefined;
|
|
}
|
|
setQuery('');
|
|
setSubmitting(false);
|
|
|
|
const frame = requestAnimationFrame(() => {
|
|
inputRef.current?.focus();
|
|
inputRef.current?.select?.();
|
|
updatePosition();
|
|
});
|
|
|
|
return () => cancelAnimationFrame(frame);
|
|
}, [isOpen, updatePosition]);
|
|
|
|
const normalizedOptions = useMemo<NormalizedOption[]>(
|
|
() =>
|
|
options
|
|
.map((option, index) => normalizeOption(option, index))
|
|
.filter((option): option is NormalizedOption => Boolean(option)),
|
|
[options],
|
|
);
|
|
|
|
const filteredOptions = useMemo(() => {
|
|
if (!query.trim()) {
|
|
return normalizedOptions;
|
|
}
|
|
const search = query.trim().toLowerCase();
|
|
return normalizedOptions.filter((option) => option.label.toLowerCase().includes(search));
|
|
}, [normalizedOptions, query]);
|
|
|
|
const handleSelect = useCallback(
|
|
async (option: NormalizedOption) => {
|
|
if (!option || !onSelectOption) {
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
try {
|
|
await onSelectOption(option.original ?? option.label, option);
|
|
setSubmitting(false);
|
|
close();
|
|
} catch (error) {
|
|
setSubmitting(false);
|
|
console.error('[quick-add] option selection failed', error);
|
|
}
|
|
},
|
|
[close, onSelectOption],
|
|
);
|
|
|
|
const handleCreate = useCallback(
|
|
async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
if (!onCreate) {
|
|
return;
|
|
}
|
|
const value = query.trim();
|
|
if (!value) {
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
try {
|
|
await onCreate(value);
|
|
setSubmitting(false);
|
|
close();
|
|
} catch (error) {
|
|
setSubmitting(false);
|
|
console.error('[quick-add] creation failed', error);
|
|
}
|
|
},
|
|
[close, onCreate, query],
|
|
);
|
|
|
|
const canCreate = Boolean(onCreate);
|
|
const isAnchoredMenu = positionStrategy === 'absolute' && align === 'start';
|
|
const menuClassName = 'menu menu--floating';
|
|
const anchoredMenuStyle = isAnchoredMenu && menuStyle
|
|
? {
|
|
top: menuStyle.top,
|
|
left: menuStyle.left,
|
|
...(menuStyle.width ? { width: menuStyle.width } : null),
|
|
}
|
|
: undefined;
|
|
const menuInlineStyle = (isAnchoredMenu ? anchoredMenuStyle : menuStyle || undefined) as CSSVarStyle | undefined;
|
|
const hasFloatingWidthVar = Boolean(menuInlineStyle && Object.prototype.hasOwnProperty.call(menuInlineStyle, '--floating-min-width'));
|
|
const menuStyleWithVar: CSSVarStyle | undefined = hasFloatingWidthVar
|
|
? menuInlineStyle
|
|
: {
|
|
...(menuInlineStyle || {}),
|
|
'--floating-min-width': `${Math.max(menuMinWidth, 0)}px`,
|
|
};
|
|
|
|
return (
|
|
<div className={className ? `quick-add ${className}` : 'quick-add'}>
|
|
<button
|
|
type="button"
|
|
ref={anchorRef}
|
|
className={triggerClassName}
|
|
aria-haspopup="menu"
|
|
aria-expanded={isOpen}
|
|
onClick={toggle}
|
|
aria-label={triggerAriaLabel}
|
|
title={triggerTitle}
|
|
disabled={disabled}
|
|
>
|
|
{triggerContent ?? <PlusIcon />}
|
|
</button>
|
|
{isOpen ? (
|
|
<div
|
|
className={menuClassName}
|
|
ref={menuRef}
|
|
style={menuStyleWithVar}
|
|
role="menu"
|
|
data-floating-position
|
|
>
|
|
{canCreate ? (
|
|
<form className="quick-add__form" onSubmit={handleCreate}>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder={placeholder}
|
|
disabled={submitting}
|
|
aria-label={placeholder}
|
|
/>
|
|
<button type="submit" disabled={submitting || !query.trim()}>
|
|
{createLabel}
|
|
</button>
|
|
</form>
|
|
) : null}
|
|
<div className="menu__list" role="presentation">
|
|
{filteredOptions.length ? (
|
|
filteredOptions.map((option) => {
|
|
const key = option.id ?? option.index;
|
|
return (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
className="menu__item"
|
|
role="menuitem"
|
|
onClick={() => handleSelect(option)}
|
|
disabled={submitting}
|
|
>
|
|
{renderOption ? (
|
|
renderOption(option.original ?? option.label, option)
|
|
) : (
|
|
<span className="menu__label">{option.label}</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})
|
|
) : (
|
|
<div className="menu__empty">{emptyMessage}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default QuickAddMenu;
|