63 lines
1.9 KiB
TypeScript
63 lines
1.9 KiB
TypeScript
import { ChevronDown } from "lucide-react";
|
|
import type { FocusEvent } from "react";
|
|
import { useMemo, useState } from "react";
|
|
|
|
export type SelectOption = {
|
|
value: string;
|
|
label: string;
|
|
};
|
|
|
|
type SelectFieldProps = {
|
|
label?: string;
|
|
value: string;
|
|
options: SelectOption[];
|
|
placeholder?: string;
|
|
onChange: (value: string) => void;
|
|
};
|
|
|
|
export function SelectField({ label, value, options, placeholder = "Select", onChange }: SelectFieldProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const selectedOption = useMemo(() => options.find((option) => option.value === value), [options, value]);
|
|
const closeOnOutsideBlur = (event: FocusEvent<HTMLDivElement>) => {
|
|
const nextTarget = event.relatedTarget;
|
|
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
const control = (
|
|
<div className="select-field" onBlur={closeOnOutsideBlur}>
|
|
<button className={`select-trigger ${open ? "open" : ""}`} type="button" aria-haspopup="listbox" aria-expanded={open} onClick={() => setOpen(!open)}>
|
|
<span>{selectedOption?.label ?? placeholder}</span>
|
|
<ChevronDown size={16} />
|
|
</button>
|
|
{open && (
|
|
<div className="select-menu" role="listbox">
|
|
{options.map((option) => (
|
|
<button
|
|
className={option.value === value ? "select-option active" : "select-option"}
|
|
key={option.value}
|
|
role="option"
|
|
type="button"
|
|
aria-selected={option.value === value}
|
|
onClick={() => {
|
|
onChange(option.value);
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
if (!label) return control;
|
|
return (
|
|
<label>
|
|
{label}
|
|
{control}
|
|
</label>
|
|
);
|
|
}
|