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.
svelte-kit-vice/src/uix/soma/components/combobox/combobox-provider.svelte.ts

1092 lines
35 KiB

import { untrack } from 'svelte';
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,
SomaMouseEvent,
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';
import type { ComboboxType } from './types';
const attrs = createAttrs(comboboxMorfo);
// ── 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;
allowsCustomValue: 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, {
states: {
open: () => this.opts.open.current
},
props: {
disabled: () => this.opts.disabled.current,
required: () => this.opts.required.current,
type: () => this.opts.type.current,
multiple: () => this.opts.type.current === 'multiple',
empty: () => this.isEmpty,
clearDisabled: () => this.opts.disabled.current || this.isEmpty,
selectedTagsEmpty: () => this.opts.value.current.length === 0
},
parts: {
input: () => this.inputId.current || undefined,
content: () => this.contentId.current || undefined
}
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: ComboboxProvider.ctx,
syncAttrs: true
});
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)
});
// Single-mode auto-sync: keep `inputValue` mirroring the selected
// value's label so the input reflects external `value` changes
// (initial mount with a pre-selected value, or `bind:value` updates).
// Multi-mode intentionally leaves `inputValue` alone — that input is
// the live filter, and stamping a label onto it would hide the rest
// of the items behind a single-match query.
$effect(() => {
if (this.opts.type.current !== 'single') return;
const v = this.opts.value.current;
if (v.length === 0) return;
const label = this.labelRegistry.get(v[0]);
if (label === undefined) return;
if (this.opts.inputValue.current !== label) {
this.opts.inputValue.current = label;
}
});
// Mode switch reset: when `type` changes between 'single' and
// 'multiple', wipe `inputValue` so leftover text from the previous
// mode doesn't accidentally filter the list when the user expects a
// fresh start.
let lastType = untrack(() => this.opts.type.current);
$effect(() => {
const type = this.opts.type.current;
if (type !== lastType) {
lastType = type;
untrack(() => {
this.opts.inputValue.current = '';
});
}
});
// Multi mode renders selections as inline tags via a separate
// `Combobox.SelectedTags` part (Material UI Autocomplete pattern).
// The input stays for filtering — we don't stamp labels into it.
}
isSelected(value: string): boolean {
return this.opts.value.current.includes(value);
}
/** True when both `inputValue` is empty and no values are selected. Drives `data-empty` + clear button disabled state. */
readonly isEmpty = $derived.by(
() => this.opts.inputValue.current === '' && this.opts.value.current.length === 0
);
/** Clear `inputValue` and `value` (deselects all in multi). Refocuses the input. */
clearAll() {
if (this.opts.disabled.current) return;
this.opts.inputValue.current = '';
this.opts.value.current = [];
this.soma.dom.focus(this.inputRef.current);
}
private resolveItemEl(value: string): HTMLElement | null {
const root = this.contentRef.current;
if (!root) return null;
const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(value) : value;
return root.querySelector<HTMLElement>(`[${attrs.item}][data-value="${escaped}"]`);
}
selectItem(value: string, target?: HTMLElement) {
if (this.opts.disabled.current) return;
const current = this.opts.value.current;
const isSelected = current.includes(value);
let eventName: 'commit-select' | 'commit-unselect' | undefined;
let next: string[];
if (this.opts.type.current === 'single') {
if (isSelected && this.opts.allowDeselect.current) {
next = [];
eventName = 'commit-unselect';
} else if (isSelected) {
this.handleClose();
return;
} else {
next = [value];
eventName = 'commit-select';
}
this.handleClose();
} else {
next = isSelected ? current.filter((v) => v !== value) : [...current, value];
eventName = isSelected ? 'commit-unselect' : 'commit-select';
}
this.opts.value.current = next;
// Sync inputValue in SINGLE mode only — show the selected label so the
// input reflects the current value. In MULTI mode the user is likely
// still filtering for more picks (cmdk pattern: filter → pick → filter
// → pick), so we preserve the current input text and leave the
// already-selected items visible as checked items in the list.
if (this.opts.type.current === 'single') {
const label = next.length > 0 ? (this.labelRegistry.get(next[0]) ?? next[0]) : '';
this.opts.inputValue.current = label;
}
const eventTarget = target ?? this.resolveItemEl(value);
if (eventName && eventTarget) {
void this.runtime.trigger(eventName, { fallbackTarget: eventTarget });
}
}
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.soma.dom.focus(this.inputRef.current);
}
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;
}
/**
* Commit the current input text as a value when `allowsCustomValue` is on
* and no item is highlighted. Single: replace and close. Multi: append + clear input.
*/
commitCustomValue(raw: string) {
const value = raw.trim();
if (value === '' || this.opts.disabled.current) return;
// Register the custom value with itself as label so the trigger / displayText resolves it.
this.registerLabel(value, value);
if (this.opts.type.current === 'single') {
this.opts.value.current = [value];
this.opts.inputValue.current = value;
this.handleClose();
} else {
const current = this.opts.value.current;
if (!current.includes(value)) {
this.opts.value.current = [...current, value];
}
this.opts.inputValue.current = '';
}
}
/** 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(', ');
}
/**
* Snapshot of currently-selected `{ value, label }` pairs in selection
* order. Drives `<Combobox.SelectedTags>` chip rendering. Reads the
* label registry so chips reflect the same labels the menu shows.
*/
getSelectedItems(): Array<{ value: string; label: string }> {
return this.opts.value.current.map((v) => ({
value: v,
label: this.labelRegistry.get(v) ?? v
}));
}
/**
* Remove a single value from the selection. Used by
* `<Combobox.SelectedTagRemove>` chip X buttons. No-op if the combobox
* is disabled.
*/
removeValue(value: string) {
if (this.opts.disabled.current) return;
const current = this.opts.value.current;
if (!current.includes(value)) return;
this.opts.value.current = current.filter((v) => v !== value);
}
readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props } as const));
}
// ── 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();
}
}
} else if (
this.provider.opts.allowsCustomValue.current &&
this.provider.opts.inputValue.current !== ''
) {
// No highlighted item: commit current input as a custom value.
e.preventDefault();
this.provider.commitCustomValue(this.provider.opts.inputValue.current);
}
} else if (e.key === KEYS.BACKSPACE) {
// Multi mode: empty input + Backspace removes the last selected value.
if (
this.provider.opts.type.current === 'multiple' &&
this.provider.opts.inputValue.current === '' &&
this.provider.opts.value.current.length > 0
) {
e.preventDefault();
const current = this.provider.opts.value.current;
this.provider.opts.value.current = current.slice(0, -1);
}
}
};
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,
timers: this.provider.soma.uix?.timers,
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.soma.dom.focus(this.provider.inputRef.current);
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];
this.provider.soma.dom.focus(target);
this.provider.soma.dom.scrollIntoView(target, { 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 without a ref-reading mount effect.
// Item text fallback belongs to consumers; label is the stable headless
// source for display text when content unmounts.
//
// We register on mount but DO NOT unregister on cleanup — keeping the
// label around after the listbox closes/lazy-unmounts lets
// `<Combobox.SelectedTags>` render the proper label for previously-
// rendered values even when their `Item` is no longer in the DOM.
// Re-registration on remount is idempotent (SvelteMap.set).
const value = opts.value.current;
this.provider.registerLabel(value, opts.label.current || value);
}
readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current));
readonly onclick = (e: SomaMouseEvent<HTMLElement>) => {
if (this.opts.disabled.current) return;
this.provider.selectItem(this.opts.value.current, e.currentTarget as HTMLElement);
};
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, e.currentTarget as HTMLElement);
}
};
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)
);
}
// ── SelectedTags ────────────────────────────────────────────────────────────
interface ComboboxSelectedTagsOpts extends WithRefOpts {}
export class ComboboxSelectedTagsProvider {
readonly opts: ComboboxSelectedTagsOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: ComboboxSelectedTagsOpts) {
return new ComboboxSelectedTagsProvider(opts);
}
readonly provider: ComboboxProvider;
private constructor(opts: ComboboxSelectedTagsOpts) {
this.opts = opts;
this.provider = ComboboxProvider.require();
this.runtimePart = this.provider.runtime.part('selected-tags', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
/** Live snapshot of `{ value, label }` for every selected entry. Drives the chip loop. */
readonly items = $derived.by(() => this.provider.getSelectedItems());
readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props } as const));
}
// ── SelectedTag ─────────────────────────────────────────────────────────────
interface ComboboxSelectedTagOpts
extends
WithRefOpts,
ActiveProps<{
value: string;
label: string;
disabled: boolean;
}> {}
export class ComboboxSelectedTagProvider {
readonly opts: ComboboxSelectedTagOpts;
readonly runtimePart: SomaRuntimePart;
static readonly ctx = context<ComboboxSelectedTagProvider>('ComboboxSelectedTag');
static get(): ComboboxSelectedTagProvider | undefined {
return this.ctx.getOr(undefined) as ComboboxSelectedTagProvider | undefined;
}
static require(): ComboboxSelectedTagProvider {
return this.ctx.get();
}
static create(opts: ComboboxSelectedTagOpts) {
return new ComboboxSelectedTagProvider(opts);
}
readonly provider: ComboboxProvider;
private constructor(opts: ComboboxSelectedTagOpts) {
this.opts = opts;
this.provider = ComboboxProvider.require();
this.runtimePart = this.provider.runtime.part('selected-tag', {
id: opts.id,
ref: opts.ref,
owner: this,
context: ComboboxSelectedTagProvider.ctx
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'data-value': this.opts.value.current,
'data-label': this.opts.label.current || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current)
} as const)
);
}
// ── SelectedTagRemove ───────────────────────────────────────────────────────
interface ComboboxSelectedTagRemoveOpts extends WithRefOpts {}
export class ComboboxSelectedTagRemoveProvider {
readonly opts: ComboboxSelectedTagRemoveOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: ComboboxSelectedTagRemoveOpts) {
return new ComboboxSelectedTagRemoveProvider(opts);
}
readonly provider: ComboboxProvider;
readonly tag: ComboboxSelectedTagProvider;
private constructor(opts: ComboboxSelectedTagRemoveOpts) {
this.opts = opts;
this.tag = ComboboxSelectedTagProvider.require();
this.provider = this.tag.provider;
this.runtimePart = this.provider.runtime.part('selected-tag-remove', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly disabled = $derived.by(
() => this.provider.opts.disabled.current || this.tag.opts.disabled.current
);
readonly onclick = (e: SomaMouseEvent<HTMLElement>) => {
if (this.disabled) return;
e.preventDefault();
e.stopPropagation();
this.provider.removeValue(this.tag.opts.value.current);
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
'data-disabled': boolToEmptyStrOrUndef(this.disabled),
disabled: this.disabled || undefined,
onclick: this.onclick
} as const)
);
}
// ── ClearButton ─────────────────────────────────────────────────────────────
interface ComboboxClearButtonOpts extends WithRefOpts {}
export class ComboboxClearButtonProvider {
readonly opts: ComboboxClearButtonOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: ComboboxClearButtonOpts) {
return new ComboboxClearButtonProvider(opts);
}
readonly provider: ComboboxProvider;
private constructor(opts: ComboboxClearButtonOpts) {
this.opts = opts;
this.provider = ComboboxProvider.require();
this.runtimePart = this.provider.runtime.part('clear-button', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
this.provider.clearAll();
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
'aria-label': this.provider.soma.langs.ts(COMBOBOX_LANGS.CLEAR),
tabindex: -1,
disabled: this.provider.opts.disabled.current || this.provider.isEmpty || undefined,
'data-disabled': boolToEmptyStrOrUndef(
this.provider.opts.disabled.current || this.provider.isEmpty
),
onclick: this.onclick
} as const)
);
}

Powered by TurnKey Linux.