You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
760 lines
23 KiB
760 lines
23 KiB
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<boolean>;
|
|
}> {}
|
|
|
|
export class ComboboxProvider {
|
|
readonly opts: ComboboxOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
readonly soma: Soma;
|
|
readonly runtime: SomaRuntime;
|
|
static readonly ctx = context<ComboboxProvider>('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<HTMLElement | null>(null);
|
|
inputRef = state<HTMLElement | null>(null);
|
|
triggerRef = state<HTMLElement | null>(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<string, string>();
|
|
|
|
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<HTMLElement>(`[${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<HTMLInputElement>) => {
|
|
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<HTMLElement>(
|
|
`#${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<Boundary>;
|
|
collisionPadding: number | Partial<Record<Side, number>>;
|
|
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<HTMLElement>) => {
|
|
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<ComboboxGroupProvider>('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)
|
|
);
|
|
}
|