Consolidate Soma group runtimes

active-uix
dev 5 months ago
parent ef5e9574e2
commit 73862094f6

@ -168,19 +168,16 @@ export class RadioItemProvider {
private constructor(opts: RadioItemOpts) { private constructor(opts: RadioItemOpts) {
this.opts = opts; this.opts = opts;
this.provider = RadioGroupProvider.require(); this.provider = RadioGroupProvider.require();
this.runtimePart = Soma.require() this.runtimePart = this.provider.runtime.part('item', {
.runtime(radioGroupMorfo, { id: opts.id,
ref: opts.ref,
states: { states: {
checked: () => this.isChecked checked: () => this.isChecked
}, },
props: { props: {
disabled: () => this.isDisabled, disabled: () => this.isDisabled,
orientation: () => this.provider.opts.orientation.current orientation: () => this.provider.opts.orientation.current
} },
})
.part('item', {
id: opts.id,
ref: opts.ref,
owner: this, owner: this,
context: RadioItemProvider.ctx, context: RadioItemProvider.ctx,
syncAttrs: true syncAttrs: true
@ -266,13 +263,15 @@ export class RadioGroupIndicatorProvider {
private constructor(opts: RadioGroupIndicatorOpts) { private constructor(opts: RadioGroupIndicatorOpts) {
this.opts = opts; this.opts = opts;
this.item = RadioItemProvider.require(); this.item = RadioItemProvider.require();
this.runtimePart = Soma.require() this.runtimePart = this.item.provider.runtime.part('indicator', {
.runtime(radioGroupMorfo, { id: opts.id,
ref: opts.ref,
states: { states: {
checked: () => this.item.isChecked checked: () => this.item.isChecked
} },
}) owner: this,
.part('indicator', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); syncAttrs: true
});
} }
get isVisible(): boolean { get isVisible(): boolean {
@ -301,10 +300,13 @@ export class RadioGroupHiddenInputProvider {
private constructor(opts: RadioGroupHiddenInputOpts) { private constructor(opts: RadioGroupHiddenInputOpts) {
this.opts = opts; 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.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(() => readonly props = $derived.by(() =>
@ -332,16 +334,20 @@ export class RadioGroupLabelProvider {
return new RadioGroupLabelProvider(opts); return new RadioGroupLabelProvider(opts);
} }
readonly provider: RadioGroupProvider;
private constructor(opts: RadioGroupLabelOpts) { private constructor(opts: RadioGroupLabelOpts) {
this.opts = opts; this.opts = opts;
this.runtimePart = Soma.require() this.provider = RadioGroupProvider.require();
.runtime(radioGroupMorfo, {}) this.runtimePart = this.provider.runtime.part('label', {
.part('label', { id: opts.id, ref: opts.ref, owner: this }); id: opts.id,
const provider = RadioGroupProvider.get() as RadioGroupProvider | undefined; ref: opts.ref,
owner: this
});
// A30: register label id with direct assignment in the constructor. // A30: register label id with direct assignment in the constructor.
// Ids are stable so no $effect needed. // Ids are stable so no $effect needed.
if (provider) provider.setLabelId(opts.id.current); this.provider.setLabelId(opts.id.current);
} }
readonly props = $derived.by(() => readonly props = $derived.by(() =>

@ -182,13 +182,15 @@ export class TabsListProvider {
private constructor(opts: TabsListOpts) { private constructor(opts: TabsListOpts) {
this.opts = opts; this.opts = opts;
this.provider = TabsProvider.require(); this.provider = TabsProvider.require();
this.runtimePart = Soma.require() this.runtimePart = this.provider.runtime.part('list', {
.runtime(tabsMorfo, { id: opts.id,
ref: opts.ref,
props: { props: {
orientation: () => this.provider.opts.orientation.current orientation: () => this.provider.opts.orientation.current
} },
}) owner: this,
.part('list', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); syncAttrs: true
});
} }
readonly props = $derived.by(() => readonly props = $derived.by(() =>
@ -220,8 +222,9 @@ export class TabsTriggerProvider {
private constructor(opts: TabsTriggerOpts) { private constructor(opts: TabsTriggerOpts) {
this.opts = opts; this.opts = opts;
this.provider = TabsProvider.require(); this.provider = TabsProvider.require();
this.runtimePart = Soma.require() this.runtimePart = this.provider.runtime.part('trigger', {
.runtime(tabsMorfo, { id: opts.id,
ref: opts.ref,
states: { states: {
active: () => this.isActive active: () => this.isActive
}, },
@ -231,9 +234,10 @@ export class TabsTriggerProvider {
}, },
parts: { parts: {
content: () => this.provider.contentIds[this.opts.value.current] || undefined content: () => this.provider.contentIds[this.opts.value.current] || undefined
} },
}) owner: this,
.part('trigger', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); syncAttrs: true
});
this.provider.triggerIds[opts.value.current] = opts.id.current; this.provider.triggerIds[opts.value.current] = opts.id.current;
// Cleanup stale entry on unmount // Cleanup stale entry on unmount
@ -344,8 +348,9 @@ export class TabsContentProvider {
private constructor(opts: TabsContentOpts) { private constructor(opts: TabsContentOpts) {
this.opts = opts; this.opts = opts;
this.provider = TabsProvider.require(); this.provider = TabsProvider.require();
this.runtimePart = Soma.require() this.runtimePart = this.provider.runtime.part('content', {
.runtime(tabsMorfo, { id: opts.id,
ref: opts.ref,
states: { states: {
active: () => this.isActive active: () => this.isActive
}, },
@ -354,9 +359,10 @@ export class TabsContentProvider {
}, },
parts: { parts: {
trigger: () => this.provider.triggerIds[this.opts.value.current] || undefined trigger: () => this.provider.triggerIds[this.opts.value.current] || undefined
} },
}) owner: this,
.part('content', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); syncAttrs: true
});
this.provider.contentIds[opts.value.current] = opts.id.current; this.provider.contentIds[opts.value.current] = opts.id.current;
// Cleanup stale entry on unmount // Cleanup stale entry on unmount
@ -412,13 +418,15 @@ export class TabsIndicatorProvider {
private constructor(opts: TabsIndicatorOpts) { private constructor(opts: TabsIndicatorOpts) {
this.opts = opts; this.opts = opts;
this.provider = TabsProvider.require(); this.provider = TabsProvider.require();
this.runtimePart = Soma.require() this.runtimePart = this.provider.runtime.part('indicator', {
.runtime(tabsMorfo, { id: opts.id,
ref: opts.ref,
props: { props: {
orientation: () => this.provider.opts.orientation.current orientation: () => this.provider.opts.orientation.current
} },
}) owner: this,
.part('indicator', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); syncAttrs: true
});
} }
readonly props = $derived.by(() => readonly props = $derived.by(() =>

@ -6,7 +6,7 @@ import { KEYS, getDirectionalKeys } from '../../keyboard';
import { toggleGroupMorfo } from '../../../morfo/components/toggle-group'; import { toggleGroupMorfo } from '../../../morfo/components/toggle-group';
import { Soma } from '../../core/soma.svelte'; import { Soma } from '../../core/soma.svelte';
import type { SomaRuntimePart } from '../../runtime.svelte'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
const attrs = createAttrs(toggleGroupMorfo); const attrs = createAttrs(toggleGroupMorfo);
@ -29,6 +29,8 @@ interface ToggleGroupOpts
export class ToggleGroupProvider { export class ToggleGroupProvider {
readonly opts: ToggleGroupOpts; readonly opts: ToggleGroupOpts;
readonly runtimePart: SomaRuntimePart; readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<ToggleGroupProvider>('ToggleGroup'); static readonly ctx = context<ToggleGroupProvider>('ToggleGroup');
static get(): ToggleGroupProvider | undefined { static get(): ToggleGroupProvider | undefined {
return this.ctx.getOr(undefined) as ToggleGroupProvider | undefined; return this.ctx.getOr(undefined) as ToggleGroupProvider | undefined;
@ -43,14 +45,14 @@ export class ToggleGroupProvider {
private constructor(opts: ToggleGroupOpts) { private constructor(opts: ToggleGroupOpts) {
this.opts = opts; this.opts = opts;
this.runtimePart = Soma.require() this.soma = Soma.require();
.runtime(toggleGroupMorfo, { this.runtime = this.soma.runtime(toggleGroupMorfo, {
props: { props: {
orientation: () => this.opts.orientation.current, orientation: () => this.opts.orientation.current,
disabled: () => this.opts.disabled.current disabled: () => this.opts.disabled.current
} }
}) });
.part('provider', { this.runtimePart = this.runtime.part('provider', {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this, owner: this,
@ -118,17 +120,19 @@ export class ToggleGroupItemProvider {
private constructor(opts: ToggleGroupItemOpts) { private constructor(opts: ToggleGroupItemOpts) {
this.opts = opts; this.opts = opts;
this.provider = ToggleGroupProvider.require(); this.provider = ToggleGroupProvider.require();
this.runtimePart = Soma.require() this.runtimePart = this.provider.runtime.part('item', {
.runtime(toggleGroupMorfo, { id: opts.id,
ref: opts.ref,
states: { states: {
on: () => this.isPressed on: () => this.isPressed
}, },
props: { props: {
disabled: () => this.isDisabled, disabled: () => this.isDisabled,
orientation: () => this.provider.opts.orientation.current orientation: () => this.provider.opts.orientation.current
} },
}) owner: this,
.part('item', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); syncAttrs: true
});
} }
readonly isPressed = $derived.by(() => this.provider.isItemPressed(this.opts.value.current)); readonly isPressed = $derived.by(() => this.provider.isItemPressed(this.opts.value.current));

@ -1,13 +1,13 @@
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { createAttrs } from '$uix/morfo'; import { createAttrs } from '$uix/morfo';
import { context, type WithRefOpts } from '../../provider'; 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 type { Orientation, Direction, SomaKeyboardEvent, SomaMouseEvent } from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard'; import { KEYS, getDirectionalKeys } from '../../keyboard';
import { toolbarMorfo } from '../../../morfo/components/toolbar'; import { toolbarMorfo } from '../../../morfo/components/toolbar';
import { Soma } from '../../core/soma.svelte'; import { Soma } from '../../core/soma.svelte';
import type { SomaRuntimePart } from '../../runtime.svelte'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
const attrs = createAttrs(toolbarMorfo); const attrs = createAttrs(toolbarMorfo);
@ -27,6 +27,8 @@ interface ToolbarOpts
export class ToolbarProvider { export class ToolbarProvider {
readonly opts: ToolbarOpts; readonly opts: ToolbarOpts;
readonly runtimePart: SomaRuntimePart; readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<ToolbarProvider>('Toolbar'); static readonly ctx = context<ToolbarProvider>('Toolbar');
static get(): ToolbarProvider | undefined { static get(): ToolbarProvider | undefined {
return this.ctx.getOr(undefined) as ToolbarProvider | undefined; return this.ctx.getOr(undefined) as ToolbarProvider | undefined;
@ -44,13 +46,13 @@ export class ToolbarProvider {
private constructor(opts: ToolbarOpts) { private constructor(opts: ToolbarOpts) {
this.opts = opts; this.opts = opts;
this.runtimePart = Soma.require() this.soma = Soma.require();
.runtime(toolbarMorfo, { this.runtime = this.soma.runtime(toolbarMorfo, {
props: { props: {
orientation: () => this.opts.orientation.current orientation: () => this.opts.orientation.current
} }
}) });
.part('provider', { this.runtimePart = this.runtime.part('provider', {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this, owner: this,
@ -160,14 +162,16 @@ export class ToolbarButtonProvider {
private constructor(opts: ToolbarButtonOpts) { private constructor(opts: ToolbarButtonOpts) {
this.opts = opts; this.opts = opts;
this.provider = ToolbarProvider.require(); this.provider = ToolbarProvider.require();
this.runtimePart = Soma.require() this.runtimePart = this.provider.runtime.part('button', {
.runtime(toolbarMorfo, { id: opts.id,
ref: opts.ref,
props: { props: {
disabled: () => this.opts.disabled.current, disabled: () => this.opts.disabled.current,
orientation: () => this.provider.opts.orientation.current orientation: () => this.provider.opts.orientation.current
} },
}) owner: this,
.part('button', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); syncAttrs: true
});
} }
readonly props = $derived.by(() => readonly props = $derived.by(() =>
@ -195,13 +199,15 @@ export class ToolbarLinkProvider {
private constructor(opts: ToolbarLinkOpts) { private constructor(opts: ToolbarLinkOpts) {
this.opts = opts; this.opts = opts;
this.provider = ToolbarProvider.require(); this.provider = ToolbarProvider.require();
this.runtimePart = Soma.require() this.runtimePart = this.provider.runtime.part('link', {
.runtime(toolbarMorfo, { id: opts.id,
ref: opts.ref,
props: { props: {
orientation: () => this.provider.opts.orientation.current orientation: () => this.provider.opts.orientation.current
} },
}) owner: this,
.part('link', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); syncAttrs: true
});
} }
readonly props = $derived.by(() => readonly props = $derived.by(() =>
@ -243,16 +249,13 @@ export class ToolbarGroupProvider {
private constructor(opts: ToolbarGroupOpts) { private constructor(opts: ToolbarGroupOpts) {
this.opts = opts; this.opts = opts;
this.provider = ToolbarProvider.require(); this.provider = ToolbarProvider.require();
this.runtimePart = Soma.require() this.runtimePart = this.provider.runtime.part('group', {
.runtime(toolbarMorfo, { id: opts.id,
ref: opts.ref,
props: { props: {
disabled: () => this.opts.disabled.current, disabled: () => this.opts.disabled.current,
orientation: () => this.provider.opts.orientation.current orientation: () => this.provider.opts.orientation.current
} },
})
.part('group', {
id: opts.id,
ref: opts.ref,
owner: this, owner: this,
context: ToolbarGroupProvider.ctx, context: ToolbarGroupProvider.ctx,
syncAttrs: true syncAttrs: true
@ -310,17 +313,19 @@ export class ToolbarGroupItemProvider {
this.opts = opts; this.opts = opts;
this.group = ToolbarGroupProvider.require(); this.group = ToolbarGroupProvider.require();
this.provider = this.group.provider; this.provider = this.group.provider;
this.runtimePart = Soma.require() this.runtimePart = this.provider.runtime.part('group-item', {
.runtime(toolbarMorfo, { id: opts.id,
ref: opts.ref,
states: { states: {
on: () => this.isPressed on: () => this.isPressed
}, },
props: { props: {
disabled: () => this.isDisabled, disabled: () => this.isDisabled,
orientation: () => this.provider.opts.orientation.current orientation: () => this.provider.opts.orientation.current
} },
}) owner: this,
.part('group-item', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); syncAttrs: true
});
} }
readonly isPressed = $derived.by(() => this.group.isItemPressed(this.opts.value.current)); readonly isPressed = $derived.by(() => this.group.isItemPressed(this.opts.value.current));
@ -361,13 +366,15 @@ export class ToolbarSeparatorProvider {
private constructor(opts: ToolbarSeparatorOpts) { private constructor(opts: ToolbarSeparatorOpts) {
this.opts = opts; this.opts = opts;
this.provider = ToolbarProvider.require(); this.provider = ToolbarProvider.require();
this.runtimePart = Soma.require() this.runtimePart = this.provider.runtime.part('separator', {
.runtime(toolbarMorfo, { id: opts.id,
ref: opts.ref,
props: { props: {
orientation: () => this.separatorOrientation orientation: () => this.separatorOrientation
} },
}) owner: this,
.part('separator', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); syncAttrs: true
});
} }
/** Separator orientation is perpendicular to toolbar orientation. */ /** Separator orientation is perpendicular to toolbar orientation. */

@ -87,6 +87,80 @@ describe('createSomaRuntime', () => {
cleanup(); 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<HTMLElement | null>(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', () => { it('part can opt into runtime DOM attr synchronization', () => {
const ref = state<HTMLElement | null>(null); const ref = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => { const { cleanup } = withEffectRoot(() => {

@ -131,6 +131,8 @@ export interface SomaRuntimePartBaseOpts {
states?: SourceMap; states?: SourceMap;
/** Per-part prop sources (override component-level for this part only). */ /** Per-part prop sources (override component-level for this part only). */
props?: SourceMap; props?: SourceMap;
/** Per-part part-id sources (override component-level for this part only). */
parts?: SourceMap;
} }
export interface SomaRuntimePartContext<T = unknown> { export interface SomaRuntimePartContext<T = unknown> {
@ -238,6 +240,7 @@ interface PartRegistration {
attachment: RefAttachment | undefined; attachment: RefAttachment | undefined;
states: SourceMap | undefined; states: SourceMap | undefined;
props: SourceMap | undefined; props: SourceMap | undefined;
parts: SourceMap | undefined;
bindings: RuntimeBindingsScratch; bindings: RuntimeBindingsScratch;
/** Compiled part metadata so `partProps` doesn't re-walk the morfo tree. */ /** Compiled part metadata so `partProps` doesn't re-walk the morfo tree. */
meta: CompiledPart; meta: CompiledPart;
@ -259,13 +262,13 @@ function hasSourceKeys(map: SourceMap | undefined): boolean {
} }
function createBindingsScratch( function createBindingsScratch(
opts: Pick<SomaRuntimePartBaseOpts, 'states' | 'props'>, opts: Pick<SomaRuntimePartBaseOpts, 'states' | 'props' | 'parts'>,
sources: SomaRuntimeSources sources: SomaRuntimeSources
): RuntimeBindingsScratch { ): RuntimeBindingsScratch {
return { return {
states: hasSourceKeys(sources.states) || hasSourceKeys(opts.states) ? {} : undefined, states: hasSourceKeys(sources.states) || hasSourceKeys(opts.states) ? {} : undefined,
props: hasSourceKeys(sources.props) || hasSourceKeys(opts.props) ? {} : 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 translations: sources.translate
}; };
} }
@ -295,6 +298,7 @@ function readBindings(reg: PartRegistration, sources: SomaRuntimeSources): Morfo
readSourcesInto(bindings.parts, sources.parts); readSourcesInto(bindings.parts, sources.parts);
readSourcesInto(bindings.states, reg.states); readSourcesInto(bindings.states, reg.states);
readSourcesInto(bindings.props, reg.props); readSourcesInto(bindings.props, reg.props);
readSourcesInto(bindings.parts, reg.parts);
bindings.translations = sources.translate; bindings.translations = sources.translate;
return bindings; return bindings;
@ -321,6 +325,7 @@ export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): So
attachment: undefined, attachment: undefined,
states: opts.states, states: opts.states,
props: opts.props, props: opts.props,
parts: opts.parts,
bindings: createBindingsScratch(opts, sources), bindings: createBindingsScratch(opts, sources),
meta: compiledPart meta: compiledPart
}; };
@ -367,10 +372,10 @@ export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): So
return { return {
get attachment() { get attachment() {
return registrations.get(partName)?.attachment; return reg.attachment;
}, },
get props() { get props() {
return partProps(partName); return partPropsForRegistration(partName, reg);
}, },
resolveProps(bindings: MorfoBindings = {}) { resolveProps(bindings: MorfoBindings = {}) {
const props: Record<string, unknown> = { ...compiledPart.staticAttrs }; const props: Record<string, unknown> = { ...compiledPart.staticAttrs };
@ -390,7 +395,10 @@ export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): So
function partProps(part: string): Record<string, unknown> { function partProps(part: string): Record<string, unknown> {
const reg = registrations.get(part); const reg = registrations.get(part);
if (!reg) return {}; if (!reg) return {};
return partPropsForRegistration(part, reg);
}
function partPropsForRegistration(part: string, reg: PartRegistration): Record<string, unknown> {
const marker = compiled.parts.attrs[part]; const marker = compiled.parts.attrs[part];
const props: Record<string, unknown> = { const props: Record<string, unknown> = {
id: reg.id.current id: reg.id.current

Loading…
Cancel
Save

Powered by TurnKey Linux.