import {
Children,
isValidElement,
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
} from "react";
import { Check, ChevronDown } from "lucide-react";
export interface AppSelectChangeEvent {
target: {
value: string;
};
}
export interface AppSelectProps {
children: ReactNode;
className?: string;
disabled?: boolean;
id?: string;
onChange?: (event: AppSelectChangeEvent) => void;
menuClassName?: string;
optionClassName?: string;
placement?: "bottom" | "top";
title?: string;
value?: string | null;
"aria-label"?: string;
"aria-labelledby"?: string;
}
interface ParsedOption {
key: string;
value: string;
label: ReactNode;
disabled: boolean;
}
type OptionElement = ReactElement<{
value?: string;
children?: ReactNode;
disabled?: boolean;
}, "option">;
function cx(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(" ");
}
function parseOptions(children: ReactNode): ParsedOption[] {
const options: ParsedOption[] = [];
Children.forEach(children, (child, index) => {
if (!isValidElement(child) || child.type !== "option") {
return;
}
const option = child as OptionElement;
options.push({
key: option.key?.toString() ?? `option-${index}-${String(option.props.value ?? "")}`,
value: String(option.props.value ?? ""),
label: option.props.children,
disabled: Boolean(option.props.disabled),
});
});
return options;
}
export function AppSelect({
children,
className,
disabled = false,
onChange,
optionClassName,
menuClassName,
placement = "bottom",
title,
value,
id,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
}: AppSelectProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const suppressNextToggleRef = useRef(false);
const pointerCommittedRef = useRef(false);
const guardTimerRef = useRef<number | null>(null);
const options = useMemo(() => parseOptions(children), [children]);
const normalizedValue = String(value ?? "");
const selectedOption = options.find((option) => option.value === normalizedValue) ?? options[0] ?? null;
useEffect(() => {
return () => {
if (guardTimerRef.current != null) {
window.clearTimeout(guardTimerRef.current);
}
};
}, []);
useEffect(() => {
if (!open) {
return;
}
function handlePointerDown(event: PointerEvent) {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(false);
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleEscape);
};
}, [open]);
function commit(nextValue: string) {
suppressNextToggleRef.current = true;
onChange?.({ target: { value: nextValue } });
setOpen(false);
buttonRef.current?.focus({ preventScroll: true });
if (guardTimerRef.current != null) {
window.clearTimeout(guardTimerRef.current);
}
guardTimerRef.current = window.setTimeout(() => {
suppressNextToggleRef.current = false;
pointerCommittedRef.current = false;
guardTimerRef.current = null;
}, 220);
}
return (
<div ref={rootRef} className="relative">
<button
ref={buttonRef}
id={id}
type="button"
title={title ?? (typeof selectedOption?.label === "string" ? selectedOption.label : undefined)}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (disabled) return;
if (suppressNextToggleRef.current) {
suppressNextToggleRef.current = false;
return;
}
setOpen((current) => !current);
}}
onKeyDown={(event) => {
if (disabled) {
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
setOpen(true);
}
}}
className={cx(
"flex w-full items-center gap-2 text-left transition-colors",
"hover:border-primary/30 hover:bg-surface/70",
"focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20",
"disabled:cursor-not-allowed disabled:opacity-60",
open && "border-primary/45 bg-surface/85 shadow-[0_16px_36px_-24px] shadow-black/70",
className,
)}
style={{ paddingRight: "2.5rem" }}
>
<span className="min-w-0 flex-1 truncate">
{selectedOption?.label ?? <span className="text-foreground-secondary/60"> </span>}
</span>
<ChevronDown
size={14}
className="pointer-events-none text-foreground-secondary/60"
style={{
position: "absolute",
right: 12,
top: "50%",
transform: open ? "translateY(-50%) rotate(180deg)" : "translateY(-50%) rotate(0deg)",
transition: "transform 180ms ease",
}}
/>
</button>
{open && !disabled && (
<div
className={cx(
"hf-elevated-menu absolute left-0 z-50 w-full overflow-hidden rounded-xl border p-1 backdrop-blur-xl",
placement === "top" ? "bottom-full mb-1.5" : "top-full mt-1.5",
menuClassName,
)}
>
<div role="listbox" className="max-h-64 overflow-y-auto">
{options.map((option) => {
const selected = option.value === normalizedValue;
return (
<button
key={option.key}
type="button"
role="option"
aria-selected={selected}
disabled={option.disabled}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
if (option.disabled) return;
pointerCommittedRef.current = true;
commit(option.value);
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (option.disabled || pointerCommittedRef.current) {
pointerCommittedRef.current = false;
return;
}
commit(option.value);
}}
data-selected={selected ? "true" : undefined}
className={cx(
"hf-menu-item flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors",
selected ? "text-foreground" : "text-foreground-secondary hover:text-foreground",
option.disabled && "cursor-not-allowed opacity-45",
optionClassName,
)}
>
<span className={cx("min-w-0 flex-1 truncate", selected && "font-medium")}>{option.label}</span>
{selected && <Check size={14} className="shrink-0 text-primary" />}
</button>
);
})}
</div>
</div>
)}
</div>
);
}