soma: Collapsible pilot — multi-part runtime migration

Second pilot of MorfoRuntime V1, validating the multi-part case left
unproven by Toggle. Collapsible has three providers (provider/trigger/
content), shares root state across them, and uses partRef cross-references
(`aria-controls` ↔ `aria-labelledby`).

Note on scope
The morfo doesn't declare `keyboard` — the Trigger is a `<button>` and
relies on native Enter/Space via onclick. So this pilot does NOT exercise
`runtime.keydown`; that's deferred to the next pilot with a real keyboard
contract (Tabs, Accordion, Listbox).

What this pilot validates
- Single runtime instance shared across three part-providers, created at
  the root and accessed by children via the Provider context.
- Cross-part state source: all three parts read `open` and `disabled`
  from the root provider's opts via `states` / `props` source maps.
- Cross-part id resolution: `parts.trigger`/`parts.content` getters read
  from the root's reactive `triggerId`/`contentId` State; partRef on
  Trigger and Content resolves once the corresponding sibling registers.
- Conditional emission with partRef: `aria-controls` (when Content present)
  and `aria-labelledby` (when Trigger present) toggle correctly as siblings
  mount/unmount.
- Effect timing across multiple parts: each part's effect runs in its own
  component scope, cleans up on unmount.

Morfo update
Same fix as Toggle: data entries got explicit `value` sources. `data-state`
references `stateRef('open')`; `data-disabled` references `propRef` with
`prop-truthy` condition. Without this the runtime had no way to source
the values (the legacy provider hard-coded them).

Provider migration
- CollapsibleProvider creates the runtime in its constructor with shared
  states/props/parts sources and registers the 'provider' part.
- CollapsibleTriggerProvider and CollapsibleContentProvider call
  `provider.runtime.registerPart(...)` for their slot, and publish their
  id to the root's State so partRef resolves.
- Each part's `props` derivation collapses from ~6 attrs (data-state,
  data-disabled, role, aria-*, type, etc.) to just `runtime.partProps()`
  + the few primitive attrs not in the morfo (`disabled`, `onclick`,
  `hidden`).

Verification
- 76/76 focused tests green.
- 66/66 morfo-check (Collapsible's emitted DOM matches the morfo).
- 68/68 smoke routes 200 OK.
- svelte-check unchanged.
morfo-runtime
dev 6 months ago
parent 62bcb1268f
commit 53d6539abe

@ -15,8 +15,13 @@ export const collapsibleMorfo = {
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-disabled', severity: 'optional' }
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{
attr: 'data-disabled',
value: v.propRef('disabled'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'disabled' }
}
],
aria: []
},
@ -29,8 +34,13 @@ export const collapsibleMorfo = {
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-disabled', severity: 'optional' }
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{
attr: 'data-disabled',
value: v.propRef('disabled'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'disabled' }
}
],
aria: [
{ attr: 'type', value: v.literal('button') },
@ -52,8 +62,13 @@ export const collapsibleMorfo = {
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-disabled', severity: 'optional' }
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{
attr: 'data-disabled',
value: v.propRef('disabled'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'disabled' }
}
],
aria: [
{

@ -1,15 +1,12 @@
import { Provider, context, type WithRefOpts } from '../../provider';
import {
createAttrs,
registerContract,
boolToEmptyStrOrUndef,
getDataOpenClosed
} from '../../attrs';
import { createAttrs, registerContract } from '../../attrs';
import { state, type ActiveProps, type StateProps } from '../../reactive';
import type { SomaMouseEvent } from '../../types';
import { Soma } from '../../core/soma.svelte';
import { createMorfoRuntime, type MorfoRuntime } from '../../morfo';
import { collapsibleMorfo } from '../../../morfo/components/collapsible';
const attrs = createAttrs(collapsibleMorfo);
createAttrs(collapsibleMorfo);
registerContract(collapsibleMorfo);
// ── Provider ─────────────────────────────────────────────────────────────────
@ -19,18 +16,44 @@ interface CollapsibleOpts
export class CollapsibleProvider extends Provider<CollapsibleOpts> {
static readonly ctx = context<CollapsibleProvider>('Collapsible');
static get(): CollapsibleProvider | undefined { return this.ctx.getOr(undefined) as CollapsibleProvider | undefined; }
static require(): CollapsibleProvider { return this.ctx.get(); }
static get(): CollapsibleProvider | undefined {
return this.ctx.getOr(undefined) as CollapsibleProvider | undefined;
}
static require(): CollapsibleProvider {
return this.ctx.get();
}
static create(opts: CollapsibleOpts) {
return new CollapsibleProvider(opts);
}
// H1 fix: track both IDs for ARIA relationship
// Cross-part id sources read by partRef('trigger') / partRef('content')
// in the Trigger/Content morfo declarations.
triggerId = state('');
contentId = state('');
readonly runtime: MorfoRuntime;
private constructor(opts: CollapsibleOpts) {
super(opts, 'Collapsible', 'provider', attrs.provider, CollapsibleProvider.ctx);
super(opts, { morfo: collapsibleMorfo, part: 'provider' }, CollapsibleProvider.ctx);
const soma = Soma.require();
this.runtime = createMorfoRuntime(collapsibleMorfo, {
dom: soma.dom,
states: {
open: () => opts.open.current
},
props: {
disabled: () => opts.disabled.current
},
parts: {
trigger: () => this.triggerId.current,
content: () => this.contentId.current
}
});
this.runtime.registerPart('provider', {
id: opts.id,
ref: opts.ref
});
}
toggle() {
@ -40,9 +63,7 @@ export class CollapsibleProvider extends Provider<CollapsibleOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-state': getDataOpenClosed(this.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current)
...this.runtime.partProps('provider')
} as const)
);
}
@ -59,11 +80,17 @@ export class CollapsibleTriggerProvider extends Provider<CollapsibleTriggerOpts>
readonly provider: CollapsibleProvider;
private constructor(opts: CollapsibleTriggerOpts) {
super(opts, 'Collapsible', 'trigger', attrs.trigger);
super(opts, { morfo: collapsibleMorfo, part: 'trigger' });
this.provider = CollapsibleProvider.require();
// H1 fix: register trigger ID + L2 fix: sync reactively
// Publish the trigger id so Content's `aria-labelledby` (partRef trigger)
// resolves; the runtime reads this via the parts source on the root.
this.provider.triggerId.current = opts.id.current;
this.provider.runtime.registerPart('trigger', {
id: opts.id,
ref: opts.ref
});
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
@ -72,12 +99,7 @@ export class CollapsibleTriggerProvider extends Provider<CollapsibleTriggerOpts>
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' 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),
...this.provider.runtime.partProps('trigger'),
disabled: this.provider.opts.disabled.current || undefined,
onclick: this.onclick
} as const)
@ -96,24 +118,25 @@ export class CollapsibleContentProvider extends Provider<CollapsibleContentOpts>
readonly provider: CollapsibleProvider;
private constructor(opts: CollapsibleContentOpts) {
super(opts, 'Collapsible', 'content', attrs.content);
super(opts, { morfo: collapsibleMorfo, part: 'content' });
this.provider = CollapsibleProvider.require();
// L2 fix: sync content ID reactively
// Publish the content id so Trigger's `aria-controls` (partRef content)
// resolves on the next runtime tick.
this.provider.contentId.current = opts.id.current;
this.provider.runtime.registerPart('content', {
id: opts.id,
ref: opts.ref
});
}
readonly isOpen = $derived.by(() => this.provider.opts.open.current);
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'region' as const,
// H1 fix: aria-labelledby links content to its trigger
'aria-labelledby': this.provider.triggerId.current || undefined,
hidden: this.isOpen ? undefined : true,
'data-state': getDataOpenClosed(this.isOpen),
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current)
...this.provider.runtime.partProps('content'),
hidden: this.isOpen ? undefined : true
} as const)
);
}

Loading…
Cancel
Save

Powered by TurnKey Linux.