From c499dd239a9e1654196b9b95a30a4217304eb593 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 15 May 2026 02:37:04 +0200 Subject: [PATCH] Consolidate Soma interaction runtimes --- .../drag-drop/drag-drop-provider.svelte.ts | 47 ++++++----- .../listbox/listbox-provider.svelte.ts | 61 +++++++++------ .../number-field-provider.svelte.ts | 77 ++++++++++--------- 3 files changed, 107 insertions(+), 78 deletions(-) diff --git a/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.ts b/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.ts index 0bcced6b0..2c0451da7 100644 --- a/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.ts +++ b/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.ts @@ -10,7 +10,7 @@ import { DRAG_DROP_LANGS } from './langs'; import type { ActiveDrag, DragData, DragEndEvent, DropEvent } from './types'; import { dragDropMorfo } from '../../../morfo/components/drag-drop'; -import type { SomaRuntimePart } from '../../runtime.svelte'; +import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; const attrs = createAttrs(dragDropMorfo); @@ -31,6 +31,7 @@ interface DragDropOpts export class DragDropProvider { readonly opts: DragDropOpts; readonly runtimePart: SomaRuntimePart; + readonly runtime: SomaRuntime; static readonly ctx = context('DragDrop'); static get(): DragDropProvider | undefined { return this.ctx.getOr(undefined) as DragDropProvider | undefined; @@ -60,9 +61,13 @@ export class DragDropProvider { private constructor(opts: DragDropOpts) { this.opts = opts; this.globalAnnounce = createAnnouncer(this.soma.dom, 1500); - this.runtimePart = this.soma - .runtime(dragDropMorfo, {}) - .part('provider', { id: opts.id, ref: opts.ref, owner: this, context: DragDropProvider.ctx }); + this.runtime = this.soma.runtime(dragDropMorfo, {}); + this.runtimePart = this.runtime.part('provider', { + id: opts.id, + ref: opts.ref, + owner: this, + context: DragDropProvider.ctx + }); $effect(() => { return () => this.teardown(); }); @@ -72,8 +77,7 @@ export class DragDropProvider { private announce(key: string, priority: 'polite' | 'assertive' = 'polite') { if (!this.opts.announceEnabled.current) return; - const ling = this.soma?.langs; - const text = ling ? ling.ts(key) : key; + const text = this.soma.langs.ts(key); this.globalAnnounce.announce(text, priority); } @@ -83,8 +87,7 @@ export class DragDropProvider { priority: 'polite' | 'assertive' = 'polite' ) { if (!this.opts.announceEnabled.current) return; - const ling = this.soma?.langs; - const text = ling ? ling.t(key, vars) : key; + const text = this.soma.langs.t(key, vars); this.globalAnnounce.announce(text, priority); } @@ -375,10 +378,12 @@ export class DraggableProvider { private constructor(opts: DraggableOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(dragDropMorfo, {}) - .part('draggable', { id: opts.id, ref: opts.ref, owner: this }); this.provider = DragDropProvider.require(); + this.runtimePart = this.provider.runtime.part('draggable', { + id: opts.id, + ref: opts.ref, + owner: this + }); } readonly isActive = $derived.by(() => this.provider.active?.value === this.opts.value.current); @@ -467,7 +472,7 @@ export class DraggableProvider { role: 'button' as const, tabindex: this.opts.disabled.current ? -1 : 0, 'aria-disabled': this.opts.disabled.current ? true : undefined, - 'aria-roledescription': this.provider.soma?.langs.ts(DRAG_DROP_LANGS.DRAGGABLE_ROLE), + 'aria-roledescription': this.provider.soma.langs.ts(DRAG_DROP_LANGS.DRAGGABLE_ROLE), 'aria-grabbed': this.isActive ? true : undefined, 'data-dragging': boolToEmptyStrOrUndef(this.isActive), 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), @@ -502,10 +507,12 @@ export class DroppableProvider { private constructor(opts: DroppableOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(dragDropMorfo, {}) - .part('droppable', { id: opts.id, ref: opts.ref, owner: this }); this.provider = DragDropProvider.require(); + this.runtimePart = this.provider.runtime.part('droppable', { + id: opts.id, + ref: opts.ref, + owner: this + }); // Attach the accept predicate directly to the DOM element so the // Provider can read it without a registry (which would require reactive @@ -556,7 +563,7 @@ export class DroppableProvider { ...this.runtimePart.props, role: 'region' as const, tabindex: this.provider.active && this.isAccepting ? 0 : -1, - 'aria-roledescription': this.provider.soma?.langs.ts(DRAG_DROP_LANGS.DROPPABLE_ROLE), + 'aria-roledescription': this.provider.soma.langs.ts(DRAG_DROP_LANGS.DROPPABLE_ROLE), 'aria-dropeffect': this.provider.active && this.isAccepting ? ('move' as const) : ('none' as const), 'aria-disabled': this.opts.disabled.current ? true : undefined, @@ -584,10 +591,12 @@ export class DragPreviewProvider { private constructor(opts: DragPreviewOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(dragDropMorfo, {}) - .part('preview', { id: opts.id, ref: opts.ref, owner: this }); this.provider = DragDropProvider.require(); + this.runtimePart = this.provider.runtime.part('preview', { + id: opts.id, + ref: opts.ref, + owner: this + }); } readonly active: Active = readableActive(() => this.provider.active); diff --git a/src/uix/soma/components/listbox/listbox-provider.svelte.ts b/src/uix/soma/components/listbox/listbox-provider.svelte.ts index 83e4e82e4..5f1175ff5 100644 --- a/src/uix/soma/components/listbox/listbox-provider.svelte.ts +++ b/src/uix/soma/components/listbox/listbox-provider.svelte.ts @@ -16,7 +16,7 @@ import { LISTBOX_LANGS } from './langs'; import type { ListboxSelectionMode } from './types'; import { listboxMorfo } from '../../../morfo/components/listbox'; -import type { SomaRuntimePart } from '../../runtime.svelte'; +import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; const attrs = createAttrs(listboxMorfo); @@ -46,6 +46,8 @@ interface ListboxOpts export class ListboxProvider { readonly opts: ListboxOpts; readonly runtimePart: SomaRuntimePart; + readonly soma: Soma; + readonly runtime: SomaRuntime; static readonly ctx = context('Listbox'); static get(): ListboxProvider | undefined { return this.ctx.getOr(undefined) as ListboxProvider | undefined; @@ -58,7 +60,6 @@ export class ListboxProvider { return new ListboxProvider(opts); } - readonly soma = Soma.get(); readonly field = FieldProvider.get(); focused = $state(false); @@ -67,9 +68,14 @@ export class ListboxProvider { private constructor(opts: ListboxOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(listboxMorfo, {}) - .part('provider', { id: opts.id, ref: opts.ref, owner: this, context: ListboxProvider.ctx }); + this.soma = Soma.require(); + this.runtime = this.soma.runtime(listboxMorfo, {}); + this.runtimePart = this.runtime.part('provider', { + id: opts.id, + ref: opts.ref, + owner: this, + context: ListboxProvider.ctx + }); $effect(() => { return () => { @@ -94,13 +100,13 @@ export class ListboxProvider { ); readonly resolvedDir: Active = readableActive( - () => this.opts.dir.current ?? this.soma?.prefs.getDir() ?? 'ltr' + () => this.opts.dir.current ?? this.soma.prefs.getDir() ?? 'ltr' ); readonly resolvedAriaLabel: Active = readableActive(() => this.opts.ariaLabelledby.current ? undefined - : this.opts.ariaLabel.current || this.soma?.langs.ts(LISTBOX_LANGS.LABEL) || undefined + : this.opts.ariaLabel.current || this.soma.langs.ts(LISTBOX_LANGS.LABEL) || undefined ); readonly isEmpty = $derived.by(() => this.opts.value.current.length === 0); @@ -360,10 +366,13 @@ export class ListboxItemProvider { private constructor(opts: ListboxItemOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(listboxMorfo, {}) - .part('item', { id: opts.id, ref: opts.ref, owner: this, context: ListboxItemProvider.ctx }); this.provider = ListboxProvider.require(); + this.runtimePart = this.provider.runtime.part('item', { + id: opts.id, + ref: opts.ref, + owner: this, + context: ListboxItemProvider.ctx + }); } readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current)); @@ -446,11 +455,13 @@ export class ListboxItemIndicatorProvider { private constructor(opts: ListboxItemIndicatorOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(listboxMorfo, {}) - .part('item-indicator', { id: opts.id, ref: opts.ref, owner: this }); this.provider = ListboxProvider.require(); this.item = ListboxItemProvider.require(); + this.runtimePart = this.provider.runtime.part('item-indicator', { + id: opts.id, + ref: opts.ref, + owner: this + }); } readonly isPresent = $derived.by(() => this.item.isSelected); @@ -471,6 +482,7 @@ interface ListboxGroupOpts extends WithRefOpts {} export class ListboxGroupProvider { readonly opts: ListboxGroupOpts; readonly runtimePart: SomaRuntimePart; + readonly provider: ListboxProvider; static readonly ctx = context('ListboxGroup'); static get(): ListboxGroupProvider | undefined { return this.ctx.getOr(undefined) as ListboxGroupProvider | undefined; @@ -487,14 +499,13 @@ export class ListboxGroupProvider { private constructor(opts: ListboxGroupOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(listboxMorfo, {}) - .part('group', { - id: opts.id, - ref: opts.ref, - owner: this, - context: ListboxGroupProvider.ctx - }); + this.provider = ListboxProvider.require(); + this.runtimePart = this.provider.runtime.part('group', { + id: opts.id, + ref: opts.ref, + owner: this, + context: ListboxGroupProvider.ctx + }); } readonly props = $derived.by(() => @@ -519,10 +530,12 @@ export class ListboxGroupLabelProvider { private constructor(opts: ListboxGroupLabelOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(listboxMorfo, {}) - .part('group-label', { id: opts.id, ref: opts.ref, owner: this }); this.group = ListboxGroupProvider.require(); + this.runtimePart = this.group.provider.runtime.part('group-label', { + id: opts.id, + ref: opts.ref, + owner: this + }); this.group.labelId = opts.id.current; } diff --git a/src/uix/soma/components/number-field/number-field-provider.svelte.ts b/src/uix/soma/components/number-field/number-field-provider.svelte.ts index c10868504..248bfc75f 100644 --- a/src/uix/soma/components/number-field/number-field-provider.svelte.ts +++ b/src/uix/soma/components/number-field/number-field-provider.svelte.ts @@ -43,7 +43,7 @@ function escapeRegex(s: string): string { } import { numberFieldMorfo } from '../../../morfo/components/number-field'; -import type { SomaRuntimePart } from '../../runtime.svelte'; +import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -81,6 +81,8 @@ interface NumberFieldOpts export class NumberFieldProvider { readonly opts: NumberFieldOpts; readonly runtimePart: SomaRuntimePart; + readonly soma: Soma; + readonly runtime: SomaRuntime; static readonly ctx = context('NumberField'); static get(): NumberFieldProvider | undefined { return this.ctx.getOr(undefined) as NumberFieldProvider | undefined; @@ -93,7 +95,6 @@ export class NumberFieldProvider { return new NumberFieldProvider(opts); } - readonly soma = Soma.get(); /** * Optional parent Field context. When present, NumberField inherits * `disabled`/`readonly`/`required`/`invalid` and its Input registers into @@ -116,20 +117,18 @@ export class NumberFieldProvider { private constructor(opts: NumberFieldOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(numberFieldMorfo, {}) - .part('provider', { - id: opts.id, - ref: opts.ref, - owner: this, - context: NumberFieldProvider.ctx - }); - - this.locale = readableActive(() => opts.locale.current ?? this.soma?.langs.getLocale() ?? 'en'); - - this.resolvedDir = readableActive( - () => opts.dir.current ?? this.soma?.prefs.getDir() ?? 'ltr' - ); + this.soma = Soma.require(); + this.runtime = this.soma.runtime(numberFieldMorfo, {}); + this.runtimePart = this.runtime.part('provider', { + id: opts.id, + ref: opts.ref, + owner: this, + context: NumberFieldProvider.ctx + }); + + this.locale = readableActive(() => opts.locale.current ?? this.soma.langs.getLocale() ?? 'en'); + + this.resolvedDir = readableActive(() => opts.dir.current ?? this.soma.prefs.getDir() ?? 'ltr'); // Sync inputValue with external value/locale changes. // When focused (user is typing), only update if the value itself changed — not on locale/format changes. @@ -179,12 +178,12 @@ export class NumberFieldProvider { /** Decimal separator for the active locale (locale-aware, override-aware). */ readonly decimalSeparator = $derived.by( - () => this.soma?.nums?.getDecimalSeparator() ?? resolveDecimalSeparator(this.locale.current) + () => this.soma.nums?.getDecimalSeparator() ?? resolveDecimalSeparator(this.locale.current) ); /** Group (thousands) separator for the active locale. */ readonly groupSeparator = $derived.by( - () => this.soma?.nums?.getGroupSeparator() ?? resolveGroupSeparator(this.locale.current) + () => this.soma.nums?.getGroupSeparator() ?? resolveGroupSeparator(this.locale.current) ); /** Regex of characters accepted during typing. Rebuilt on locale/format change. */ @@ -223,7 +222,7 @@ export class NumberFieldProvider { formatValue(value: number | undefined): string { if (value === undefined || Number.isNaN(value)) return ''; const opts = this.opts.formatOptions.current; - const nums = this.soma?.nums; + const nums = this.soma.nums; if (nums) return nums.format(value, opts); return intlFormat(this.locale.current, opts).format(value); } @@ -233,7 +232,7 @@ export class NumberFieldProvider { if (value === undefined || Number.isNaN(value)) return ''; const scaled = this.toEditValue(value); const opts = this.editFormatOptions; - const nums = this.soma?.nums; + const nums = this.soma.nums; if (nums) return nums.format(scaled, opts); return intlFormat(this.locale.current, opts).format(scaled); } @@ -241,7 +240,7 @@ export class NumberFieldProvider { /** Parse edit string and convert to internal value. Returns undefined on failure. */ parseValue(raw: string): number | undefined { if (!raw.trim()) return undefined; - const nums = this.soma?.nums; + const nums = this.soma.nums; let n: number | undefined; if (nums) { n = nums.parse(raw); @@ -451,10 +450,12 @@ export class NumberFieldInputProvider { private constructor(opts: NumberFieldInputOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(numberFieldMorfo, {}) - .part('input', { id: opts.id, ref: opts.ref, owner: this }); this.provider = NumberFieldProvider.require(); + this.runtimePart = this.provider.runtime.part('input', { + id: opts.id, + ref: opts.ref, + owner: this + }); // When embedded inside Field, register this input id so Field.Label's // `for=` attribute targets it. if (this.provider.field) { @@ -525,10 +526,12 @@ export class NumberFieldIncrementTriggerProvider { private constructor(opts: NumberFieldIncrementTriggerOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(numberFieldMorfo, {}) - .part('increment-trigger', { id: opts.id, ref: opts.ref, owner: this }); this.provider = NumberFieldProvider.require(); + this.runtimePart = this.provider.runtime.part('increment-trigger', { + id: opts.id, + ref: opts.ref, + owner: this + }); } readonly isDisabled = $derived.by(() => this.provider.isDisabled || this.provider.atMax); @@ -543,7 +546,7 @@ export class NumberFieldIncrementTriggerProvider { type: 'button' as const, tabindex: -1, disabled: this.isDisabled || undefined, - 'aria-label': this.provider.soma?.langs.ts(NUMBER_FIELD_LANGS.INCREMENT), + 'aria-label': this.provider.soma.langs.ts(NUMBER_FIELD_LANGS.INCREMENT), 'aria-disabled': boolToStr(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), onclick: this.handleClick @@ -566,10 +569,12 @@ export class NumberFieldDecrementTriggerProvider { private constructor(opts: NumberFieldDecrementTriggerOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(numberFieldMorfo, {}) - .part('decrement-trigger', { id: opts.id, ref: opts.ref, owner: this }); this.provider = NumberFieldProvider.require(); + this.runtimePart = this.provider.runtime.part('decrement-trigger', { + id: opts.id, + ref: opts.ref, + owner: this + }); } readonly isDisabled = $derived.by(() => this.provider.isDisabled || this.provider.atMin); @@ -584,7 +589,7 @@ export class NumberFieldDecrementTriggerProvider { type: 'button' as const, tabindex: -1, disabled: this.isDisabled || undefined, - 'aria-label': this.provider.soma?.langs.ts(NUMBER_FIELD_LANGS.DECREMENT), + 'aria-label': this.provider.soma.langs.ts(NUMBER_FIELD_LANGS.DECREMENT), 'aria-disabled': boolToStr(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), onclick: this.handleClick @@ -619,10 +624,12 @@ export class NumberFieldScrubberProvider { private constructor(opts: NumberFieldScrubberOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(numberFieldMorfo, {}) - .part('scrubber', { id: opts.id, ref: opts.ref, owner: this }); this.provider = NumberFieldProvider.require(); + this.runtimePart = this.provider.runtime.part('scrubber', { + id: opts.id, + ref: opts.ref, + owner: this + }); } private get isDisabled(): boolean {