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.
1092 lines
35 KiB
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)
|
|
);
|
|
}
|