/** * createDropdownList - the HEADLESS core behind : a single-select * dropdown state machine (trigger open/close, roving active index over enabled * options, full keyboard) exposed as **prop-getters** you spread onto YOUR OWN * markup. No styles, no DOM, no portal/measurement - those stay in the styled * component. * * ```svelte * * * {#if dd.open} * * {/if} * ``` */ import { createTypeaheadBuffer, isTypeaheadKey, nextTypeaheadIndex, type ListOption } from './list-option' import { editorAria, type EditorAriaState } from './editor-contract' export type DropdownValue = string | number | null /** Reactive inputs are passed as getters so the core tracks live prop changes. */ export type DropdownListConfig = { options: () => ReadonlyArray value: () => DropdownValue onChange?: (value: string | number) => void disabled?: () => boolean /** Read-only: value shown, trigger focusable, but the panel will not open. */ readonly?: () => boolean ariaLabel?: () => string | undefined /** DOM focus hook provided by the renderer; the core never touches the DOM. */ focusTrigger?: () => void // Editor contract (ARIA + validation) - folded into triggerProps(). id?: () => string | undefined invalid?: () => boolean required?: () => boolean error?: () => string | undefined hint?: () => string | undefined } let uid = 0 export function createDropdownList(config: DropdownListConfig) { const listId = `sv-ddl-${uid++}` const optionId = (index: number) => `${listId}-opt-${index}` const opts = () => config.options() const disabled = () => config.disabled?.() ?? false const readonly = () => config.readonly?.() ?? false const ariaState = (): EditorAriaState => ({ id: config.id?.(), invalid: config.invalid?.(), required: config.required?.(), error: config.error?.(), hint: config.hint?.(), ariaLabel: config.ariaLabel?.(), }) let open = $state(false) let active = $state(-1) const selected = $derived(opts().find((o) => o.value === config.value()) ?? null) const enabledIdx = $derived(opts().map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0)) function openPanel() { if (disabled() || readonly() || open) return open = true active = Math.max(0, opts().findIndex((o) => o.value === config.value())) } function close() { open = false } function toggle() { if (open) { close() } else { openPanel() } } function pick(i: number) { if (readonly()) return const o = opts()[i] if (!o || o.disabled) return config.onChange?.(o.value); close(); config.focusTrigger?.() } function setActive(i: number) { const o = opts()[i]; if (o && !o.disabled) active = i } function move(d: number) { const pos = enabledIdx.indexOf(active) active = enabledIdx[(pos + d + enabledIdx.length) % enabledIdx.length] ?? active } // Type-ahead: typing a label prefix jumps the active option (and selects it // when the list is closed, matching a native