import { SvelteMap } from 'svelte/reactivity'; import { createAttrs } from '$uix/morfo'; import { context, type ProviderOpts, type WithRefOpts } from '../../provider'; import { boolToEmptyStrOrUndef, getDataOpenClosed } from '$adom'; import { readableActive, state, type ActiveProps, type StateProps } from '$libs/reactive'; import type { OnChangeFn, SomaKeyboardEvent, Direction, Arrayable, StyleProperties } from '../../types'; import { KEYS } from '../../keyboard'; import { Soma } from '../../core/soma.svelte'; import { COMBOBOX_LANGS } from './langs'; import { Presence } from '../../layers/presence.svelte'; import { Dismissal, type DismissalBehavior } from '../../layers/dismissal.svelte'; import { FloatingProvider, FloatingContent, FloatingArrow, FloatingAnchor, type Side, type Align, type Boundary } from '../../layers/floating'; import { comboboxMorfo } from '../../../morfo/components/combobox'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; const attrs = createAttrs(comboboxMorfo); export type ComboboxType = 'single' | 'multiple'; // ── Root ───────────────────────────────────────────────────────────────────── interface ComboboxOpts extends ProviderOpts, StateProps<{ open: boolean; value: string[]; inputValue: string }>, ActiveProps<{ type: ComboboxType; disabled: boolean; required: boolean; name: string; loop: boolean; allowDeselect: boolean; dir: Direction; onOpenChangeComplete: OnChangeFn; }> {} export class ComboboxProvider { readonly opts: ComboboxOpts; readonly runtimePart: SomaRuntimePart; readonly soma: Soma; readonly runtime: SomaRuntime; static readonly ctx = context('Combobox'); static get(): ComboboxProvider | undefined { return this.ctx.getOr(undefined) as ComboboxProvider | undefined; } static require(): ComboboxProvider { return this.ctx.get(); } static create(opts: ComboboxOpts) { return new ComboboxProvider(opts); } readonly floatingProvider: FloatingProvider; readonly contentPresence: Presence; contentId = state(''); inputId = state(''); triggerId = state(''); contentRef = state(null); inputRef = state(null); triggerRef = state(null); // Track highlighted item for aria-activedescendant highlightedId = $state(''); // value → label registry. Populated by ComboboxItem mounts. Using this // instead of DOM queries keeps `getDisplayText()` correct even when the // content is unmounted (closed popover) or lazy-rendered. // // `SvelteMap` (not plain Map) so `.get` / `.set` / `.has` are reactive // per-entry — `getDisplayText()` reads `.get(v)` and needs to re-run when // a label is registered (A33). private labelRegistry = new SvelteMap(); registerLabel(value: string, label: string) { this.labelRegistry.set(value, label); } unregisterLabel(value: string) { this.labelRegistry.delete(value); } private constructor(opts: ComboboxOpts) { this.opts = opts; this.soma = Soma.require(); this.runtime = this.soma.runtime(comboboxMorfo, {}); this.runtimePart = this.runtime.part('provider', { id: opts.id, ref: opts.ref, owner: this, context: ComboboxProvider.ctx }); this.floatingProvider = FloatingProvider.create({ dom: this.soma.dom }); this.contentPresence = new Presence({ dom: this.soma.dom, open: opts.open, ref: this.contentRef, onComplete: (open) => opts.onOpenChangeComplete.current(open) }); } isSelected(value: string): boolean { return this.opts.value.current.includes(value); } selectItem(value: string) { if (this.opts.disabled.current) return; const current = this.opts.value.current; const isSelected = current.includes(value); let next: string[]; if (this.opts.type.current === 'single') { if (isSelected && this.opts.allowDeselect.current) { next = []; } else if (isSelected) { this.handleClose(); return; } else { next = [value]; } this.handleClose(); } else { next = isSelected ? current.filter((v) => v !== value) : [...current, value]; } this.opts.value.current = next; } handleOpen() { if (this.opts.disabled.current) return; this.opts.open.current = true; } handleClose(returnFocus = true) { this.highlightedId = ''; this.opts.open.current = false; if (returnFocus) this.inputRef.current?.focus(); } toggle() { if (this.opts.open.current) this.handleClose(); else this.handleOpen(); } setContentRef(el: HTMLElement | null) { this.contentRef.current = el; } getItems(container: HTMLElement | null): HTMLElement[] { if (!container) return []; return Array.from( container.querySelectorAll(`[${attrs.item}]:not([data-disabled])`) ); } updateInputValue(value: string) { this.opts.inputValue.current = value; } /** Get display text for the current value. Resolves labels via the registry. */ getDisplayText(): string { return this.opts.value.current.map((v) => this.labelRegistry.get(v) ?? v).join(', '); } } // ── Input ─────────────────────────────────────────────────────────────────── interface ComboboxInputOpts extends WithRefOpts {} export class ComboboxInputProvider { readonly opts: ComboboxInputOpts; readonly runtimePart: SomaRuntimePart; static create(opts: ComboboxInputOpts) { return new ComboboxInputProvider(opts); } readonly provider: ComboboxProvider; private constructor(opts: ComboboxInputOpts) { this.opts = opts; this.provider = ComboboxProvider.require(); this.runtimePart = this.provider.runtime.part('input', { id: opts.id, ref: opts.ref, owner: this }); this.provider.inputId.current = opts.id.current; this.provider.inputRef = opts.ref; // Input is the floating anchor FloatingAnchor.create({ id: opts.id, ref: readableActive(() => opts.ref.current) }); } readonly oninput = (e: Event) => { const target = e.target as HTMLInputElement; this.provider.updateInputValue(target.value); if (!this.provider.opts.open.current) { this.provider.handleOpen(); } }; readonly onkeydown = (e: SomaKeyboardEvent) => { if (e.key === KEYS.ARROW_DOWN) { e.preventDefault(); if (!this.provider.opts.open.current) { this.provider.handleOpen(); } // Focus first item this.provider.soma.dom.requestFrame(() => { const container = this.provider.contentRef.current; if (!container) return; const items = this.provider.getItems(container); if (items.length > 0) { this.provider.soma.dom.focus(items[0]); this.provider.highlightedId = items[0].id; } }, this.provider.contentRef.current ?? this.provider.inputRef.current); } else if (e.key === KEYS.ARROW_UP) { e.preventDefault(); if (!this.provider.opts.open.current) { this.provider.handleOpen(); } // Focus last item this.provider.soma.dom.requestFrame(() => { const container = this.provider.contentRef.current; if (!container) return; const items = this.provider.getItems(container); if (items.length > 0) { const last = items[items.length - 1]; this.provider.soma.dom.focus(last); this.provider.highlightedId = last.id; } }, this.provider.contentRef.current ?? this.provider.inputRef.current); } else if (e.key === KEYS.ENTER) { if (this.provider.opts.open.current && this.provider.highlightedId) { e.preventDefault(); const container = this.provider.contentRef.current; if (container) { const highlighted = container.querySelector( `#${CSS.escape(this.provider.highlightedId)}` ); if (highlighted) { highlighted.click(); } } } } }; readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, type: 'text' as const, role: 'combobox' as const, 'aria-haspopup': 'listbox' as const, 'aria-expanded': this.provider.opts.open.current, 'aria-controls': this.provider.contentId.current || undefined, 'aria-activedescendant': this.provider.highlightedId || undefined, 'aria-required': this.provider.opts.required.current || undefined, 'aria-autocomplete': 'list' as const, 'data-state': getDataOpenClosed(this.provider.opts.open.current), 'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current), disabled: this.provider.opts.disabled.current || undefined, value: this.provider.opts.inputValue.current, oninput: this.oninput, onkeydown: this.onkeydown } as const) ); } // ── Trigger ───────────────────────────────────────────────────────────────── interface ComboboxTriggerOpts extends WithRefOpts {} export class ComboboxTriggerProvider { readonly opts: ComboboxTriggerOpts; readonly runtimePart: SomaRuntimePart; static create(opts: ComboboxTriggerOpts) { return new ComboboxTriggerProvider(opts); } readonly provider: ComboboxProvider; private constructor(opts: ComboboxTriggerOpts) { this.opts = opts; this.provider = ComboboxProvider.require(); this.runtimePart = this.provider.runtime.part('trigger', { id: opts.id, ref: opts.ref, owner: this }); this.provider.triggerId.current = opts.id.current; this.provider.triggerRef = opts.ref; } readonly onclick = () => { this.provider.toggle(); }; readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, type: 'button' as const, tabindex: -1, 'aria-label': this.provider.soma.langs.ts(COMBOBOX_LANGS.TOGGLE), 'aria-haspopup': 'listbox' as const, 'aria-expanded': this.provider.opts.open.current, 'aria-controls': this.provider.contentId.current || undefined, 'data-state': getDataOpenClosed(this.provider.opts.open.current), 'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current), disabled: this.provider.opts.disabled.current || undefined, onclick: this.onclick } as const) ); } // ── Content ───────────────────────────────────────────────────────────────── interface ComboboxContentOpts extends WithRefOpts, ActiveProps<{ side: Side; sideOffset: number; align: Align; alignOffset: number; arrowPadding: number; avoidCollisions: boolean; collisionBoundary: Arrayable; collisionPadding: number | Partial>; sticky: 'partial' | 'always'; hideWhenDetached: boolean; strategy: 'fixed' | 'absolute'; onEscapeKeydown: (e: KeyboardEvent) => void; escapeKeydownBehavior: DismissalBehavior; onInteractOutside: (e: PointerEvent) => void; interactOutsideBehavior: DismissalBehavior; dir: Direction; style: StyleProperties | null | undefined | string; }> {} export class ComboboxContentProvider { readonly opts: ComboboxContentOpts; readonly runtimePart: SomaRuntimePart; static create(opts: ComboboxContentOpts) { return new ComboboxContentProvider(opts); } readonly provider: ComboboxProvider; readonly floating: FloatingContent; readonly dismissal: Dismissal; private constructor(opts: ComboboxContentOpts) { this.opts = opts; this.provider = ComboboxProvider.require(); this.runtimePart = this.provider.runtime.part('content', { id: opts.id, ref: opts.ref, owner: this, onRefChange: (el) => { this.provider.setContentRef(el); } }); this.provider.contentId.current = opts.id.current; // Floating this.floating = FloatingContent.create({ id: opts.id, wrapperId: readableActive(() => `${opts.id.current}-wrapper`), side: opts.side, sideOffset: opts.sideOffset, align: opts.align, alignOffset: opts.alignOffset, arrowPadding: opts.arrowPadding, avoidCollisions: opts.avoidCollisions, collisionBoundary: opts.collisionBoundary, collisionPadding: opts.collisionPadding, sticky: opts.sticky, hideWhenDetached: opts.hideWhenDetached, updatePositionStrategy: readableActive(() => 'optimized' as const), strategy: opts.strategy, onPlaced: readableActive(() => () => {}), dir: opts.dir, style: opts.style, enabled: readableActive(() => this.provider.opts.open.current), customAnchor: readableActive(() => null) }); // Dismissal — isValidEvent returns false for clicks on the input element this.dismissal = Dismissal.use({ dom: this.provider.soma.dom, ref: readableActive(() => opts.ref.current), enabled: readableActive(() => this.provider.opts.open.current), onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => { opts.onEscapeKeydown.current(e); if (!e.defaultPrevented) this.provider.handleClose(); }), escapeKeydownBehavior: opts.escapeKeydownBehavior, onInteractOutside: readableActive(() => (e: PointerEvent) => { opts.onInteractOutside.current(e); if (!e.defaultPrevented) this.provider.handleClose(false); }), interactOutsideBehavior: opts.interactOutsideBehavior, onFocusOutside: readableActive(() => () => {}), isValidEvent: readableActive(() => (e: PointerEvent | FocusEvent) => { const target = e.target; if (!(target instanceof Node)) return true; // Clicks on the input or trigger should not close const inputEl = this.provider.inputRef.current; if (inputEl && inputEl.contains(target)) return false; const triggerEl = this.provider.triggerRef.current; if (triggerEl && triggerEl.contains(target)) return false; // Clicks inside the content (including scrollbar) should not close const contentEl = opts.ref.current; if (contentEl && contentEl.contains(target)) return false; return true; }) }); // No ScrollLock for combobox } readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent); readonly wrapperProps = $derived.by(() => ({ ...this.floating.wrapperProps, style: { ...(typeof this.floating.wrapperProps.style === 'object' ? this.floating.wrapperProps.style : {}), 'pointer-events': 'auto' } })); readonly onkeydown = (e: KeyboardEvent) => { const container = this.opts.ref.current; if (!container) return; const items = this.provider.getItems(container); const currentIndex = this.provider.highlightedId ? items.findIndex((el) => el.id === this.provider.highlightedId) : -1; const loop = this.provider.opts.loop.current; let targetIndex = -1; if (e.key === KEYS.ARROW_DOWN) { e.preventDefault(); targetIndex = loop ? (currentIndex + 1) % items.length : Math.min(currentIndex + 1, items.length - 1); } else if (e.key === KEYS.ARROW_UP) { e.preventDefault(); if (currentIndex === -1) { // No item highlighted — go to last targetIndex = items.length - 1; } else if (currentIndex === 0 && !loop) { // First item, return focus to input this.provider.highlightedId = ''; this.provider.inputRef.current?.focus(); return; } else { targetIndex = loop ? (currentIndex - 1 + items.length) % items.length : Math.max(currentIndex - 1, 0); } } else if (e.key === KEYS.HOME) { e.preventDefault(); targetIndex = 0; } else if (e.key === KEYS.END) { e.preventDefault(); targetIndex = items.length - 1; } else if (e.key === KEYS.TAB) { e.preventDefault(); this.provider.handleClose(); return; } else { return; } if (targetIndex >= 0 && items[targetIndex]) { const target = items[targetIndex]; target.focus(); target.scrollIntoView({ block: 'nearest' }); this.provider.highlightedId = target.id; } }; readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'listbox' as const, 'aria-labelledby': this.provider.inputId.current || undefined, 'aria-multiselectable': this.provider.opts.type.current === 'multiple' || undefined, 'data-state': getDataOpenClosed(this.provider.opts.open.current), ...this.floating.props, ...this.provider.contentPresence.transitionAttrs, ...this.dismissal.props, tabindex: -1, onkeydown: this.onkeydown } as const) ); } // ── Viewport ──────────────────────────────────────────────────────────────── interface ComboboxViewportOpts extends WithRefOpts {} export class ComboboxViewportProvider { readonly opts: ComboboxViewportOpts; readonly runtimePart: SomaRuntimePart; static create(opts: ComboboxViewportOpts) { return new ComboboxViewportProvider(opts); } private constructor(opts: ComboboxViewportOpts) { this.opts = opts; const provider = ComboboxProvider.require(); this.runtimePart = provider.runtime.part('viewport', { id: opts.id, ref: opts.ref, owner: this }); } readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, style: { overflow: 'auto' } } as const) ); } // ── Item ──────────────────────────────────────────────────────────────────── interface ComboboxItemOpts extends WithRefOpts, ActiveProps<{ value: string; label: string; disabled: boolean; }> {} export class ComboboxItemProvider { readonly opts: ComboboxItemOpts; readonly runtimePart: SomaRuntimePart; static create(opts: ComboboxItemOpts) { return new ComboboxItemProvider(opts); } readonly provider: ComboboxProvider; private constructor(opts: ComboboxItemOpts) { this.opts = opts; this.provider = ComboboxProvider.require(); this.runtimePart = this.provider.runtime.part('item', { id: opts.id, ref: opts.ref, owner: this }); // Maintain value → label registry for display-text resolution without // relying on DOM queries (content may be unmounted or lazy-rendered). $effect(() => { const value = opts.value.current; const label = opts.label.current || opts.ref.current?.textContent?.trim() || value; this.provider.registerLabel(value, label); return () => this.provider.unregisterLabel(value); }); } readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current)); readonly onclick = () => { if (this.opts.disabled.current) return; this.provider.selectItem(this.opts.value.current); }; readonly onkeydown = (e: SomaKeyboardEvent) => { if (this.opts.disabled.current) return; if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) { e.preventDefault(); this.provider.selectItem(this.opts.value.current); } }; readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'option' as const, 'aria-selected': this.isSelected, tabindex: this.opts.disabled.current ? undefined : -1, 'data-state': this.isSelected ? 'checked' : 'unchecked', 'data-highlighted': this.provider.highlightedId === this.opts.id.current ? '' : undefined, 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), 'data-value': this.opts.value.current, 'data-label': this.opts.label.current || undefined, onclick: this.onclick, onkeydown: this.onkeydown } as const) ); } // ── Group ─────────────────────────────────────────────────────────────────── interface ComboboxGroupOpts extends WithRefOpts {} export class ComboboxGroupProvider { readonly opts: ComboboxGroupOpts; readonly runtimePart: SomaRuntimePart; readonly provider: ComboboxProvider; static readonly ctx = context('ComboboxGroup'); static get(): ComboboxGroupProvider | undefined { return this.ctx.getOr(undefined) as ComboboxGroupProvider | undefined; } static require(): ComboboxGroupProvider { return this.ctx.get(); } static create(opts: ComboboxGroupOpts) { return new ComboboxGroupProvider(opts); } headingId = state(''); private constructor(opts: ComboboxGroupOpts) { this.opts = opts; this.provider = ComboboxProvider.require(); this.runtimePart = this.provider.runtime.part('group', { id: opts.id, ref: opts.ref, owner: this, context: ComboboxGroupProvider.ctx }); } readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'group' as const, 'aria-labelledby': this.headingId.current || undefined } as const) ); } // ── GroupHeading ───────────────────────────────────────────────────────────── interface ComboboxGroupHeadingOpts extends WithRefOpts {} export class ComboboxGroupHeadingProvider { readonly opts: ComboboxGroupHeadingOpts; readonly runtimePart: SomaRuntimePart; static create(opts: ComboboxGroupHeadingOpts) { return new ComboboxGroupHeadingProvider(opts); } private readonly group: ComboboxGroupProvider | undefined; private constructor(opts: ComboboxGroupHeadingOpts) { this.opts = opts; this.group = ComboboxGroupProvider.get() as ComboboxGroupProvider | undefined; const provider = this.group?.provider ?? ComboboxProvider.require(); this.runtimePart = provider.runtime.part('group-heading', { id: opts.id, ref: opts.ref, owner: this }); if (this.group) { const group = this.group; group.headingId.current = opts.id.current; } } readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props } as const) ); } // ── Separator ─────────────────────────────────────────────────────────────── interface ComboboxSeparatorOpts extends WithRefOpts {} export class ComboboxSeparatorProvider { readonly opts: ComboboxSeparatorOpts; readonly runtimePart: SomaRuntimePart; static create(opts: ComboboxSeparatorOpts) { return new ComboboxSeparatorProvider(opts); } private constructor(opts: ComboboxSeparatorOpts) { this.opts = opts; const provider = ComboboxProvider.require(); this.runtimePart = provider.runtime.part('separator', { id: opts.id, ref: opts.ref, owner: this }); } readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'separator' as const } as const) ); } // ── Arrow ─────────────────────────────────────────────────────────────────── interface ComboboxArrowOpts extends WithRefOpts {} export class ComboboxArrowProvider { readonly opts: ComboboxArrowOpts; readonly runtimePart: SomaRuntimePart; static create(opts: ComboboxArrowOpts) { return new ComboboxArrowProvider(opts); } readonly floatingArrow: FloatingArrow; private constructor(opts: ComboboxArrowOpts) { this.opts = opts; const provider = ComboboxProvider.require(); this.runtimePart = provider.runtime.part('arrow', { id: opts.id, ref: opts.ref, owner: this }); this.floatingArrow = FloatingArrow.create(opts); } readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, ...this.floatingArrow.props } as const) ); }