feat(morfo): add `part-absent` condition — inverse of `part-present` (STUMBLES S3)

The common ARIA pattern "emit aria-label only when there is no Label part"
was not declarable. Add `{ when: 'part-absent', part }` as the logical
inverse of `part-present`, plumbed through the four condition sites:

- types.ts: MorfoCondition union member + JSDoc.
- schema.ts: discriminated-union member (structural accept) + extend the
  checkCondition unknown-part guard to cover part-absent (throws on unknown
  part, same as part-present — the prior guard only discriminated part-present).
- compile.ts: collectConditionDeps adds the referenced part to partRefs, so
  the runtime re-evaluates when the part's presence toggles.
- resolver.ts: shouldEmitMorfoEntry returns !bindings.parts?.[part] — emit
  when the part is absent/falsy, suppress when present.

Runtime + keyboard paths delegate to these helpers (evalAttrPlan /
shouldEmitMorfoEntry), so no runtime change is needed. Correct by symmetry
with part-present, which reads the same presence-accurate bindings.parts.

Tests: schema accepts a part-absent condition on a known part and throws on
an unknown part; the compiler collects the part dep; the resolver emits the
value when the part is absent and undefined when present.

Docs: morfo.md condition list gains the part-absent form.

Non-breaking: no existing morfo uses it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent d0f41fb21a
commit 1150404329

@ -484,6 +484,7 @@ Add `condition` when the ARIA is emitted only in some cases:
```ts
condition: 'always'
condition: { when: 'part-present', part: 'title' }
condition: { when: 'part-absent', part: 'label' } // e.g. aria-label only when no Label part
condition: { when: 'state-equals', state: 'open', value: 'true' }
condition: { when: 'prop-truthy', prop: 'modal' }
condition: { when: 'prop-falsy', prop: 'disabled' }

@ -437,3 +437,56 @@ describe('compileMorfo — invariants', () => {
})
})
describe('compileMorfo — part-absent condition (S3)', () => {
// Synthetic morfo: the Provider carries an aria-label that must only
// surface when there is no visible Label part (the common APG pattern).
const morfo = {
name: 'Notify',
kebab: 'notify',
scope: ['soma'],
parts: [
{
name: 'Provider',
kebab: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: [
{
attr: 'aria-label',
value: v.literal('Notifications'),
condition: { when: 'part-absent', part: 'label' }
}
]
},
{
name: 'Label',
kebab: 'label',
kind: 'public',
defaultElement: 'span',
optional: true,
data: [],
aria: []
}
]
} as never
it('collects the referenced part as a dependency (like part-present)', () => {
const compiled = compileMorfo(morfo)
const provider = compiled.parts.byKebab.get('provider')!
expect(provider.deps.parts).toContain('label')
})
it('emits the value when the part is absent and undefined when present', () => {
const compiled = compileMorfo(morfo)
const provider = compiled.parts.byKebab.get('provider')!
const ariaLabel = provider.dynamicAttrs.find((a) => a.attr === 'aria-label')
expect(ariaLabel).toBeDefined()
// Label part not rendered → aria-label surfaces.
expect(evalAttrPlan(ariaLabel!, { parts: {} })).toBe('Notifications')
// Label part rendered → aria-label suppressed (inverse of part-present).
expect(evalAttrPlan(ariaLabel!, { parts: { label: {} } })).toBeUndefined()
})
})

@ -677,7 +677,8 @@ function collectConditionDeps(
condition.when === 'prop-defined'
)
props.add(condition.prop)
else if (condition.when === 'part-present') partRefs.add(condition.part)
else if (condition.when === 'part-present' || condition.when === 'part-absent')
partRefs.add(condition.part)
}
// ── Plan evaluation ────────────────────────────────────────────────────────

@ -42,6 +42,7 @@ export function shouldEmitMorfoEntry(
): boolean {
if (!condition || condition === 'always') return true;
if (condition.when === 'part-present') return Boolean(bindings.parts?.[condition.part]);
if (condition.when === 'part-absent') return !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];

@ -84,3 +84,45 @@ describe('validateMorfo — data attrs', () => {
).toThrow(/emit is only valid for non-enum data attrs/);
});
});
describe('validateMorfo — part-absent condition', () => {
const labelPart = {
name: 'Label',
kebab: 'label',
kind: 'public',
defaultElement: 'span',
optional: true,
data: [],
aria: []
};
function withAriaCondition(part: string) {
return {
...baseMorfo,
parts: [
{
...baseMorfo.parts[0],
aria: [
{
attr: 'aria-label',
value: { kind: 'literal', value: 'Notifications' },
condition: { when: 'part-absent', part }
}
]
},
labelPart
]
};
}
it('accepts a part-absent condition referencing a declared part', () => {
expect(() => validateMorfo(withAriaCondition('label'))).not.toThrow();
});
it('throws when part-absent references an unknown part', () => {
expect(() => validateMorfo(withAriaCondition('nope'))).toThrow(MorfoInvariantError);
expect(() => validateMorfo(withAriaCondition('nope'))).toThrow(
/part-absent references unknown part/
);
});
});

@ -142,6 +142,7 @@ const valueSourceSchema = discriminated('kind', [
const conditionObjectSchema = discriminated('when', [
object({ when: literal('part-present'), part: string() }),
object({ when: literal('part-absent'), part: string() }),
object({
when: literal('state-equals'),
state: string(),
@ -555,9 +556,9 @@ function validateInvariants(morfo: Morfo): void {
const checkCondition = (c: MorfoCondition | undefined, context: string) => {
if (!c || c === 'always') return;
if (c.when === 'part-present' && !kebabs.has(c.part)) {
if ((c.when === 'part-present' || c.when === 'part-absent') && !kebabs.has(c.part)) {
throw new MorfoInvariantError(
`${context}: condition part-present references unknown part "${c.part}"`,
`${context}: condition ${c.when} references unknown part "${c.part}"`,
path
);
}

@ -160,6 +160,8 @@ export type MorfoDataValue = MorfoValueSource;
*
* `'always'` — unconditional
* `{ when: 'part-present', part }` — emitted iff part is rendered
* `{ when: 'part-absent', part }` — emitted iff part is NOT rendered
* (e.g. aria-label only when no Label part)
* `{ 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
@ -169,6 +171,7 @@ export type MorfoDataValue = MorfoValueSource;
export type MorfoCondition =
| 'always'
| { when: 'part-present'; part: string }
| { when: 'part-absent'; part: string }
| { when: 'state-equals'; state: string; value: string }
| { when: 'prop-truthy'; prop: string }
| { when: 'prop-falsy'; prop: string }

Loading…
Cancel
Save

Powered by TurnKey Linux.