diff --git a/src/uix/morfo/components/toggle.ts b/src/uix/morfo/components/toggle.ts index 504b26771..9f5467d4e 100644 --- a/src/uix/morfo/components/toggle.ts +++ b/src/uix/morfo/components/toggle.ts @@ -15,11 +15,31 @@ export const toggleMorfo = { optional: false, states: ['on', 'off'], data: [ - { attr: 'data-state', values: ['on', 'off'] }, - { attr: 'data-disabled', severity: 'optional' }, - { attr: 'data-readonly', severity: 'optional' }, - { attr: 'data-invalid', severity: 'optional' }, - { attr: 'data-required', severity: 'optional' } + { attr: 'data-state', values: ['on', 'off'], value: v.stateRef('on') }, + { + attr: 'data-disabled', + value: v.propRef('disabled'), + severity: 'optional', + condition: { when: 'prop-truthy', prop: 'disabled' } + }, + { + attr: 'data-readonly', + value: v.propRef('readonly'), + severity: 'optional', + condition: { when: 'prop-truthy', prop: 'readonly' } + }, + { + attr: 'data-invalid', + value: v.propRef('invalid'), + severity: 'optional', + condition: { when: 'prop-truthy', prop: 'invalid' } + }, + { + attr: 'data-required', + value: v.propRef('required'), + severity: 'optional', + condition: { when: 'prop-truthy', prop: 'required' } + } ], aria: [ { attr: 'type', value: v.literal('button') }, diff --git a/src/uix/soma/components/toggle/toggle-provider.svelte.ts b/src/uix/soma/components/toggle/toggle-provider.svelte.ts index 3dc6de215..a0d280b1f 100644 --- a/src/uix/soma/components/toggle/toggle-provider.svelte.ts +++ b/src/uix/soma/components/toggle/toggle-provider.svelte.ts @@ -1,7 +1,9 @@ import { Provider, type WithRefOpts } from '../../provider'; -import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs'; +import { createAttrs, registerContract } from '../../attrs'; import type { ActiveProps, StateProps } from '../../reactive'; import type { OnChangeFn, SomaMouseEvent } from '../../types'; +import { Soma } from '../../core/soma.svelte'; +import { createMorfoRuntime, type MorfoRuntime } from '../../morfo'; import { FieldProvider } from '../field/field-provider.svelte'; import { toggleMorfo } from '../../../morfo/components/toggle'; @@ -32,12 +34,36 @@ export class ToggleProvider extends Provider { } readonly field = FieldProvider.get(); + readonly runtime: MorfoRuntime; private constructor(opts: ToggleOpts) { - super(opts, 'Toggle', 'provider', attrs.provider); + super(opts, { morfo: toggleMorfo, part: 'provider' }); + // Register as the Field's primary input so `Field.Label`'s `for` // attribute lands on this button. Direct assign — NOT $effect (A30). if (this.field) this.field.inputId.current = opts.id.current; + + // MorfoRuntime — V1 pilot. Owns the morfo-derived attrs (data-state, + // aria-pressed, aria-disabled, etc.); this provider keeps only handler + // wiring + the HTML `disabled` attribute (not a morfo concern). + const soma = Soma.require(); + this.runtime = createMorfoRuntime(toggleMorfo, { + dom: soma.dom, + states: { + on: () => opts.pressed.current + }, + props: { + disabled: () => this.isDisabled, + readonly: () => this.isReadonly, + required: () => this.isRequired, + invalid: () => this.isInvalid, + ariaLabel: () => opts.ariaLabel.current + } + }); + this.runtime.registerPart('provider', { + id: opts.id, + ref: opts.ref + }); } readonly isDisabled = $derived.by( @@ -64,23 +90,24 @@ export class ToggleProvider extends Provider { this.opts.onPressedChange.current?.(next); }; + /** + * Render-time props. Only includes: + * - identity from the runtime (`id`, `data-toggle`, ref attachment) + * - the HTML `disabled` attribute (not in morfo — primitive button concern) + * - the `onclick` handler + * + * All morfo-declared attrs (`data-state`, `aria-pressed`, `aria-disabled`, + * etc.) are applied to the DOM by the runtime's effect via `dom.apply`. + */ readonly props = $derived.by(() => this.assertProps({ - ...this.baseProps, - type: 'button' as const, - 'aria-pressed': this.opts.pressed.current, - 'aria-label': this.opts.ariaLabel.current, - 'aria-disabled': this.isDisabled || undefined, - 'aria-readonly': this.isReadonly || undefined, - 'aria-required': this.isRequired || undefined, - 'aria-invalid': this.isInvalid || undefined, - 'data-state': this.opts.pressed.current ? 'on' : 'off', - 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), - 'data-readonly': boolToEmptyStrOrUndef(this.isReadonly), - 'data-required': boolToEmptyStrOrUndef(this.isRequired), - 'data-invalid': boolToEmptyStrOrUndef(this.isInvalid), + ...this.runtime.partProps('provider'), disabled: this.isDisabled || undefined, onclick: this.onclick } as const) ); } + +// `attrs` is exported for any consumer needing the static marker name (e.g. +// querySelector in tests). The runtime owns the runtime emission. +export { attrs as toggleAttrs }; diff --git a/src/uix/soma/morfo/index.ts b/src/uix/soma/morfo/index.ts new file mode 100644 index 000000000..ebf4563a3 --- /dev/null +++ b/src/uix/soma/morfo/index.ts @@ -0,0 +1,17 @@ +export { + createMorfoRuntime, + type MorfoRuntime, + type MorfoRuntimeSources, + type RegisterPartOpts, + type SourceMap +} from './runtime.svelte'; + +export { + findMorfoPart, + resolvePartAttrs, + resolveMorfoSource, + resolveMorfoDataValue, + resolveMorfoAriaValue, + shouldEmitMorfoEntry, + type MorfoBindings +} from './resolver'; diff --git a/src/uix/soma/morfo/resolver.ts b/src/uix/soma/morfo/resolver.ts new file mode 100644 index 000000000..a99517460 --- /dev/null +++ b/src/uix/soma/morfo/resolver.ts @@ -0,0 +1,142 @@ +/** + * Pure morfo → attrs resolver. + * + * Walks a `MorfoPart`'s `data` and `aria` declarations, evaluates conditions, + * resolves value sources against the supplied bindings, and returns a flat + * attribute map ready to render or to pass to `dom.apply`. + * + * No reactivity, no side-effects, no DOM. The runtime layer wraps these in + * effects; the Provider base class wraps them in a `$derived.by`. + */ + +import type { + MorfoAriaEntry, + MorfoCondition, + MorfoData, + MorfoPart, + MorfoPrimitiveValueSource, + MorfoValueSource +} from '../../morfo/types'; + +export interface MorfoBindings { + props?: Record; + states?: Record; + parts?: Record; + translations?: (key: string) => string | undefined; +} + +export function findMorfoPart(parts: readonly MorfoPart[], target: string): MorfoPart | undefined { + for (const part of parts) { + if (part.kebab === target) return part; + if (part.parts) { + const nested = findMorfoPart(part.parts, target); + if (nested) return nested; + } + } + return undefined; +} + +export function shouldEmitMorfoEntry( + condition: MorfoCondition | undefined, + bindings: MorfoBindings +): boolean { + if (!condition || condition === 'always') return true; + if (condition.when === 'part-present') return Boolean(bindings.parts?.[condition.part]); + if (condition.when === 'state-equals') return bindings.states?.[condition.state] === condition.value; + if (condition.when === 'prop-truthy') return Boolean(bindings.props?.[condition.prop]); + if (condition.when === 'prop-falsy') return !bindings.props?.[condition.prop]; + return true; +} + +function resolveMorfoPrimitiveSource( + source: MorfoPrimitiveValueSource, + bindings: MorfoBindings +): unknown { + if (source.kind === 'literal') return source.value; + if (source.kind === 'stateRef') return bindings.states?.[source.state]; + if (source.kind === 'partRef') return bindings.parts?.[source.target]; + if (source.kind === 'propRef') return bindings.props?.[source.prop]; + if (source.kind === 'translationRef') return bindings.translations?.(source.key); + return undefined; +} + +export function resolveMorfoSource(source: MorfoValueSource, bindings: MorfoBindings): unknown { + if (source.kind !== 'mapRef') { + return resolveMorfoPrimitiveSource(source, bindings); + } + + const raw = resolveMorfoPrimitiveSource(source.source, bindings); + if (raw === undefined || raw === null) { + return source.fallback; + } + + const mapped = source.map[String(raw)]; + return mapped ?? source.fallback; +} + +export function resolveMorfoDataValue(data: MorfoData, bindings: MorfoBindings): unknown { + const source = data.value; + if (!source) return undefined; + const raw = resolveMorfoSource(source, bindings); + + if (!data.values || data.values.length === 0) { + return raw ? '' : undefined; + } + + if (source.kind === 'stateRef' && typeof raw === 'boolean') { + if (raw) return source.state; + return data.values.find((value) => value !== source.state); + } + + return raw; +} + +export function resolveMorfoAriaValue(entry: MorfoAriaEntry, bindings: MorfoBindings): unknown { + const raw = resolveMorfoSource(entry.value, bindings); + + if (entry.value.kind === 'stateRef') { + // ARIA boolean attrs require literal 'true'/'false' strings, not the + // HTML boolean attribute semantics. `aria-pressed="false"` is a valid + // state distinct from the attribute being absent — never remove it + // based on falsy values. + return Boolean(raw) ? 'true' : 'false'; + } + + return raw; +} + +/** + * Resolves a part's full attribute map (role + data-* + aria-*) against the + * given bindings. Returns `undefined`-valued entries for declared attrs that + * shouldn't be emitted in the current state — the caller decides whether to + * skip them (Svelte does it automatically; `dom.apply` removes them). + */ +export function resolvePartAttrs( + partMeta: MorfoPart, + bindings: MorfoBindings +): Record { + const result: Record = {}; + + if (partMeta.role) { + result.role = partMeta.role; + } + + for (const data of partMeta.data) { + if (!data.value) continue; + if (!shouldEmitMorfoEntry(data.condition, bindings)) continue; + const value = resolveMorfoDataValue(data, bindings); + if (value !== undefined) { + result[data.attr] = value; + } + } + + for (const aria of partMeta.aria) { + if (!shouldEmitMorfoEntry(aria.condition, bindings)) continue; + const value = resolveMorfoAriaValue(aria, bindings); + if (value !== undefined) { + result[aria.attr] = value; + } + } + + return result; +} diff --git a/src/uix/soma/morfo/runtime.svelte.test.ts b/src/uix/soma/morfo/runtime.svelte.test.ts new file mode 100644 index 000000000..ef6595a80 --- /dev/null +++ b/src/uix/soma/morfo/runtime.svelte.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { flushSync } from 'svelte' + +import { createActiveDom } from '$uix/adom' + +import { state } from '../reactive' +import { toggleMorfo } from '../../morfo/components/toggle' +import { createMorfoRuntime } from './runtime.svelte' + +/** + * Effect roots are normally provided by Svelte components. For unit tests we + * wrap the runtime construction in `$effect.root` so `$effect` calls inside + * `registerPart` have a parent to attach to. + */ +function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { + let result!: T + const cleanup = $effect.root(() => { + result = fn() + }) + return { result, cleanup } +} + +describe('createMorfoRuntime', () => { + let target: HTMLElement + let dom: ReturnType + + beforeEach(() => { + document.body.innerHTML = '' + target = document.createElement('button') + target.id = 'tog-1' + document.body.appendChild(target) + dom = createActiveDom() + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + it('partProps returns identity (id + marker) without mutable attrs', () => { + const { result: runtime, cleanup } = withEffectRoot(() => { + const id = state('tog-1') + const ref = state(null) + const r = createMorfoRuntime(toggleMorfo, { + dom, + states: { on: () => false } + }) + r.registerPart('provider', { id, ref }) + return r + }) + + const props = runtime.partProps('provider') + expect(props.id).toBe('tog-1') + expect(props['data-toggle']).toBe('') + // Mutable attrs are NOT in partProps; they're written via dom.apply by the effect. + expect(props['data-state']).toBeUndefined() + expect(props['aria-pressed']).toBeUndefined() + cleanup() + }) + + it('writes morfo-derived attrs to DOM when ref attaches', () => { + const ref = state(null) + const { cleanup } = withEffectRoot(() => { + const id = state('tog-1') + const r = createMorfoRuntime(toggleMorfo, { + dom, + states: { on: () => false }, + props: { + disabled: () => false, + readonly: () => false, + required: () => false, + invalid: () => false, + ariaLabel: () => undefined + } + }) + r.registerPart('provider', { id, ref }) + }) + + // Simulate Svelte's attachment firing — the runtime registered a setter + // on the ref via attachRef, but in tests we mutate the ref directly. + ref.current = target + flushSync() + + expect(target.getAttribute('data-state')).toBe('off') + expect(target.getAttribute('aria-pressed')).toBe('false') + expect(target.getAttribute('type')).toBe('button') + expect(target.hasAttribute('data-disabled')).toBe(false) + expect(target.hasAttribute('aria-disabled')).toBe(false) + cleanup() + }) + + it('reapplies attrs when sources change', () => { + const ref = state(null) + const pressed = state(false) + const disabled = state(false) + const { cleanup } = withEffectRoot(() => { + const id = state('tog-1') + const r = createMorfoRuntime(toggleMorfo, { + dom, + states: { on: () => pressed.current }, + props: { + disabled: () => disabled.current, + readonly: () => false, + required: () => false, + invalid: () => false, + ariaLabel: () => undefined + } + }) + r.registerPart('provider', { id, ref }) + }) + + ref.current = target + flushSync() + expect(target.getAttribute('data-state')).toBe('off') + + pressed.current = true + flushSync() + expect(target.getAttribute('data-state')).toBe('on') + expect(target.getAttribute('aria-pressed')).toBe('true') + + disabled.current = true + flushSync() + expect(target.getAttribute('aria-disabled')).toBe('true') + cleanup() + }) + + it('throws when registering an unknown part', () => { + const { cleanup } = withEffectRoot(() => { + const r = createMorfoRuntime(toggleMorfo, { dom }) + expect(() => r.registerPart('nonexistent', { id: state('x') })).toThrow(/not declared/) + }) + cleanup() + }) +}) diff --git a/src/uix/soma/morfo/runtime.svelte.ts b/src/uix/soma/morfo/runtime.svelte.ts new file mode 100644 index 000000000..f7168b4d3 --- /dev/null +++ b/src/uix/soma/morfo/runtime.svelte.ts @@ -0,0 +1,171 @@ +/** + * MorfoRuntime — interpreter of a component's morfo within Soma. + * + * V1 surface: + * - `registerPart(part, opts)` — the part-provider declares its instance to + * the runtime (id source, optional ref, optional per-part sources). Sets up + * an internal `$effect` that recomputes the part's mutable attrs whenever + * any of its sources change and applies them via `dom.apply`. + * - `partProps(part)` — returns ONLY the part's static identity (id, marker, + * ref attachment). Mutable attrs (data-state, aria-*, etc.) are written to + * the DOM by the runtime's effect, never via Svelte render. + * + * Operational rules: + * - `partProps` must not include any state-derived attr — that would race + * with `dom.apply`. The boundary is identity vs. derivation. + * - `registerPart` must be called inside an effect root (a Svelte component + * scope or a class constructor invoked during component init), because the + * per-part effect uses `$effect`. + * - Effects are best-effort idempotent: each tick reapplies the full attr + * map for that part. Missing attrs are removed via `dom.apply`'s + * `false/null/undefined` handling. + * + * Not in V1 (planned for V2): + * - `keydown(part, event)` — dispatch from `morfo.keyboard`. + * - `trigger(eventName)` — orchestrates prewrite + semantic.emit + handler. + * - `OptsFromProps` + `bindProps` helper to absorb the props bridge. + */ + +import { untrack } from 'svelte'; +import type { ActiveDom, DomAttrValue } from '$uix/adom'; + +import type { Morfo, MorfoPart } from '../../morfo/types'; +import { createAttrs } from '../attrs'; +import { attachRef, type RefAttachment } from '../provider/provider.svelte'; +import type { Active, State } from '../reactive'; + +import { + findMorfoPart, + resolvePartAttrs, + type MorfoBindings +} from './resolver'; + +export type SourceMap = Record unknown>; + +export interface MorfoRuntimeSources { + /** DOM service. Required — the runtime applies attrs through it. */ + dom: ActiveDom; + /** Component-wide state sources. Read by `stateRef` declarations. */ + states?: SourceMap; + /** Component-wide prop sources. Read by `propRef` declarations. */ + props?: SourceMap; + /** Component-wide part-id sources. Read by `partRef` declarations. */ + parts?: SourceMap; + /** Translation lookup. Read by `translationRef` declarations. */ + translate?: (key: string) => string | undefined; +} + +export interface RegisterPartOpts { + /** Per-instance id of this part (from the part-provider's opts). */ + id: Active; + /** Per-instance ref. Required for parts with DOM. */ + ref?: State; + /** Optional callback when the ref attaches/detaches. */ + onRefChange?: (el: HTMLElement | null) => void; + /** Per-part state sources (override component-level for this part only). */ + states?: SourceMap; + /** Per-part prop sources (override component-level for this part only). */ + props?: SourceMap; +} + +export interface MorfoRuntime { + registerPart(part: string, opts: RegisterPartOpts): void; + partProps(part: string): Record; +} + +interface PartRegistration { + id: Active; + ref: State | undefined; + attachment: RefAttachment | undefined; + states: SourceMap | undefined; + props: SourceMap | undefined; +} + +function readBindings( + reg: PartRegistration, + sources: MorfoRuntimeSources +): MorfoBindings { + const states: Record = {}; + const props: Record = {}; + const parts: Record = {}; + + if (sources.states) { + for (const key in sources.states) states[key] = sources.states[key](); + } + if (sources.props) { + for (const key in sources.props) props[key] = sources.props[key](); + } + if (sources.parts) { + for (const key in sources.parts) parts[key] = sources.parts[key](); + } + + if (reg.states) { + for (const key in reg.states) states[key] = reg.states[key](); + } + if (reg.props) { + for (const key in reg.props) props[key] = reg.props[key](); + } + + return { states, props, parts, translations: sources.translate }; +} + +export function createMorfoRuntime( + morfo: Morfo, + sources: MorfoRuntimeSources +): MorfoRuntime { + const attrs = createAttrs(morfo); + const registrations = new Map(); + + function registerPart(part: string, opts: RegisterPartOpts): void { + const partMeta = findMorfoPart(morfo.parts as readonly MorfoPart[], part); + if (!partMeta) { + throw new Error( + `[morfo-runtime] Part "${part}" not declared in morfo "${morfo.kebab}".` + ); + } + + const reg: PartRegistration = { + id: opts.id, + ref: opts.ref, + attachment: undefined, + states: opts.states, + props: opts.props + }; + + if (opts.ref) { + reg.attachment = attachRef(opts.ref, (el) => { + untrack(() => opts.onRefChange?.(el)); + }); + } + + registrations.set(part, reg); + + // Effect: whenever the part's ref attaches OR any source changes, + // recompute the morfo-derived attrs and write them via dom.apply. + // Reading `reg.ref.current` is the reactive subscription point — the + // `attachRef` setter mutates it when Svelte's attachment callback fires. + $effect(() => { + const target = reg.ref ? reg.ref.current : null; + if (!target) return; + const bindings = readBindings(reg, sources); + const resolved = resolvePartAttrs(partMeta, bindings); + sources.dom.apply({ target, attrs: resolved as Record }); + }); + } + + function partProps(part: string): Record { + const reg = registrations.get(part); + if (!reg) return {}; + + const marker = attrs[part]; + const props: Record = { + id: reg.id.current + }; + if (marker) props[marker] = ''; + if (reg.attachment) Object.assign(props, reg.attachment); + + return props; + } + + return { registerPart, partProps }; +}