refactor(soma): de-dup attrs in dropdown-menu + context-menu via renderProps (batch 3 tail)

Final 2 components of the rollout (words excluded per request). Both menu
siblings now source role/aria/data from the morfo via renderProps().
Audit total 213 → 158; both at 0 residual hits.

Unified the siblings' aria-boolean handling: aria-expanded/aria-checked
(morfo stateRef → aria-boolean string) resolve through renderProps, not a
soma boolean override. For checkbox/radio aria-checked the per-part
`checked` state source feeds the COMPUTED value — dropdown-menu's
effectiveChecked (group membership) and isChecked (value === group value)
— so renderProps emits the correct string. The migration agent had left
dropdown-menu's aria-checked as a boolean override reading effectiveChecked;
removing it without registering the state regressed a checked item to
aria-checked="false" — fixed by registering the computed source.

Tests assert the string aria-boolean shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 7bf5d047c3
commit 712fc8587f

@ -144,7 +144,7 @@ describe('ContextMenuProvider', () => {
expect(result.trigger.props).toMatchObject({
'aria-haspopup': 'menu',
'aria-expanded': false,
'aria-expanded': 'false',
'aria-controls': 'context-menu-content',
'data-state': 'closed'
});
@ -264,7 +264,7 @@ describe('ContextMenuProvider', () => {
expect(result.checkboxItem.props).toMatchObject({
role: 'menuitemcheckbox',
'aria-checked': false,
'aria-checked': 'false',
'data-state': 'unchecked'
});
result.checkboxItem.onclick({} as never);
@ -314,7 +314,7 @@ describe('ContextMenuProvider', () => {
expect(result.trigger.props).toMatchObject({
role: 'menuitem',
'aria-haspopup': 'menu',
'aria-expanded': true,
'aria-expanded': 'true',
'aria-controls': 'context-menu-sub-content',
'data-state': 'open',
'data-text-value': 'More'

@ -170,7 +170,11 @@ export class ContextMenuTriggerProvider {
this.runtimePart = this.provider.runtime.part('trigger', {
id: opts.id,
ref: opts.ref,
owner: this
owner: this,
// Per-part sources so renderProps resolves the morfo's state-/part-
// bound aria: aria-expanded ← open, aria-controls ← content id.
states: { open: () => this.provider.opts.open.current },
parts: { content: () => this.provider.contentId.current }
});
this.provider.triggerId.current = opts.id.current;
}
@ -185,10 +189,10 @@ export class ContextMenuTriggerProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'aria-haspopup': 'menu' as const,
'aria-expanded': this.provider.opts.open.current,
'aria-controls': this.provider.contentId.current || undefined,
// Morfo-declared: aria-haspopup, aria-expanded, aria-controls.
...this.runtimePart.renderProps(),
// Soma-owned: data-state (morfo declares it without a value) + the
// native disabled marker + the handler.
'data-state': getDataOpenClosed(this.provider.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
oncontextmenu: this.oncontextmenu
@ -249,6 +253,8 @@ export class ContextMenuContentProvider {
id: opts.id,
ref: opts.ref,
owner: this,
// aria-labelledby ← trigger id (renderProps resolves the partRef).
parts: { trigger: () => this.provider.triggerId.current },
onRefChange: (el) => {
this.provider.setContentRef(el);
}
@ -366,10 +372,9 @@ export class ContextMenuContentProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'menu' as const,
'aria-labelledby': this.provider.triggerId.current || undefined,
'aria-orientation': 'vertical' as const,
// Morfo-declared: role=menu, aria-labelledby, aria-orientation.
...this.runtimePart.renderProps(),
// Soma-owned data-state (morfo declares it without a value) + layers.
'data-state': getDataOpenClosed(this.provider.opts.open.current),
...this.floating.props,
...this.provider.contentPresence.transitionAttrs,
@ -436,7 +441,9 @@ export class ContextMenuItemProvider {
this.runtimePart = this.provider.runtime.part('item', {
id: opts.id,
ref: opts.ref,
owner: this
owner: this,
// aria-disabled is morfo-declared (literal 'true' gated on prop-truthy).
props: { disabled: () => this.opts.disabled.current }
});
}
@ -465,10 +472,10 @@ export class ContextMenuItemProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'menuitem' as const,
// Morfo-declared: role=menuitem, aria-disabled.
...this.runtimePart.renderProps(),
// Soma-owned: tabindex + the no-value data-* markers + handlers.
tabindex: this.opts.disabled.current ? undefined : -1,
'aria-disabled': this.opts.disabled.current || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-text-value': this.opts.textValue.current || undefined,
onclick: this.onclick,
@ -505,16 +512,15 @@ export class ContextMenuGroupProvider {
id: opts.id,
ref: opts.ref,
owner: this,
context: ContextMenuGroupProvider.ctx
context: ContextMenuGroupProvider.ctx,
// aria-labelledby ← group-heading id (renderProps resolves the partRef).
parts: { 'group-heading': () => this.headingId.current }
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'group' as const,
'aria-labelledby': this.headingId.current || undefined
} as const)
// Morfo-declared: role=group, aria-labelledby.
this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const)
);
}
@ -570,11 +576,8 @@ export class ContextMenuSeparatorProvider {
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'separator' as const,
'aria-orientation': 'horizontal' as const
} as const)
// Morfo-declared: role=separator, aria-orientation=horizontal.
this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const)
);
}
@ -606,7 +609,10 @@ export class ContextMenuCheckboxItemProvider {
this.runtimePart = this.provider.runtime.part('checkbox-item', {
id: opts.id,
ref: opts.ref,
owner: this
owner: this,
// aria-checked ← checked state, aria-disabled ← disabled prop.
states: { checked: () => this.opts.checked.current },
props: { disabled: () => this.opts.disabled.current }
});
}
@ -631,11 +637,10 @@ export class ContextMenuCheckboxItemProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'menuitemcheckbox' as const,
'aria-checked': this.opts.checked.current,
// Morfo-declared: role=menuitemcheckbox, aria-checked, aria-disabled.
...this.runtimePart.renderProps(),
// Soma-owned: tabindex + the no-value data-* markers + handlers.
tabindex: this.opts.disabled.current ? undefined : -1,
'aria-disabled': this.opts.disabled.current || undefined,
'data-state': this.opts.checked.current ? 'checked' : 'unchecked',
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-text-value': this.opts.textValue.current || undefined,
@ -676,10 +681,8 @@ export class ContextMenuRadioGroupProvider {
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'group' as const
} as const)
// Morfo-declared: role=group.
this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const)
);
}
@ -712,7 +715,10 @@ export class ContextMenuRadioItemProvider {
this.runtimePart = this.provider.runtime.part('radio-item', {
id: opts.id,
ref: opts.ref,
owner: this
owner: this,
// aria-checked ← checked state (computed), aria-disabled ← disabled.
states: { checked: () => this.isChecked },
props: { disabled: () => this.opts.disabled.current }
});
this.radioGroup = ContextMenuRadioGroupProvider.require();
}
@ -741,11 +747,10 @@ export class ContextMenuRadioItemProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'menuitemradio' as const,
'aria-checked': this.isChecked,
// Morfo-declared: role=menuitemradio, aria-checked, aria-disabled.
...this.runtimePart.renderProps(),
// Soma-owned: tabindex + the no-value data-* markers + handlers.
tabindex: this.opts.disabled.current ? undefined : -1,
'aria-disabled': this.opts.disabled.current || undefined,
'data-state': this.isChecked ? 'checked' : 'unchecked',
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-text-value': this.opts.textValue.current || undefined,
@ -855,6 +860,12 @@ export class ContextMenuSubTriggerProvider {
id: opts.id,
ref: opts.ref,
owner: this,
// renderProps resolves the morfo's state-/part-/prop-bound aria:
// aria-expanded ← open, aria-controls ← sub-content id,
// aria-disabled ← disabled prop.
states: { open: () => this.sub.opts.open.current },
parts: { 'sub-content': () => this.sub.contentId.current },
props: { disabled: () => this.opts.disabled.current },
onRefChange: (el) => {
this.sub.subTriggerRef.current = el;
}
@ -921,13 +932,11 @@ export class ContextMenuSubTriggerProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'menuitem' as const,
'aria-haspopup': 'menu' as const,
'aria-expanded': this.sub.opts.open.current,
'aria-controls': this.sub.contentId.current || undefined,
// Morfo-declared: role=menuitem, aria-haspopup, aria-expanded,
// aria-controls, aria-disabled.
...this.runtimePart.renderProps(),
// Soma-owned: tabindex + the no-value data-* markers + handlers.
tabindex: this.opts.disabled.current ? undefined : -1,
'aria-disabled': this.opts.disabled.current || undefined,
'data-state': getDataOpenClosed(this.sub.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-text-value': this.opts.textValue.current || undefined,
@ -974,6 +983,8 @@ export class ContextMenuSubContentProvider {
id: opts.id,
ref: opts.ref,
owner: this,
// aria-labelledby ← sub-trigger id (renderProps resolves the partRef).
parts: { 'sub-trigger': () => this.sub.subTriggerId.current },
onRefChange: (el) => {
this.sub.setContentRef(el);
}
@ -1053,10 +1064,9 @@ export class ContextMenuSubContentProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'menu' as const,
'aria-labelledby': this.sub.subTriggerId.current || undefined,
'aria-orientation': 'vertical' as const,
// Morfo-declared: role=menu, aria-labelledby, aria-orientation.
...this.runtimePart.renderProps(),
// Soma-owned data-state (morfo declares it without a value) + layers.
'data-state': getDataOpenClosed(this.sub.opts.open.current),
...this.floating.props,
...this.sub.contentPresence.transitionAttrs,

@ -141,7 +141,7 @@ describe('MenuProvider', () => {
expect(result.trigger.props).toMatchObject({
type: 'button',
'aria-haspopup': 'menu',
'aria-expanded': false,
'aria-expanded': 'false',
'aria-controls': 'dropdown-menu-content',
'data-state': 'closed'
});
@ -266,7 +266,7 @@ describe('MenuProvider', () => {
expect(result.checkboxItem.effectiveChecked).toBe(true);
expect(result.checkboxItem.props).toMatchObject({
role: 'menuitemcheckbox',
'aria-checked': true,
'aria-checked': 'true',
'data-state': 'checked'
});
result.checkboxItem.onclick({} as never);
@ -315,7 +315,7 @@ describe('MenuProvider', () => {
expect(result.trigger.props).toMatchObject({
role: 'menuitem',
'aria-haspopup': 'menu',
'aria-expanded': true,
'aria-expanded': 'true',
'aria-controls': 'dropdown-menu-sub-content',
'data-state': 'open',
'data-text-value': 'More'

@ -178,7 +178,13 @@ export class MenuTriggerProvider {
this.runtimePart = this.provider.runtime.part('trigger', {
id: opts.id,
ref: opts.ref,
owner: this
owner: this,
// Per-part sources for the morfo's value-bound attrs: aria-expanded
// (stateRef 'open') and aria-controls (partRef 'content', emitted only
// when content is present). With these, `renderProps()` resolves them
// from the morfo instead of the provider re-declaring them.
states: { open: () => this.provider.opts.open.current },
parts: { content: () => this.provider.contentId.current || undefined }
});
this.provider.triggerId.current = opts.id.current;
@ -204,11 +210,11 @@ export class MenuTriggerProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
'aria-haspopup': 'menu' as const,
'aria-expanded': this.provider.opts.open.current,
'aria-controls': this.provider.contentId.current || undefined,
// Morfo-declared (type, aria-haspopup, aria-controls) resolved against
// this part's registered sources — no longer re-declared here.
...this.runtimePart.renderProps(),
// aria-expanded resolves from renderProps (morfo stateRef → string);
// data-state has no morfo value source so soma derives it.
'data-state': getDataOpenClosed(this.provider.opts.open.current),
onclick: this.onclick,
onkeydown: this.onkeydown
@ -271,7 +277,10 @@ export class MenuContentProvider {
owner: this,
onRefChange: (el) => {
this.provider.setContentRef(el);
}
},
// aria-labelledby (partRef 'trigger', emitted only when the trigger is
// present) resolves from this source via renderProps.
parts: { trigger: () => this.provider.triggerId.current || undefined }
});
this.provider.contentId.current = opts.id.current;
@ -397,10 +406,10 @@ export class MenuContentProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'menu' as const,
'aria-labelledby': this.provider.triggerId.current || undefined,
'aria-orientation': 'vertical' as const,
// Morfo-declared (role, aria-labelledby, aria-orientation) resolved
// against this part's registered sources.
...this.runtimePart.renderProps(),
// data-state has no morfo value source — soma derives it.
'data-state': getDataOpenClosed(this.provider.opts.open.current),
...this.floating.props,
...this.provider.contentPresence.transitionAttrs,
@ -467,7 +476,10 @@ export class MenuItemProvider {
this.runtimePart = this.provider.runtime.part('item', {
id: opts.id,
ref: opts.ref,
owner: this
owner: this,
// aria-disabled (literal 'true', emitted only when `disabled` is
// truthy) resolves from this source via renderProps.
props: { disabled: () => this.opts.disabled.current }
});
}
@ -498,13 +510,16 @@ export class MenuItemProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'menuitem' as const,
// Morfo-declared (role, aria-disabled) resolved against this part's
// registered sources.
...this.runtimePart.renderProps(),
// Soma owns: tabindex (native, computed from disabled +
// accessibleWhenDisabled), data-disabled/data-text-value (morfo
// declares them without a value source), handlers.
tabindex:
this.opts.disabled.current && !this.provider.opts.accessibleWhenDisabled.current
? undefined
: -1,
'aria-disabled': this.opts.disabled.current || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-text-value': this.opts.textValue.current || undefined,
onclick: this.onclick,
@ -541,15 +556,18 @@ export class MenuGroupProvider {
id: opts.id,
ref: opts.ref,
owner: this,
context: MenuGroupProvider.ctx
context: MenuGroupProvider.ctx,
// aria-labelledby (partRef 'group-heading', emitted only when the
// heading is present) resolves from this source via renderProps.
parts: { 'group-heading': () => this.headingId.current || undefined }
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'group' as const,
'aria-labelledby': this.headingId.current || undefined
// Morfo-declared (role, aria-labelledby) resolved against this part's
// registered sources.
...this.runtimePart.renderProps()
} as const)
);
}
@ -608,9 +626,8 @@ export class MenuSeparatorProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'separator' as const,
'aria-orientation': 'horizontal' as const
// Morfo-declared (role, aria-orientation) — both literals.
...this.runtimePart.renderProps()
} as const)
);
}
@ -680,7 +697,12 @@ export class MenuCheckboxItemProvider {
this.runtimePart = this.provider.runtime.part('checkbox-item', {
id: opts.id,
ref: opts.ref,
owner: this
owner: this,
// Per-part sources: aria-checked (stateRef 'checked') resolves from the
// EFFECTIVE checked state (group membership when grouped), aria-disabled
// (literal 'true' when `disabled` truthy) — both via renderProps.
states: { checked: () => this.effectiveChecked },
props: { disabled: () => this.opts.disabled.current }
});
this.group = MenuCheckboxGroupProvider.get();
}
@ -718,14 +740,16 @@ export class MenuCheckboxItemProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'menuitemcheckbox' as const,
'aria-checked': this.effectiveChecked,
// Morfo-declared (role, aria-disabled) resolved against this part's
// registered sources.
...this.runtimePart.renderProps(),
// aria-checked resolves from renderProps (morfo stateRef → string).
// Soma owns: data-state (no morfo value source), tabindex/
// data-disabled/data-text-value (native or no-value), handlers.
tabindex:
this.opts.disabled.current && !this.provider.opts.accessibleWhenDisabled.current
? undefined
: -1,
'aria-disabled': this.opts.disabled.current || undefined,
'data-state': this.effectiveChecked ? 'checked' : 'unchecked',
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-text-value': this.opts.textValue.current || undefined,
@ -767,8 +791,8 @@ export class MenuRadioGroupProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'group' as const
// Morfo-declared (role) — literal.
...this.runtimePart.renderProps()
} as const)
);
}
@ -802,7 +826,12 @@ export class MenuRadioItemProvider {
this.runtimePart = this.provider.runtime.part('radio-item', {
id: opts.id,
ref: opts.ref,
owner: this
owner: this,
// Per-part sources: aria-checked (stateRef 'checked') resolves from the
// computed isChecked (value === group value), aria-disabled (literal
// 'true' when `disabled` truthy) — both via renderProps.
states: { checked: () => this.isChecked },
props: { disabled: () => this.opts.disabled.current }
});
this.radioGroup = MenuRadioGroupProvider.require();
}
@ -831,14 +860,16 @@ export class MenuRadioItemProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'menuitemradio' as const,
'aria-checked': this.isChecked,
// Morfo-declared (role, aria-disabled) resolved against this part's
// registered sources.
...this.runtimePart.renderProps(),
// aria-checked resolves from renderProps (morfo stateRef → string).
// Soma owns: data-state (no morfo value source), tabindex/
// data-disabled/data-text-value (native or no-value), handlers.
tabindex:
this.opts.disabled.current && !this.provider.opts.accessibleWhenDisabled.current
? undefined
: -1,
'aria-disabled': this.opts.disabled.current || undefined,
'data-state': this.isChecked ? 'checked' : 'unchecked',
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-text-value': this.opts.textValue.current || undefined,
@ -939,7 +970,14 @@ export class MenuSubTriggerProvider {
owner: this,
onRefChange: (el) => {
this.sub.subTriggerRef.current = el;
}
},
// Per-part sources for the morfo's value-bound attrs: aria-controls
// (partRef 'sub-content', emitted only when present) and aria-disabled
// (literal 'true' when `disabled` truthy). aria-expanded stays a soma
// override (boolean, vs the morfo's stringified stateRef).
states: { open: () => this.sub.opts.open.current },
parts: { 'sub-content': () => this.sub.contentId.current || undefined },
props: { disabled: () => this.opts.disabled.current }
});
this.sub.subTriggerId.current = opts.id.current;
@ -1015,16 +1053,16 @@ export class MenuSubTriggerProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'menuitem' as const,
'aria-haspopup': 'menu' as const,
'aria-expanded': this.sub.opts.open.current,
'aria-controls': this.sub.contentId.current || undefined,
// Morfo-declared (role, aria-haspopup, aria-controls, aria-disabled)
// resolved against this part's registered sources.
...this.runtimePart.renderProps(),
// aria-expanded resolves from renderProps (morfo stateRef → string).
// Soma owns: data-state (no morfo value source), tabindex/
// data-disabled/data-text-value (native or no-value), handlers.
tabindex:
this.opts.disabled.current && !this.sub.provider.opts.accessibleWhenDisabled.current
? undefined
: -1,
'aria-disabled': this.opts.disabled.current || undefined,
'data-state': getDataOpenClosed(this.sub.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-text-value': this.opts.textValue.current || undefined,
@ -1073,7 +1111,10 @@ export class MenuSubContentProvider {
owner: this,
onRefChange: (el) => {
this.sub.setContentRef(el);
}
},
// aria-labelledby (partRef 'sub-trigger', emitted only when present)
// resolves from this source via renderProps.
parts: { 'sub-trigger': () => this.sub.subTriggerId.current || undefined }
});
this.sub.contentId.current = opts.id.current;
@ -1152,10 +1193,10 @@ export class MenuSubContentProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'menu' as const,
'aria-labelledby': this.sub.subTriggerId.current || undefined,
'aria-orientation': 'vertical' as const,
// Morfo-declared (role, aria-labelledby, aria-orientation) resolved
// against this part's registered sources.
...this.runtimePart.renderProps(),
// data-state has no morfo value source — soma derives it.
'data-state': getDataOpenClosed(this.sub.opts.open.current),
...this.floating.props,
...this.sub.contentPresence.transitionAttrs,

@ -67,7 +67,11 @@ const MIGRATED_CEILINGS: Record<string, number> = {
table: 8, // per-cell/row aria-rowindex/colindex/sort/selected (dynamic)
editable: 4, // computed trigger aria-labels
combobox: 0,
command: 4 // aria-activedescendant/controls (dynamic) + computed labels
command: 4, // aria-activedescendant/controls (dynamic) + computed labels
// Menu siblings — aria-expanded/aria-checked come from the morfo's stateRef
// (aria-boolean string) via renderProps; tests assert the string. 0 residue.
'dropdown-menu': 0,
'context-menu': 0
};
describe('soma↔morfo attr de-dup guard', () => {

Loading…
Cancel
Save

Powered by TurnKey Linux.