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

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)
);
}

Powered by TurnKey Linux.