223 lines
6.3 KiB
TypeScript
223 lines
6.3 KiB
TypeScript
import clsx from 'clsx/lite';
|
|
import { ReactNode, useEffect, useRef, useState } from 'react';
|
|
import MaskedScroll from './MaskedScroll';
|
|
import useClickInsideOutside from '@/utility/useClickInsideOutside';
|
|
import IconSelectChevron from './icons/IconSelectChevron';
|
|
import SelectMenuOption, { SelectMenuOptionType } from './SelectMenuOption';
|
|
|
|
const LISTENER_KEY_MOUSE_MOVE = 'mousemove';
|
|
const LISTENER_KEY_KEYDOWN = 'keydown';
|
|
|
|
export default function SelectMenu({
|
|
id,
|
|
name,
|
|
value,
|
|
className,
|
|
onChange,
|
|
options,
|
|
defaultOptionLabel,
|
|
tabIndex,
|
|
error,
|
|
readOnly,
|
|
children,
|
|
}: {
|
|
id?: string
|
|
name: string
|
|
value: string
|
|
className?: string
|
|
onChange?: (value: string) => void
|
|
options: SelectMenuOptionType[]
|
|
defaultOptionLabel?: string
|
|
tabIndex?: number
|
|
error?: string
|
|
readOnly?: boolean
|
|
children?: ReactNode
|
|
}) {
|
|
const ARIA_ID_SELECT_OPTIONS = `select-options-${name}`;
|
|
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [selectedOptionIndex, setSelectedOptionIndex] = useState<number>();
|
|
const [shouldHighlightOnHover, setShouldHighlightOnHover] = useState(true);
|
|
|
|
const selectedOption = options.find(o => o.value === value);
|
|
|
|
useClickInsideOutside({
|
|
htmlElements: [ref],
|
|
onClickOutside: () => setIsOpen(false),
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (readOnly) {
|
|
setIsOpen(false);
|
|
}
|
|
}, [readOnly]);
|
|
|
|
// Setup keyboard listener
|
|
useEffect(() => {
|
|
const listener = (e: KeyboardEvent) => {
|
|
// Keys which always trap focus
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
case 'ArrowUp':
|
|
case 'Escape':
|
|
setShouldHighlightOnHover(false);
|
|
e.stopImmediatePropagation();
|
|
e.preventDefault();
|
|
}
|
|
// Navigate options
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
if (isOpen) {
|
|
setSelectedOptionIndex(i => {
|
|
if (i === undefined) {
|
|
return options.length > 1 ? 1 : 0;
|
|
} else if (i >= options.length - 1) {
|
|
return 0;
|
|
} else {
|
|
return i + 1;
|
|
}
|
|
});
|
|
} else {
|
|
setIsOpen(true);
|
|
setSelectedOptionIndex(0);
|
|
}
|
|
break;
|
|
case 'ArrowUp':
|
|
if (isOpen) {
|
|
setSelectedOptionIndex((i = 0) => {
|
|
if (options.length > 1) {
|
|
if (i === 0) {
|
|
return options.length - 1;
|
|
} else {
|
|
return i - 1;
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
setIsOpen(true);
|
|
setSelectedOptionIndex(Math.max(0, options.length - 1));
|
|
}
|
|
break;
|
|
case 'Enter':
|
|
if (isOpen) {
|
|
if (selectedOptionIndex !== undefined) {
|
|
onChange?.(options[selectedOptionIndex].value);
|
|
}
|
|
setIsOpen(false);
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
setIsOpen(false);
|
|
break;
|
|
}
|
|
};
|
|
|
|
const refRef = ref.current;
|
|
refRef?.addEventListener(LISTENER_KEY_KEYDOWN, listener);
|
|
return () => refRef?.removeEventListener(LISTENER_KEY_KEYDOWN, listener);
|
|
}, [
|
|
isOpen,
|
|
options,
|
|
selectedOptionIndex,
|
|
onChange,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const onMouseMove = () => {
|
|
setShouldHighlightOnHover(true);
|
|
};
|
|
const refRef = ref.current;
|
|
refRef?.addEventListener(LISTENER_KEY_MOUSE_MOVE, onMouseMove);
|
|
return () =>
|
|
refRef?.removeEventListener(LISTENER_KEY_MOUSE_MOVE, onMouseMove);
|
|
}, []);
|
|
|
|
return (
|
|
<div ref={ref} className={className}>
|
|
<div
|
|
tabIndex={tabIndex}
|
|
className={clsx(
|
|
'cursor-pointer control pl-1.5 py-2',
|
|
'flex items-center w-full h-9.5',
|
|
'focus:outline-2 -outline-offset-2 focus:outline-blue-600',
|
|
'select-none',
|
|
Boolean(error) && 'error',
|
|
readOnly && 'disabled-select',
|
|
)}
|
|
onMouseDown={() => setIsOpen(o => !o)}
|
|
onFocus={() => setIsOpen(true)}
|
|
onBlur={e => {
|
|
if (e.relatedTarget && !ref.current?.contains(e.relatedTarget)) {
|
|
setIsOpen(false);
|
|
}
|
|
}}
|
|
aria-autocomplete="list"
|
|
aria-expanded={isOpen}
|
|
aria-haspopup="true"
|
|
aria-controls={isOpen ? ARIA_ID_SELECT_OPTIONS : undefined}
|
|
role="combobox"
|
|
>
|
|
{children ?? <div className="flex items-center w-full">
|
|
<div className="grow min-w-0">
|
|
<SelectMenuOption
|
|
value={value}
|
|
label={selectedOption?.label}
|
|
accessoryStart={selectedOption?.accessoryStart}
|
|
/>
|
|
</div>
|
|
<IconSelectChevron
|
|
className={clsx(
|
|
'shrink-0',
|
|
isOpen && 'rotate-180 transition-transform duration-200',
|
|
)}
|
|
/>
|
|
</div>}
|
|
<input id={id} type="hidden" name={name} value={value} />
|
|
</div>
|
|
<div className="relative">
|
|
{isOpen &&
|
|
<div
|
|
className={clsx(
|
|
'component-surface z-1',
|
|
'absolute top-3 w-full px-1.5 py-1.5',
|
|
'max-h-[12rem] overflow-y-auto flex flex-col',
|
|
'shadow-lg dark:shadow-xl',
|
|
'animate-fade-in-from-top',
|
|
'*:select-none',
|
|
)}
|
|
>
|
|
<MaskedScroll
|
|
id={ARIA_ID_SELECT_OPTIONS}
|
|
role="listbox"
|
|
className="flex flex-col gap-1"
|
|
fadeSize={16}
|
|
>
|
|
{defaultOptionLabel &&
|
|
<SelectMenuOption value="" label={defaultOptionLabel} />}
|
|
{options.map((option, index) =>
|
|
<SelectMenuOption
|
|
key={option.value}
|
|
value={option.value}
|
|
label={option.label}
|
|
accessoryStart={option.accessoryStart}
|
|
accessoryEnd={option.accessoryEnd}
|
|
note={option.note}
|
|
isSelected={option.value === value}
|
|
isHighlighted={
|
|
index === selectedOptionIndex ||
|
|
(selectedOptionIndex === undefined && index === 0)}
|
|
shouldHighlightOnHover={shouldHighlightOnHover}
|
|
onClick={() => {
|
|
onChange?.(option.value);
|
|
setIsOpen(false);
|
|
}}
|
|
/>)}
|
|
</MaskedScroll>
|
|
</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|