import { splitProps, mergeProps, createSignal, createEffect, onCleanup, For, Show, JSX, createUniqueId, } from 'solid-js'; import { Portal } from 'solid-js/web'; import { useFloating } from 'solid-floating-ui'; import { offset, flip, shift, autoUpdate } from '@floating-ui/dom'; import clsx from 'clsx'; export interface DropdownOption { value: string; label: string; disabled?: boolean; } export interface DropdownProps { options: DropdownOption[]; value?: string; defaultValue?: string; onChange?: (value: string) => void; placeholder?: string; size?: 'sm' | 'md' | 'lg'; color?: 'success' | 'error' | 'warning' | 'info'; accent?: string; disabled?: boolean; class?: string; } /** * Dropdown -- WAI-ARIA combobox/listbox with overlay popup via Portal + solid-floating-ui. * * The popup renders outside parent clip-path via Portal (Pitfall 4). * Positioned with useFloating + offset/flip/shift middleware. * Full keyboard navigation: ArrowDown/Up to navigate, Enter/Space to select, Escape to close. * Type-ahead: typing a character jumps to the first matching option. */ export function Dropdown(rawProps: DropdownProps) { const merged = mergeProps( { placeholder: 'Select...', size: 'md' as const }, rawProps, ); const [local] = splitProps(merged, [ 'options', 'value', 'defaultValue', 'onChange', 'placeholder', 'size', 'color', 'accent', 'disabled', 'class', ]); const uid = createUniqueId(); const listboxId = `hh-dropdown-listbox-${uid}`; const optionIdPrefix = `hh-dropdown-option-${uid}`; // State const [open, setOpen] = createSignal(false); const [activeIndex, setActiveIndex] = createSignal(-1); const [internalValue, setInternalValue] = createSignal(local.defaultValue ?? ''); // Element refs let triggerRef: HTMLButtonElement | undefined; const [reference, setReference] = createSignal(); const [floating, setFloating] = createSignal(); // Floating UI positioning const position = useFloating(reference, floating, { placement: 'bottom-start', middleware: [offset(4), flip(), shift({ padding: 8 })], whileElementsMounted: autoUpdate, }); // Resolve value: controlled vs uncontrolled const currentValue = () => { if (local.value !== undefined) return local.value; return internalValue(); }; // Get selected option label const selectedLabel = () => { const val = currentValue(); if (!val) return ''; const opt = local.options.find((o) => o.value === val); return opt?.label ?? ''; }; // Get non-disabled option indices const enabledIndices = () => local.options .map((opt, i) => ({ opt, i })) .filter(({ opt }) => !opt.disabled) .map(({ i }) => i); // Find next/previous enabled index const findNextEnabled = (from: number, direction: 1 | -1): number => { const enabled = enabledIndices(); if (enabled.length === 0) return -1; if (from < 0) { return direction === 1 ? enabled[0] : enabled[enabled.length - 1]; } const currentPos = enabled.indexOf(from); if (currentPos === -1) { // Current index is disabled; find nearest in direction if (direction === 1) { return enabled.find((i) => i > from) ?? enabled[0]; } else { return [...enabled].reverse().find((i) => i < from) ?? enabled[enabled.length - 1]; } } const nextPos = currentPos + direction; if (nextPos < 0) return enabled[enabled.length - 1]; if (nextPos >= enabled.length) return enabled[0]; return enabled[nextPos]; }; // Select a value const selectValue = (value: string) => { if (local.value === undefined) { setInternalValue(value); } local.onChange?.(value); closeDropdown(); }; // Open/close const openDropdown = () => { if (local.disabled) return; setOpen(true); // Set active index to currently selected option, or first enabled const val = currentValue(); const selectedIdx = val ? local.options.findIndex((o) => o.value === val) : -1; if (selectedIdx >= 0 && !local.options[selectedIdx].disabled) { setActiveIndex(selectedIdx); } else { const first = enabledIndices()[0]; setActiveIndex(first ?? -1); } }; const closeDropdown = () => { setOpen(false); setActiveIndex(-1); triggerRef?.focus(); }; const toggleDropdown = () => { if (open()) { closeDropdown(); } else { openDropdown(); } }; // Click outside handler createEffect(() => { if (!open()) return; const handleMouseDown = (e: MouseEvent) => { const target = e.target as Node; const floatingEl = floating(); if ( triggerRef && !triggerRef.contains(target) && (!floatingEl || !floatingEl.contains(target)) ) { closeDropdown(); } }; document.addEventListener('mousedown', handleMouseDown); onCleanup(() => document.removeEventListener('mousedown', handleMouseDown)); }); // Keyboard handler on trigger const handleTriggerKeyDown = (e: KeyboardEvent) => { if (local.disabled) return; switch (e.key) { case 'Enter': case ' ': e.preventDefault(); if (open() && activeIndex() >= 0) { const opt = local.options[activeIndex()]; if (opt && !opt.disabled) selectValue(opt.value); } else { toggleDropdown(); } break; case 'ArrowDown': e.preventDefault(); if (!open()) { openDropdown(); } else { setActiveIndex(findNextEnabled(activeIndex(), 1)); } break; case 'ArrowUp': e.preventDefault(); if (!open()) { openDropdown(); } else { setActiveIndex(findNextEnabled(activeIndex(), -1)); } break; case 'Home': if (open()) { e.preventDefault(); const first = enabledIndices()[0]; if (first !== undefined) setActiveIndex(first); } break; case 'End': if (open()) { e.preventDefault(); const enabled = enabledIndices(); const last = enabled[enabled.length - 1]; if (last !== undefined) setActiveIndex(last); } break; case 'Escape': if (open()) { e.preventDefault(); closeDropdown(); } break; default: // Type-ahead: jump to first option starting with typed character if (open() && e.key.length === 1 && !e.ctrlKey && !e.metaKey) { const char = e.key.toLowerCase(); const idx = local.options.findIndex( (opt) => !opt.disabled && opt.label.toLowerCase().startsWith(char), ); if (idx >= 0) setActiveIndex(idx); } } }; // Handle option click const handleOptionClick = (opt: DropdownOption, e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (opt.disabled) return; selectValue(opt.value); }; // Accent style const accentStyle = (): JSX.CSSProperties | undefined => { if (local.accent) { return { '--hh-comp-accent': local.accent } as JSX.CSSProperties; } return undefined; }; // Trigger classes const triggerClasses = () => clsx( 'hh-dropdown-trigger', 'hh-btn', 'hh-hover-gradient', 'hh-focus-pulse', 'hh-active-flood', 'hh-disabled', `hh-size-${local.size}`, local.color && `hh-color-${local.color}`, open() && 'hh-dropdown-trigger--open', local.class, ); return (
{(opt, i) => (
handleOptionClick(opt, e)} onMouseEnter={() => { if (!opt.disabled) setActiveIndex(i()); }} > {opt.label}
)}
); }