diff --git a/src/uix/morfo/components/collapsible.ts b/src/uix/morfo/components/collapsible.ts index de8a18977..d7bd4451c 100644 --- a/src/uix/morfo/components/collapsible.ts +++ b/src/uix/morfo/components/collapsible.ts @@ -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: [ { diff --git a/src/uix/soma/components/collapsible/collapsible-provider.svelte.ts b/src/uix/soma/components/collapsible/collapsible-provider.svelte.ts index 1df502c7a..1dbe1097b 100644 --- a/src/uix/soma/components/collapsible/collapsible-provider.svelte.ts +++ b/src/uix/soma/components/collapsible/collapsible-provider.svelte.ts @@ -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 { static readonly ctx = context('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 { 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 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) => { @@ -72,12 +99,7 @@ export class CollapsibleTriggerProvider extends Provider 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 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) ); }