145 lines
3.7 KiB
TypeScript
145 lines
3.7 KiB
TypeScript
import { useEffect, useId, useRef, useState } from 'react';
|
|
|
|
export type SelectOption = {
|
|
value: string;
|
|
label: string;
|
|
};
|
|
|
|
export type SelectOptionGroup = {
|
|
label: string;
|
|
options: SelectOption[];
|
|
};
|
|
|
|
type SelectFieldProps = {
|
|
id?: string;
|
|
label: string;
|
|
value: string;
|
|
options: SelectOption[];
|
|
groups?: SelectOptionGroup[];
|
|
disabled?: boolean;
|
|
onChange: (value: string) => void;
|
|
};
|
|
|
|
export function SelectField({
|
|
id,
|
|
label,
|
|
value,
|
|
options,
|
|
groups = [],
|
|
disabled = false,
|
|
onChange,
|
|
}: SelectFieldProps) {
|
|
const generatedId = useId();
|
|
const fieldId = id ?? generatedId;
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
const selectedOption = findOption(value, options, groups);
|
|
|
|
useEffect(() => {
|
|
function handlePointerDown(event: PointerEvent) {
|
|
if (!rootRef.current?.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
if (event.key === 'Escape') {
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('pointerdown', handlePointerDown);
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
|
|
return () => {
|
|
document.removeEventListener('pointerdown', handlePointerDown);
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, []);
|
|
|
|
function handleSelect(nextValue: string) {
|
|
onChange(nextValue);
|
|
setIsOpen(false);
|
|
}
|
|
|
|
return (
|
|
<div className="select-field" ref={rootRef}>
|
|
<label id={`${fieldId}-label`} htmlFor={fieldId}>
|
|
{label}
|
|
</label>
|
|
<button
|
|
id={fieldId}
|
|
type="button"
|
|
className="select-trigger"
|
|
aria-haspopup="listbox"
|
|
aria-expanded={isOpen}
|
|
aria-labelledby={`${fieldId}-label ${fieldId}`}
|
|
disabled={disabled}
|
|
onClick={() => setIsOpen((current) => !current)}
|
|
>
|
|
<span>{selectedOption?.label ?? 'Choose an option'}</span>
|
|
<span className="select-chevron" aria-hidden="true" />
|
|
</button>
|
|
{isOpen ? (
|
|
<div className="select-menu" role="listbox" aria-labelledby={`${fieldId}-label`}>
|
|
{options.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
option={option}
|
|
isSelected={option.value === value}
|
|
onSelect={handleSelect}
|
|
/>
|
|
))}
|
|
{groups.map((group) => (
|
|
<div className="select-group" key={group.label}>
|
|
<div className="select-group-label">{group.label}</div>
|
|
{group.options.map((option) => (
|
|
<SelectItem
|
|
key={`${group.label}-${option.value}`}
|
|
option={option}
|
|
isSelected={option.value === value}
|
|
onSelect={handleSelect}
|
|
/>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SelectItem({
|
|
option,
|
|
isSelected,
|
|
onSelect,
|
|
}: {
|
|
option: SelectOption;
|
|
isSelected: boolean;
|
|
onSelect: (value: string) => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={isSelected ? 'select-option select-option-selected' : 'select-option'}
|
|
role="option"
|
|
aria-selected={isSelected}
|
|
onClick={() => onSelect(option.value)}
|
|
>
|
|
<span>{option.label}</span>
|
|
{isSelected ? <span className="select-check" aria-hidden="true" /> : null}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function findOption(
|
|
value: string,
|
|
options: SelectOption[],
|
|
groups: SelectOptionGroup[],
|
|
): SelectOption | undefined {
|
|
return (
|
|
options.find((option) => option.value === value) ??
|
|
groups.flatMap((group) => group.options).find((option) => option.value === value)
|
|
);
|
|
}
|