diff --git a/src/uix/soma/morfo/resolver.ts b/src/uix/soma/morfo/resolver.ts index a99517460..6084d1e51 100644 --- a/src/uix/soma/morfo/resolver.ts +++ b/src/uix/soma/morfo/resolver.ts @@ -107,9 +107,17 @@ export function resolveMorfoAriaValue(entry: MorfoAriaEntry, bindings: MorfoBind /** * 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). + * given bindings. + * + * The map ALWAYS includes every declared attr (data with a `value` source + + * every aria entry). Attrs whose condition is falsy or whose value source + * resolves to nothing are emitted as `undefined`, which `dom.apply` reads as + * "remove this attribute" and Svelte ignores during render. This is what + * lets the runtime clean up attrs that previously matched their condition + * but no longer do (e.g. `data-invalid` toggling from set to absent). + * + * Entries with no `value` source on `data` are skipped entirely — they are + * documentation in the morfo, not runtime-managed attrs. */ export function resolvePartAttrs( partMeta: MorfoPart, @@ -123,19 +131,19 @@ export function resolvePartAttrs( 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; + if (!shouldEmitMorfoEntry(data.condition, bindings)) { + result[data.attr] = undefined; + continue; } + result[data.attr] = resolveMorfoDataValue(data, bindings); } for (const aria of partMeta.aria) { - if (!shouldEmitMorfoEntry(aria.condition, bindings)) continue; - const value = resolveMorfoAriaValue(aria, bindings); - if (value !== undefined) { - result[aria.attr] = value; + if (!shouldEmitMorfoEntry(aria.condition, bindings)) { + result[aria.attr] = undefined; + continue; } + result[aria.attr] = resolveMorfoAriaValue(aria, bindings); } return result; diff --git a/src/uix/soma/morfo/runtime.svelte.test.ts b/src/uix/soma/morfo/runtime.svelte.test.ts index ef6595a80..0e21258d7 100644 --- a/src/uix/soma/morfo/runtime.svelte.test.ts +++ b/src/uix/soma/morfo/runtime.svelte.test.ts @@ -125,6 +125,42 @@ describe('createMorfoRuntime', () => { cleanup() }) + it('removes conditional attrs when their condition flips back to false', () => { + const ref = state(null) + const invalid = state(false) + const { cleanup } = withEffectRoot(() => { + const id = state('tog-1') + const r = createMorfoRuntime(toggleMorfo, { + dom, + states: { on: () => false }, + props: { + disabled: () => false, + readonly: () => false, + required: () => false, + invalid: () => invalid.current, + ariaLabel: () => undefined + } + }) + r.registerPart('provider', { id, ref }) + }) + + ref.current = target + flushSync() + expect(target.hasAttribute('data-invalid')).toBe(false) + expect(target.hasAttribute('aria-invalid')).toBe(false) + + invalid.current = true + flushSync() + expect(target.getAttribute('data-invalid')).toBe('') + expect(target.getAttribute('aria-invalid')).toBe('true') + + invalid.current = false + flushSync() + expect(target.hasAttribute('data-invalid')).toBe(false) + expect(target.hasAttribute('aria-invalid')).toBe(false) + cleanup() + }) + it('throws when registering an unknown part', () => { const { cleanup } = withEffectRoot(() => { const r = createMorfoRuntime(toggleMorfo, { dom })