diff --git a/src/uix/soma/components/radio-group/radio-group-provider.svelte.ts b/src/uix/soma/components/radio-group/radio-group-provider.svelte.ts index ace006dd5..34ee31960 100644 --- a/src/uix/soma/components/radio-group/radio-group-provider.svelte.ts +++ b/src/uix/soma/components/radio-group/radio-group-provider.svelte.ts @@ -168,23 +168,20 @@ export class RadioItemProvider { private constructor(opts: RadioItemOpts) { this.opts = opts; this.provider = RadioGroupProvider.require(); - this.runtimePart = Soma.require() - .runtime(radioGroupMorfo, { - states: { - checked: () => this.isChecked - }, - props: { - disabled: () => this.isDisabled, - orientation: () => this.provider.opts.orientation.current - } - }) - .part('item', { - id: opts.id, - ref: opts.ref, - owner: this, - context: RadioItemProvider.ctx, - syncAttrs: true - }); + this.runtimePart = this.provider.runtime.part('item', { + id: opts.id, + ref: opts.ref, + states: { + checked: () => this.isChecked + }, + props: { + disabled: () => this.isDisabled, + orientation: () => this.provider.opts.orientation.current + }, + owner: this, + context: RadioItemProvider.ctx, + syncAttrs: true + }); } readonly isChecked = $derived.by( @@ -266,13 +263,15 @@ export class RadioGroupIndicatorProvider { private constructor(opts: RadioGroupIndicatorOpts) { this.opts = opts; this.item = RadioItemProvider.require(); - this.runtimePart = Soma.require() - .runtime(radioGroupMorfo, { - states: { - checked: () => this.item.isChecked - } - }) - .part('indicator', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); + this.runtimePart = this.item.provider.runtime.part('indicator', { + id: opts.id, + ref: opts.ref, + states: { + checked: () => this.item.isChecked + }, + owner: this, + syncAttrs: true + }); } get isVisible(): boolean { @@ -301,10 +300,13 @@ export class RadioGroupHiddenInputProvider { private constructor(opts: RadioGroupHiddenInputOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(radioGroupMorfo, {}) - .part('hidden-input', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); this.item = RadioItemProvider.require(); + this.runtimePart = this.item.provider.runtime.part('hidden-input', { + id: opts.id, + ref: opts.ref, + owner: this, + syncAttrs: true + }); } readonly props = $derived.by(() => @@ -332,16 +334,20 @@ export class RadioGroupLabelProvider { return new RadioGroupLabelProvider(opts); } + readonly provider: RadioGroupProvider; + private constructor(opts: RadioGroupLabelOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(radioGroupMorfo, {}) - .part('label', { id: opts.id, ref: opts.ref, owner: this }); - const provider = RadioGroupProvider.get() as RadioGroupProvider | undefined; + this.provider = RadioGroupProvider.require(); + this.runtimePart = this.provider.runtime.part('label', { + id: opts.id, + ref: opts.ref, + owner: this + }); // A30: register label id with direct assignment in the constructor. // Ids are stable so no $effect needed. - if (provider) provider.setLabelId(opts.id.current); + this.provider.setLabelId(opts.id.current); } readonly props = $derived.by(() => diff --git a/src/uix/soma/components/tabs/tabs-provider.svelte.ts b/src/uix/soma/components/tabs/tabs-provider.svelte.ts index c7ffc90b7..12c06c20d 100644 --- a/src/uix/soma/components/tabs/tabs-provider.svelte.ts +++ b/src/uix/soma/components/tabs/tabs-provider.svelte.ts @@ -182,13 +182,15 @@ export class TabsListProvider { private constructor(opts: TabsListOpts) { this.opts = opts; this.provider = TabsProvider.require(); - this.runtimePart = Soma.require() - .runtime(tabsMorfo, { - props: { - orientation: () => this.provider.opts.orientation.current - } - }) - .part('list', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); + this.runtimePart = this.provider.runtime.part('list', { + id: opts.id, + ref: opts.ref, + props: { + orientation: () => this.provider.opts.orientation.current + }, + owner: this, + syncAttrs: true + }); } readonly props = $derived.by(() => @@ -220,20 +222,22 @@ export class TabsTriggerProvider { private constructor(opts: TabsTriggerOpts) { this.opts = opts; this.provider = TabsProvider.require(); - this.runtimePart = Soma.require() - .runtime(tabsMorfo, { - states: { - active: () => this.isActive - }, - props: { - disabled: () => this.isDisabled, - orientation: () => this.provider.opts.orientation.current - }, - parts: { - content: () => this.provider.contentIds[this.opts.value.current] || undefined - } - }) - .part('trigger', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); + this.runtimePart = this.provider.runtime.part('trigger', { + id: opts.id, + ref: opts.ref, + states: { + active: () => this.isActive + }, + props: { + disabled: () => this.isDisabled, + orientation: () => this.provider.opts.orientation.current + }, + parts: { + content: () => this.provider.contentIds[this.opts.value.current] || undefined + }, + owner: this, + syncAttrs: true + }); this.provider.triggerIds[opts.value.current] = opts.id.current; // Cleanup stale entry on unmount @@ -344,19 +348,21 @@ export class TabsContentProvider { private constructor(opts: TabsContentOpts) { this.opts = opts; this.provider = TabsProvider.require(); - this.runtimePart = Soma.require() - .runtime(tabsMorfo, { - states: { - active: () => this.isActive - }, - props: { - orientation: () => this.provider.opts.orientation.current - }, - parts: { - trigger: () => this.provider.triggerIds[this.opts.value.current] || undefined - } - }) - .part('content', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); + this.runtimePart = this.provider.runtime.part('content', { + id: opts.id, + ref: opts.ref, + states: { + active: () => this.isActive + }, + props: { + orientation: () => this.provider.opts.orientation.current + }, + parts: { + trigger: () => this.provider.triggerIds[this.opts.value.current] || undefined + }, + owner: this, + syncAttrs: true + }); this.provider.contentIds[opts.value.current] = opts.id.current; // Cleanup stale entry on unmount @@ -412,13 +418,15 @@ export class TabsIndicatorProvider { private constructor(opts: TabsIndicatorOpts) { this.opts = opts; this.provider = TabsProvider.require(); - this.runtimePart = Soma.require() - .runtime(tabsMorfo, { - props: { - orientation: () => this.provider.opts.orientation.current - } - }) - .part('indicator', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); + this.runtimePart = this.provider.runtime.part('indicator', { + id: opts.id, + ref: opts.ref, + props: { + orientation: () => this.provider.opts.orientation.current + }, + owner: this, + syncAttrs: true + }); } readonly props = $derived.by(() => diff --git a/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts b/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts index 381d76bba..29db001f2 100644 --- a/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts +++ b/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts @@ -6,7 +6,7 @@ import { KEYS, getDirectionalKeys } from '../../keyboard'; import { toggleGroupMorfo } from '../../../morfo/components/toggle-group'; import { Soma } from '../../core/soma.svelte'; -import type { SomaRuntimePart } from '../../runtime.svelte'; +import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; const attrs = createAttrs(toggleGroupMorfo); @@ -29,6 +29,8 @@ interface ToggleGroupOpts export class ToggleGroupProvider { readonly opts: ToggleGroupOpts; readonly runtimePart: SomaRuntimePart; + readonly soma: Soma; + readonly runtime: SomaRuntime; static readonly ctx = context('ToggleGroup'); static get(): ToggleGroupProvider | undefined { return this.ctx.getOr(undefined) as ToggleGroupProvider | undefined; @@ -43,20 +45,20 @@ export class ToggleGroupProvider { private constructor(opts: ToggleGroupOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(toggleGroupMorfo, { - props: { - orientation: () => this.opts.orientation.current, - disabled: () => this.opts.disabled.current - } - }) - .part('provider', { - id: opts.id, - ref: opts.ref, - owner: this, - context: ToggleGroupProvider.ctx, - syncAttrs: true - }); + this.soma = Soma.require(); + this.runtime = this.soma.runtime(toggleGroupMorfo, { + props: { + orientation: () => this.opts.orientation.current, + disabled: () => this.opts.disabled.current + } + }); + this.runtimePart = this.runtime.part('provider', { + id: opts.id, + ref: opts.ref, + owner: this, + context: ToggleGroupProvider.ctx, + syncAttrs: true + }); } isItemPressed(value: string): boolean { @@ -118,17 +120,19 @@ export class ToggleGroupItemProvider { private constructor(opts: ToggleGroupItemOpts) { this.opts = opts; this.provider = ToggleGroupProvider.require(); - this.runtimePart = Soma.require() - .runtime(toggleGroupMorfo, { - states: { - on: () => this.isPressed - }, - props: { - disabled: () => this.isDisabled, - orientation: () => this.provider.opts.orientation.current - } - }) - .part('item', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); + this.runtimePart = this.provider.runtime.part('item', { + id: opts.id, + ref: opts.ref, + states: { + on: () => this.isPressed + }, + props: { + disabled: () => this.isDisabled, + orientation: () => this.provider.opts.orientation.current + }, + owner: this, + syncAttrs: true + }); } readonly isPressed = $derived.by(() => this.provider.isItemPressed(this.opts.value.current)); diff --git a/src/uix/soma/components/toolbar/toolbar-provider.svelte.ts b/src/uix/soma/components/toolbar/toolbar-provider.svelte.ts index 6da7f5487..477663980 100644 --- a/src/uix/soma/components/toolbar/toolbar-provider.svelte.ts +++ b/src/uix/soma/components/toolbar/toolbar-provider.svelte.ts @@ -1,13 +1,13 @@ import { untrack } from 'svelte'; import { createAttrs } from '$uix/morfo'; import { context, type WithRefOpts } from '../../provider'; -import { type Active, type ActiveProps, type StateProps } from '$libs/reactive'; +import { type ActiveProps, type StateProps } from '$libs/reactive'; import type { Orientation, Direction, SomaKeyboardEvent, SomaMouseEvent } from '../../types'; import { KEYS, getDirectionalKeys } from '../../keyboard'; import { toolbarMorfo } from '../../../morfo/components/toolbar'; import { Soma } from '../../core/soma.svelte'; -import type { SomaRuntimePart } from '../../runtime.svelte'; +import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; const attrs = createAttrs(toolbarMorfo); @@ -27,6 +27,8 @@ interface ToolbarOpts export class ToolbarProvider { readonly opts: ToolbarOpts; readonly runtimePart: SomaRuntimePart; + readonly soma: Soma; + readonly runtime: SomaRuntime; static readonly ctx = context('Toolbar'); static get(): ToolbarProvider | undefined { return this.ctx.getOr(undefined) as ToolbarProvider | undefined; @@ -44,19 +46,19 @@ export class ToolbarProvider { private constructor(opts: ToolbarOpts) { this.opts = opts; - this.runtimePart = Soma.require() - .runtime(toolbarMorfo, { - props: { - orientation: () => this.opts.orientation.current - } - }) - .part('provider', { - id: opts.id, - ref: opts.ref, - owner: this, - context: ToolbarProvider.ctx, - syncAttrs: true - }); + this.soma = Soma.require(); + this.runtime = this.soma.runtime(toolbarMorfo, { + props: { + orientation: () => this.opts.orientation.current + } + }); + this.runtimePart = this.runtime.part('provider', { + id: opts.id, + ref: opts.ref, + owner: this, + context: ToolbarProvider.ctx, + syncAttrs: true + }); } /** Get all focusable items in the toolbar, scoped to this root. */ @@ -160,14 +162,16 @@ export class ToolbarButtonProvider { private constructor(opts: ToolbarButtonOpts) { this.opts = opts; this.provider = ToolbarProvider.require(); - this.runtimePart = Soma.require() - .runtime(toolbarMorfo, { - props: { - disabled: () => this.opts.disabled.current, - orientation: () => this.provider.opts.orientation.current - } - }) - .part('button', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); + this.runtimePart = this.provider.runtime.part('button', { + id: opts.id, + ref: opts.ref, + props: { + disabled: () => this.opts.disabled.current, + orientation: () => this.provider.opts.orientation.current + }, + owner: this, + syncAttrs: true + }); } readonly props = $derived.by(() => @@ -195,13 +199,15 @@ export class ToolbarLinkProvider { private constructor(opts: ToolbarLinkOpts) { this.opts = opts; this.provider = ToolbarProvider.require(); - this.runtimePart = Soma.require() - .runtime(toolbarMorfo, { - props: { - orientation: () => this.provider.opts.orientation.current - } - }) - .part('link', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); + this.runtimePart = this.provider.runtime.part('link', { + id: opts.id, + ref: opts.ref, + props: { + orientation: () => this.provider.opts.orientation.current + }, + owner: this, + syncAttrs: true + }); } readonly props = $derived.by(() => @@ -243,20 +249,17 @@ export class ToolbarGroupProvider { private constructor(opts: ToolbarGroupOpts) { this.opts = opts; this.provider = ToolbarProvider.require(); - this.runtimePart = Soma.require() - .runtime(toolbarMorfo, { - props: { - disabled: () => this.opts.disabled.current, - orientation: () => this.provider.opts.orientation.current - } - }) - .part('group', { - id: opts.id, - ref: opts.ref, - owner: this, - context: ToolbarGroupProvider.ctx, - syncAttrs: true - }); + this.runtimePart = this.provider.runtime.part('group', { + id: opts.id, + ref: opts.ref, + props: { + disabled: () => this.opts.disabled.current, + orientation: () => this.provider.opts.orientation.current + }, + owner: this, + context: ToolbarGroupProvider.ctx, + syncAttrs: true + }); } isItemPressed(value: string): boolean { @@ -310,17 +313,19 @@ export class ToolbarGroupItemProvider { this.opts = opts; this.group = ToolbarGroupProvider.require(); this.provider = this.group.provider; - this.runtimePart = Soma.require() - .runtime(toolbarMorfo, { - states: { - on: () => this.isPressed - }, - props: { - disabled: () => this.isDisabled, - orientation: () => this.provider.opts.orientation.current - } - }) - .part('group-item', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); + this.runtimePart = this.provider.runtime.part('group-item', { + id: opts.id, + ref: opts.ref, + states: { + on: () => this.isPressed + }, + props: { + disabled: () => this.isDisabled, + orientation: () => this.provider.opts.orientation.current + }, + owner: this, + syncAttrs: true + }); } readonly isPressed = $derived.by(() => this.group.isItemPressed(this.opts.value.current)); @@ -361,13 +366,15 @@ export class ToolbarSeparatorProvider { private constructor(opts: ToolbarSeparatorOpts) { this.opts = opts; this.provider = ToolbarProvider.require(); - this.runtimePart = Soma.require() - .runtime(toolbarMorfo, { - props: { - orientation: () => this.separatorOrientation - } - }) - .part('separator', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); + this.runtimePart = this.provider.runtime.part('separator', { + id: opts.id, + ref: opts.ref, + props: { + orientation: () => this.separatorOrientation + }, + owner: this, + syncAttrs: true + }); } /** Separator orientation is perpendicular to toolbar orientation. */ diff --git a/src/uix/soma/runtime.svelte.test.ts b/src/uix/soma/runtime.svelte.test.ts index 40131dbaf..8c4b923c8 100644 --- a/src/uix/soma/runtime.svelte.test.ts +++ b/src/uix/soma/runtime.svelte.test.ts @@ -87,6 +87,80 @@ describe('createSomaRuntime', () => { cleanup(); }); + it('part handles keep their own registration when a repeated part is registered twice', () => { + const repeatedMorfo = { + name: 'Repeated', + kebab: 'repeated', + scope: ['soma'], + parts: [ + { + name: 'Item', + kebab: 'item', + kind: 'public', + defaultElement: 'div', + optional: false, + data: [], + aria: [] + } + ] + } as const; + + const { result, cleanup } = withEffectRoot(() => { + const r = createSomaRuntime(repeatedMorfo, { dom }); + const first = r.part('item', { id: state('item-1') }); + const second = r.part('item', { id: state('item-2') }); + return { first, second }; + }); + + expect(result.first.props.id).toBe('item-1'); + expect(result.second.props.id).toBe('item-2'); + expect(result.first.props['data-repeated-item']).toBe(''); + expect(result.second.props['data-repeated-item']).toBe(''); + cleanup(); + }); + + it('part can supply part-id sources for morfo partRef attrs', () => { + const partRefMorfo = { + name: 'PartRefProbe', + kebab: 'part-ref-probe', + scope: ['soma'], + parts: [ + { + name: 'Trigger', + kebab: 'trigger', + kind: 'public', + defaultElement: 'button', + optional: false, + data: [], + aria: [ + { + attr: 'aria-controls', + value: { kind: 'partRef', target: 'content' }, + condition: { when: 'part-present', part: 'content' } + } + ] + } + ] + } as const; + const ref = state(null); + + const { cleanup } = withEffectRoot(() => { + const r = createSomaRuntime(partRefMorfo, { dom }); + r.part('trigger', { + id: state('trigger-1'), + ref, + parts: { content: () => 'content-1' }, + syncAttrs: true + }); + }); + + ref.current = target; + flushSync(); + + expect(target.getAttribute('aria-controls')).toBe('content-1'); + cleanup(); + }); + it('part can opt into runtime DOM attr synchronization', () => { const ref = state(null); const { cleanup } = withEffectRoot(() => { diff --git a/src/uix/soma/runtime.svelte.ts b/src/uix/soma/runtime.svelte.ts index 9b95a96d8..0a8670ee8 100644 --- a/src/uix/soma/runtime.svelte.ts +++ b/src/uix/soma/runtime.svelte.ts @@ -131,6 +131,8 @@ export interface SomaRuntimePartBaseOpts { states?: SourceMap; /** Per-part prop sources (override component-level for this part only). */ props?: SourceMap; + /** Per-part part-id sources (override component-level for this part only). */ + parts?: SourceMap; } export interface SomaRuntimePartContext { @@ -238,6 +240,7 @@ interface PartRegistration { attachment: RefAttachment | undefined; states: SourceMap | undefined; props: SourceMap | undefined; + parts: SourceMap | undefined; bindings: RuntimeBindingsScratch; /** Compiled part metadata so `partProps` doesn't re-walk the morfo tree. */ meta: CompiledPart; @@ -259,13 +262,13 @@ function hasSourceKeys(map: SourceMap | undefined): boolean { } function createBindingsScratch( - opts: Pick, + opts: Pick, sources: SomaRuntimeSources ): RuntimeBindingsScratch { return { states: hasSourceKeys(sources.states) || hasSourceKeys(opts.states) ? {} : undefined, props: hasSourceKeys(sources.props) || hasSourceKeys(opts.props) ? {} : undefined, - parts: hasSourceKeys(sources.parts) ? {} : undefined, + parts: hasSourceKeys(sources.parts) || hasSourceKeys(opts.parts) ? {} : undefined, translations: sources.translate }; } @@ -295,6 +298,7 @@ function readBindings(reg: PartRegistration, sources: SomaRuntimeSources): Morfo readSourcesInto(bindings.parts, sources.parts); readSourcesInto(bindings.states, reg.states); readSourcesInto(bindings.props, reg.props); + readSourcesInto(bindings.parts, reg.parts); bindings.translations = sources.translate; return bindings; @@ -321,6 +325,7 @@ export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): So attachment: undefined, states: opts.states, props: opts.props, + parts: opts.parts, bindings: createBindingsScratch(opts, sources), meta: compiledPart }; @@ -367,10 +372,10 @@ export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): So return { get attachment() { - return registrations.get(partName)?.attachment; + return reg.attachment; }, get props() { - return partProps(partName); + return partPropsForRegistration(partName, reg); }, resolveProps(bindings: MorfoBindings = {}) { const props: Record = { ...compiledPart.staticAttrs }; @@ -390,7 +395,10 @@ export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): So function partProps(part: string): Record { const reg = registrations.get(part); if (!reg) return {}; + return partPropsForRegistration(part, reg); + } + function partPropsForRegistration(part: string, reg: PartRegistration): Record { const marker = compiled.parts.attrs[part]; const props: Record = { id: reg.id.current