From 59a851850c42ae39b62d261a964b7294fcbb7cfb Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 11 Jun 2026 22:58:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(soma):=20morfo-sourced=20part=20props=20?= =?UTF-8?q?=E2=80=94=20renderProps()=20+=20prop-defined=20(pilot)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for removing the morfo↔soma attribute duplication: providers that compose attrs in their `props` getter were re-declaring role/aria/data that the morfo already declares (a second source that drifts — the spin-field bug). - runtime: new `SomaRuntimePart.renderProps()` — static identity + every morfo-declared static/dynamic attr, resolved against THIS part's registered sources. A part's getter spreads it and adds ONLY soma-specific extras (handlers, formatted values, native attrs), instead of hardcoding role/aria. - morfo: new `prop-defined` condition (`emitted iff prop !== undefined`) so an optional numeric aria (aria-valuemin at min=0) emits correctly — `prop-truthy` wrongly dropped 0. Wired through types, resolver, schema, compile dep-collect. - NumberField Input migrated as the reference: registers value/min/max as part sources, spreads `renderProps()`, keeps only soma extras. Removes the hardcoded role + aria-valuenow/min/max + data-spin-field-input duplication. ARIA booleans (aria-required/disabled/…) stay soma overrides for now — a propRef-valued aria compiles to raw mode, so soma still stringifies them; a future `v.ariaBool()` helper would let those resolve from the morfo too. - COMPONENT_GUIDE: "Part props: read the morfo, don't re-declare it" doctrine. Verified bit-for-bit in browser (role/aria/data identical incl. min=0 → aria-valuemin="0") + provider tests 7/7. The survey found ~⅔ of components carry this duplication (form controls 70%); this lands the pattern + the NumberField Input reference. Family rollout is the documented backlog. Co-Authored-By: Claude Opus 4.8 --- src/uix/morfo/compile.ts | 6 ++- src/uix/morfo/components/number-field.ts | 4 +- src/uix/morfo/resolver.ts | 1 + src/uix/morfo/schema.ts | 3 +- src/uix/morfo/types.ts | 5 ++- src/uix/soma/COMPONENT_GUIDE.md | 37 +++++++++++++++++++ .../number-field-provider.svelte.ts | 29 +++++++++++---- src/uix/soma/runtime.svelte.ts | 28 ++++++++++++++ 8 files changed, 101 insertions(+), 12 deletions(-) diff --git a/src/uix/morfo/compile.ts b/src/uix/morfo/compile.ts index b6a4c325a..5fe3cc83a 100644 --- a/src/uix/morfo/compile.ts +++ b/src/uix/morfo/compile.ts @@ -670,7 +670,11 @@ function collectConditionDeps( ): void { if (condition === 'always') return if (condition.when === 'state-equals') states.add(condition.state) - else if (condition.when === 'prop-truthy' || condition.when === 'prop-falsy') + else if ( + condition.when === 'prop-truthy' || + condition.when === 'prop-falsy' || + condition.when === 'prop-defined' + ) props.add(condition.prop) else if (condition.when === 'part-present') partRefs.add(condition.part) } diff --git a/src/uix/morfo/components/number-field.ts b/src/uix/morfo/components/number-field.ts index 9be9bf5e2..cd9dcb826 100644 --- a/src/uix/morfo/components/number-field.ts +++ b/src/uix/morfo/components/number-field.ts @@ -83,13 +83,13 @@ export const numberFieldMorfo = { attr: 'aria-valuemin', value: v.propRef('min'), severity: 'optional', - condition: { when: 'prop-truthy', prop: 'min' } + condition: { when: 'prop-defined', prop: 'min' } }, { attr: 'aria-valuemax', value: v.propRef('max'), severity: 'optional', - condition: { when: 'prop-truthy', prop: 'max' } + condition: { when: 'prop-defined', prop: 'max' } }, { attr: 'aria-valuetext', value: v.propRef('value'), severity: 'recommended' }, { attr: 'aria-required', value: v.propRef('required'), severity: 'optional' }, diff --git a/src/uix/morfo/resolver.ts b/src/uix/morfo/resolver.ts index 7bf1edc16..2fe93a019 100644 --- a/src/uix/morfo/resolver.ts +++ b/src/uix/morfo/resolver.ts @@ -45,6 +45,7 @@ export function shouldEmitMorfoEntry( 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]; + if (condition.when === 'prop-defined') return bindings.props?.[condition.prop] !== undefined; return true; } diff --git a/src/uix/morfo/schema.ts b/src/uix/morfo/schema.ts index 90fd14fa5..946e2da0e 100644 --- a/src/uix/morfo/schema.ts +++ b/src/uix/morfo/schema.ts @@ -139,7 +139,8 @@ const conditionObjectSchema = discriminated('when', [ value: string() }), object({ when: literal('prop-truthy'), prop: string() }), - object({ when: literal('prop-falsy'), prop: string() }) + object({ when: literal('prop-falsy'), prop: string() }), + object({ when: literal('prop-defined'), prop: string() }) ]); const conditionSchema = union(literal('always'), conditionObjectSchema) as Schema< diff --git a/src/uix/morfo/types.ts b/src/uix/morfo/types.ts index 67e612f83..838a2d230 100644 --- a/src/uix/morfo/types.ts +++ b/src/uix/morfo/types.ts @@ -154,13 +154,16 @@ export type MorfoDataValue = MorfoValueSource; * `{ when: 'state-equals', state, value }` — emitted iff state === value * `{ when: 'prop-truthy', prop }` — emitted iff consumer prop is truthy * `{ when: 'prop-falsy', prop }` — emitted iff consumer prop is falsy + * `{ when: 'prop-defined', prop }` — emitted iff consumer prop !== undefined + * (e.g. aria-valuemin when min is 0) */ export type MorfoCondition = | 'always' | { when: 'part-present'; part: string } | { when: 'state-equals'; state: string; value: string } | { when: 'prop-truthy'; prop: string } - | { when: 'prop-falsy'; prop: string }; + | { when: 'prop-falsy'; prop: string } + | { when: 'prop-defined'; prop: string }; // ── Severity ────────────────────────────────────────────────────────────── diff --git a/src/uix/soma/COMPONENT_GUIDE.md b/src/uix/soma/COMPONENT_GUIDE.md index 7622499ab..5bfbcecf4 100644 --- a/src/uix/soma/COMPONENT_GUIDE.md +++ b/src/uix/soma/COMPONENT_GUIDE.md @@ -158,6 +158,43 @@ export class {Name}TriggerProvider { } ``` +### Part props: read the morfo, don't re-declare it + +"Morfo declares, soma executes" — a part's `role` / `aria-*` / `data-*` live in +the morfo. A provider must NEVER re-declare them as literals in its `props` +getter (that is duplication: the same attr in two sources, which drift). Two +sanctioned ways to apply them: + +- **No soma-specific extras** → `syncAttrs: true` (the runtime writes the morfo + attrs via `dom.apply`). The `props` getter is identity-only + (`...this.runtimePart.props`) plus event handlers. +- **Needs soma-specific extras** (event handlers, a locale-formatted value, a + native form attr the morfo doesn't model) → spread + **`...this.runtimePart.renderProps()`** (static identity + every morfo attr, + resolved against this part's registered `props`/`states` sources), then add + ONLY the extras. Register the value sources at the `runtime.part(...)` call: + + ```ts + this.runtimePart = provider.runtime.part('input', { + id, ref, owner: this, + props: { value: () => provider.value, min: () => provider.min } + }); + + readonly props = $derived.by(() => this.runtimePart.assert({ + ...this.runtimePart.renderProps(), // role, aria-valuenow/min, data-* + oninput: this.oninput, // handler (morfo can't model) + 'aria-valuetext': this.formatValue(...) // formatted (overrides raw morfo) + })); + ``` + + Override a morfo attr only when soma genuinely owns the *value* (formatting, + stringifying an ARIA boolean). Never override it just to repeat it. + +> **Anti-pattern**: `{ ...this.runtimePart.props, role: 'spinbutton', 'aria-disabled': ... }` +> — `role`/`aria-disabled` are morfo-declared; spreading `renderProps()` supplies +> them. Most existing providers still do this (a documented migration backlog); +> NumberField's Input is the reference for the corrected shape. + ### Static Method Convention All classes that use Svelte context follow the same 3-method pattern: diff --git a/src/uix/soma/components/number-field/number-field-provider.svelte.ts b/src/uix/soma/components/number-field/number-field-provider.svelte.ts index ab18294d5..4764c1cfe 100644 --- a/src/uix/soma/components/number-field/number-field-provider.svelte.ts +++ b/src/uix/soma/components/number-field/number-field-provider.svelte.ts @@ -570,7 +570,16 @@ export class NumberFieldInputProvider { this.runtimePart = this.provider.runtime.part('input', { id: opts.id, ref: opts.ref, - owner: this + owner: this, + // Per-part sources for the morfo's value-bound aria (the component-level + // sources only carry disabled/readonly/required/invalid). With these, + // `renderProps()` resolves aria-valuenow/min/max from the morfo instead + // of the provider re-declaring them. + props: { + value: () => this.provider.opts.value.current, + min: () => this.provider.opts.min.current, + max: () => this.provider.opts.max.current + } }); // When embedded inside Field, register this input id so Field.Label's // `for=` attribute targets it. @@ -591,22 +600,28 @@ export class NumberFieldInputProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'spinbutton' as const, + // Morfo-declared attrs (role, data-spin-field-input, aria-valuenow/min/ + // max/required/disabled/readonly/invalid), resolved against this part's + // registered sources — no longer re-declared in the provider. + ...this.runtimePart.renderProps(), + // Soma-only: what the morfo can't express. inputmode: 'decimal' as const, - 'data-spin-field-input': '', autocomplete: 'off' as const, autocorrect: 'off' as const, spellcheck: false, dir: this.provider.dir, value: this.provider.inputValue, - 'aria-valuenow': this.provider.opts.value.current, - 'aria-valuemin': this.provider.opts.min.current, - 'aria-valuemax': this.provider.opts.max.current, + // Morfo declares the raw value for aria-valuetext; soma supplies the + // locale-formatted text. 'aria-valuetext': this.provider.opts.value.current !== undefined ? this.provider.formatValue(this.provider.opts.value.current) : undefined, + // ARIA booleans: the morfo declares them (propRef), but a propRef-valued + // aria attr compiles to raw mode → renderProps emits the boolean. ARIA + // wants the "true"/"false" string, so soma stringifies (same role as the + // formatted aria-valuetext above). A future `v.ariaBool()` morfo helper + // would let these resolve from the morfo too. 'aria-required': boolToStr(this.provider.isRequired), 'aria-disabled': boolToStr(this.provider.isDisabled), 'aria-readonly': boolToStr(this.provider.isReadonly), diff --git a/src/uix/soma/runtime.svelte.ts b/src/uix/soma/runtime.svelte.ts index 6fc4a7f24..69544c1aa 100644 --- a/src/uix/soma/runtime.svelte.ts +++ b/src/uix/soma/runtime.svelte.ts @@ -181,6 +181,13 @@ export interface SomaRuntimePart { readonly props: Record; /** Resolve morfo static/dynamic attrs for render-time legacy/manual props. */ resolveProps(bindings?: MorfoBindings): Record; + /** + * Static identity + every morfo-declared attr resolved against THIS part's + * registered sources. Spread this in a part's `props` getter, then add only + * the soma-specific extras (handlers, formatted overrides, native attrs). + * The canonical alternative to hardcoding role/aria/data in the provider. + */ + renderProps(): Record; /** Validate an authored prop bag against the morfo data contract. */ assert

>(props: P): P; } @@ -482,6 +489,27 @@ export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): So } return props; }, + renderProps() { + // The full render bag for a part that composes its attrs in Svelte + // props (rather than `syncAttrs: true`): static identity + every + // morfo-declared static/dynamic attr, resolved against THIS part's + // registered sources (`opts.props/states/parts` merged with the + // component-level sources). The caller spreads this and then adds + // ONLY what the morfo can't express — event handlers, formatted + // values (override the raw morfo value), native form attrs. This is + // how "morfo declares, soma executes" holds without re-declaring + // role/aria/data in the provider. + const bindings = readBindings(reg, sources); + const props: Record = { + ...partPropsForRegistration(partName, reg), + ...compiledPart.staticAttrs + }; + for (const plan of compiledPart.dynamicAttrs) { + const value = evalAttrPlan(plan, bindings); + if (value !== undefined) props[plan.attr] = value; + } + return props; + }, assert

>(props: P): P { assertContract(compiled.kebab, partName, props, sources.logger); return props;