typescript
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
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 | number | { id?: string | number; label?: string; name?: string; [key: string]: unknown };
|
||||
|
||||
interface NormalizedOption {
|
||||
id?: string | number;
|
||||
label: string;
|
||||
original: QuickAddOption;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const normalizeOption = (option: QuickAddOption | null | undefined, 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 | number>;
|
||||
|
||||
export 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;
|
||||
Reference in New Issue
Block a user