refactor(soma): de-dup attrs in 12 providers via renderProps (batch 3)

Source role/aria/data from the morfo via renderProps() across the
collection/menu families, removing the attrs the providers re-declared
("morfo declares, soma executes"). Audit total 369 → 213.

Migrated: select, listbox, carousel, tree-view, year-grid, grid-list,
tree-grid, table, file-upload, editable, combobox, command.

tree-view/table keep their per-node aria hardcoded on purpose: each tree
node / table cell computes its own aria-level/selected/rowindex/colindex/
sort, which a single renderProps can't resolve. The residual hits the
guard pins are all legit value overrides — computed aria-labels (carousel
"slide N", editable/file-upload triggers), per-instance dynamic values,
and computed refs (command aria-activedescendant/controls).

listbox/grid-list tests: aria booleans the morfo declares via stateRef
now resolve through renderProps as "true"/"false" strings (aria-boolean
mode) instead of the providers' hardcoded booleans — assert strings.

words excluded from this rollout per request (active dev track).

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

@ -111,12 +111,14 @@ export const commandMorfo = {
{ {
attr: 'aria-selected', attr: 'aria-selected',
value: v.propRef('selected'), value: v.propRef('selected'),
severity: 'optional' severity: 'optional',
ariaBoolean: true
}, },
{ {
attr: 'aria-disabled', attr: 'aria-disabled',
value: v.propRef('disabled'), value: v.propRef('disabled'),
severity: 'optional' severity: 'optional',
ariaBoolean: true
} }
] ]
}, },
@ -137,12 +139,14 @@ export const commandMorfo = {
{ {
attr: 'aria-selected', attr: 'aria-selected',
value: v.propRef('selected'), value: v.propRef('selected'),
severity: 'optional' severity: 'optional',
ariaBoolean: true
}, },
{ {
attr: 'aria-disabled', attr: 'aria-disabled',
value: v.propRef('disabled'), value: v.propRef('disabled'),
severity: 'optional' severity: 'optional',
ariaBoolean: true
} }
] ]
}, },

@ -120,7 +120,14 @@ export const fileUploadMorfo = {
value: v.translationRef('#?components.file-upload.dropzone|Upload files'), value: v.translationRef('#?components.file-upload.dropzone|Upload files'),
severity: 'recommended' severity: 'recommended'
}, },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' } // Provider emitted `boolToStr(disabled)` (always "true"/"false"), so
// `propRef` + `ariaBoolean` lets renderProps() emit the same string.
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
], ],
keyboard: [ keyboard: [
{ key: 'Enter', action: 'open-file-picker' }, { key: 'Enter', action: 'open-file-picker' },
@ -143,7 +150,14 @@ export const fileUploadMorfo = {
value: v.translationRef('#?components.file-upload.trigger|Select files'), value: v.translationRef('#?components.file-upload.trigger|Select files'),
severity: 'recommended' severity: 'recommended'
}, },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' } // Provider emitted `boolToStr(disabled)` (always "true"/"false"), so
// `propRef` + `ariaBoolean` lets renderProps() emit the same string.
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
] ]
}, },
{ {

@ -51,7 +51,14 @@ export const yearGridMorfo = {
], ],
aria: [ aria: [
{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' }, { attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' } // Provider emitted `boolToStr(disabled)` (always "true"/"false"), so
// `propRef` + `ariaBoolean` lets renderProps() emit the same string.
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
], ],
keyboard: [ keyboard: [
{ key: 'ArrowRight', action: 'next-year' }, { key: 'ArrowRight', action: 'next-year' },
@ -102,7 +109,14 @@ export const yearGridMorfo = {
value: v.commonRef('year-grid.prev-page', 'Previous years'), value: v.commonRef('year-grid.prev-page', 'Previous years'),
severity: 'recommended' severity: 'recommended'
}, },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' } // Provider emitted `boolToStr(disabled)` (always "true"/"false"), so
// `propRef` + `ariaBoolean` lets renderProps() emit the same string.
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
] ]
}, },
{ {
@ -121,7 +135,14 @@ export const yearGridMorfo = {
value: v.commonRef('year-grid.next-page', 'Next years'), value: v.commonRef('year-grid.next-page', 'Next years'),
severity: 'recommended' severity: 'recommended'
}, },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' } // Provider emitted `boolToStr(disabled)` (always "true"/"false"), so
// `propRef` + `ariaBoolean` lets renderProps() emit the same string.
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
] ]
}, },
{ {
@ -139,8 +160,20 @@ export const yearGridMorfo = {
condition: { when: 'part-present', part: 'heading' }, condition: { when: 'part-present', part: 'heading' },
severity: 'recommended' severity: 'recommended'
}, },
{ attr: 'aria-readonly', value: v.propRef('readonly'), severity: 'optional' }, // Provider emitted `boolToStr(...)` (always "true"/"false") for both;
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' } // `propRef` + `ariaBoolean` lets renderProps() emit the same string.
{
attr: 'aria-readonly',
value: v.propRef('readonly'),
severity: 'optional',
ariaBoolean: true
},
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
] ]
}, },
{ {
@ -159,8 +192,20 @@ export const yearGridMorfo = {
{ attr: 'data-focused', severity: 'optional' } { attr: 'data-focused', severity: 'optional' }
], ],
aria: [ aria: [
{ attr: 'aria-selected', value: v.propRef('selected'), severity: 'optional' }, // Provider emitted `boolToStr(...)` (always "true"/"false") for both;
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' } // `propRef` + `ariaBoolean` lets renderProps() emit the same string.
{
attr: 'aria-selected',
value: v.propRef('selected'),
severity: 'optional',
ariaBoolean: true
},
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
] ]
} }
] ]

@ -439,13 +439,17 @@ export class CarouselProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // role + aria-roledescription are morfo-declared, resolved by renderProps.
role: 'region' as const, ...this.runtimePart.renderProps(),
'aria-roledescription': 'carousel', // aria-label: soma override — honours the `ariaLabel` prop then the LABEL
// translation. The morfo's translationRef is the declarative default; this
// computed value wins.
'aria-label': this.resolvedAriaLabel.current, 'aria-label': this.resolvedAriaLabel.current,
tabindex: this.isDisabled ? -1 : 0, tabindex: this.isDisabled ? -1 : 0,
dir: this.opts.dir.current, dir: this.opts.dir.current,
lang: this.opts.locale.current || undefined, lang: this.opts.locale.current || undefined,
// data-* below are contract-only in the morfo (declared with `values`,
// no resolvable `value`), so renderProps doesn't emit them — soma owns them.
'data-orientation': this.opts.orientation.current, 'data-orientation': this.opts.orientation.current,
'data-dir': this.opts.dir.current, 'data-dir': this.opts.dir.current,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
@ -600,10 +604,12 @@ export class CarouselItemProvider {
// gap is the default `0px`. // gap is the default `0px`.
const basis = spv === 1 ? '100%' : `calc((100% - ${spv - 1} * ${gap}) / ${spv})`; const basis = spv === 1 ? '100%' : `calc((100% - ${spv - 1} * ${gap}) / ${spv})`;
return this.runtimePart.assert({ return this.runtimePart.assert({
...this.runtimePart.props, // role + aria-roledescription are morfo-declared, resolved by renderProps.
role: 'group' as const, ...this.runtimePart.renderProps(),
'aria-roledescription': 'slide', // aria-label: soma override — the morfo declares propRef('index') (the raw
// slide number); soma supplies the localizable "Slide N of M" phrasing.
'aria-label': `Slide ${this.opts.index.current + 1} of ${this.provider.slideCount}`, 'aria-label': `Slide ${this.opts.index.current + 1} of ${this.provider.slideCount}`,
// data-* are contract-only in the morfo (no resolvable `value`) — soma owns them.
'data-state': this.isActive ? 'active' : 'inactive', 'data-state': this.isActive ? 'active' : 'inactive',
'data-index': this.opts.index.current, 'data-index': this.opts.index.current,
'data-orientation': this.provider.opts.orientation.current, 'data-orientation': this.provider.opts.orientation.current,
@ -649,8 +655,10 @@ export class CarouselPrevTriggerProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // type + role + aria-label (default) are morfo-declared, resolved by renderProps.
type: 'button' as const, ...this.runtimePart.renderProps(),
// aria-label: soma override — honours the `ariaLabel` prop then the PREV
// translation. Overrides the morfo's translationRef default.
'aria-label': this.opts.ariaLabel.current || this.provider.soma.langs.ts(CAROUSEL_LANGS.PREV), 'aria-label': this.opts.ariaLabel.current || this.provider.soma.langs.ts(CAROUSEL_LANGS.PREV),
disabled: this.isDisabled || undefined, disabled: this.isDisabled || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
@ -692,8 +700,10 @@ export class CarouselNextTriggerProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // type + role + aria-label (default) are morfo-declared, resolved by renderProps.
type: 'button' as const, ...this.runtimePart.renderProps(),
// aria-label: soma override — honours the `ariaLabel` prop then the NEXT
// translation. Overrides the morfo's translationRef default.
'aria-label': this.opts.ariaLabel.current || this.provider.soma.langs.ts(CAROUSEL_LANGS.NEXT), 'aria-label': this.opts.ariaLabel.current || this.provider.soma.langs.ts(CAROUSEL_LANGS.NEXT),
disabled: this.isDisabled || undefined, disabled: this.isDisabled || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
@ -721,15 +731,18 @@ export class CarouselIndicatorGroupProvider {
this.runtimePart = this.provider.runtime.part('indicator-group', { this.runtimePart = this.provider.runtime.part('indicator-group', {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this owner: this,
// `orientation` source so renderProps resolves the morfo's
// aria-orientation (propRef('orientation')).
props: { orientation: () => this.provider.opts.orientation.current }
}); });
} }
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // role + aria-orientation are morfo-declared, resolved by renderProps.
role: 'tablist' as const, ...this.runtimePart.renderProps(),
'aria-orientation': this.provider.opts.orientation.current, // data-orientation is contract-only in the morfo (no resolvable `value`) — soma owns it.
'data-orientation': this.provider.opts.orientation.current 'data-orientation': this.provider.opts.orientation.current
} as const) } as const)
); );
@ -775,12 +788,17 @@ export class CarouselIndicatorProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // type + role are morfo-declared, resolved by renderProps.
type: 'button' as const, ...this.runtimePart.renderProps(),
role: 'tab' as const, // aria-selected: soma override — the morfo declares stateRef('active') which
// stringifies to 'true'/'false'; soma supplies the real boolean (the active
// flag is computed here, not registered as a runtime state source).
'aria-selected': this.isActive, 'aria-selected': this.isActive,
// aria-label: soma override — morfo declares propRef('ariaLabel'); soma adds
// the localizable "Go to slide N" fallback when no explicit label is given.
'aria-label': this.opts.ariaLabel.current || `Go to slide ${this.opts.index.current + 1}`, 'aria-label': this.opts.ariaLabel.current || `Go to slide ${this.opts.index.current + 1}`,
tabindex: this.isActive ? 0 : -1, tabindex: this.isActive ? 0 : -1,
// data-* are contract-only in the morfo (no resolvable `value`) — soma owns them.
'data-state': this.isActive ? 'active' : 'inactive', 'data-state': this.isActive ? 'active' : 'inactive',
'data-index': this.opts.index.current, 'data-index': this.opts.index.current,
onclick: this.onclick onclick: this.onclick

@ -14,7 +14,6 @@ import type {
} from '../../types'; } from '../../types';
import { KEYS } from '../../keyboard'; import { KEYS } from '../../keyboard';
import { Soma } from '../../core/soma.svelte'; import { Soma } from '../../core/soma.svelte';
import { COMBOBOX_LANGS } from './langs';
import { Presence } from '../../layers/presence.svelte'; import { Presence } from '../../layers/presence.svelte';
import { Dismissal, type DismissalBehavior } from '../../layers/dismissal.svelte'; import { Dismissal, type DismissalBehavior } from '../../layers/dismissal.svelte';
@ -338,7 +337,12 @@ export class ComboboxInputProvider {
this.runtimePart = this.provider.runtime.part('input', { this.runtimePart = this.provider.runtime.part('input', {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this owner: this,
// Per-part source for the morfo's `aria-activedescendant` (propRef
// 'highlightedId'). The component-level sources don't carry it, so
// register it here → `renderProps()` resolves it from the morfo
// instead of the provider re-declaring it.
props: { highlightedId: () => this.provider.highlightedId || undefined }
}); });
this.provider.inputId.current = opts.id.current; this.provider.inputId.current = opts.id.current;
this.provider.inputRef = opts.ref; this.provider.inputRef = opts.ref;
@ -426,16 +430,12 @@ export class ComboboxInputProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: role, aria-haspopup/expanded/controls/activedescendant/
// required/autocomplete, data-state — resolved against this part's
// registered sources (incl. the per-part `highlightedId`).
...this.runtimePart.renderProps(),
// Soma-only: what the morfo can't express.
type: 'text' as const, type: 'text' as const,
role: 'combobox' as const,
'aria-haspopup': 'listbox' as const,
'aria-expanded': this.provider.opts.open.current,
'aria-controls': this.provider.contentId.current || undefined,
'aria-activedescendant': this.provider.highlightedId || undefined,
'aria-required': this.provider.opts.required.current || undefined,
'aria-autocomplete': 'list' as const,
'data-state': getDataOpenClosed(this.provider.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current), 'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
disabled: this.provider.opts.disabled.current || undefined, disabled: this.provider.opts.disabled.current || undefined,
value: this.provider.opts.inputValue.current, value: this.provider.opts.inputValue.current,
@ -476,13 +476,11 @@ export class ComboboxTriggerProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: type, aria-label/haspopup/expanded/controls.
type: 'button' as const, ...this.runtimePart.renderProps(),
// Soma-only: native tabindex, the data-* the morfo declares WITHOUT a
// value (soma owns the value), the native disabled attr, the handler.
tabindex: -1, tabindex: -1,
'aria-label': this.provider.soma.langs.ts(COMBOBOX_LANGS.TOGGLE),
'aria-haspopup': 'listbox' as const,
'aria-expanded': this.provider.opts.open.current,
'aria-controls': this.provider.contentId.current || undefined,
'data-state': getDataOpenClosed(this.provider.opts.open.current), 'data-state': getDataOpenClosed(this.provider.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current), 'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
disabled: this.provider.opts.disabled.current || undefined, disabled: this.provider.opts.disabled.current || undefined,
@ -658,10 +656,10 @@ export class ComboboxContentProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: role, aria-labelledby, aria-multiselectable.
role: 'listbox' as const, ...this.runtimePart.renderProps(),
'aria-labelledby': this.provider.inputId.current || undefined, // Soma-only: data-state (morfo declares it WITHOUT a value), the
'aria-multiselectable': this.provider.opts.type.current === 'multiple' || undefined, // floating/presence/dismissal layer attrs, native tabindex + handler.
'data-state': getDataOpenClosed(this.provider.opts.open.current), 'data-state': getDataOpenClosed(this.provider.opts.open.current),
...this.floating.props, ...this.floating.props,
...this.provider.contentPresence.transitionAttrs, ...this.provider.contentPresence.transitionAttrs,
@ -727,7 +725,12 @@ export class ComboboxItemProvider {
this.runtimePart = this.provider.runtime.part('item', { this.runtimePart = this.provider.runtime.part('item', {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this owner: this,
// Per-part state source for the morfo's `aria-selected` (stateRef
// 'checked'). Each Item carries its own selection state, so it's
// registered per-instance here → `renderProps()` resolves
// aria-selected from the morfo instead of the provider re-declaring it.
states: { checked: () => this.isSelected }
}); });
// Maintain value → label registry without a ref-reading mount effect. // Maintain value → label registry without a ref-reading mount effect.
@ -760,9 +763,10 @@ export class ComboboxItemProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: role, aria-selected (from the per-part `checked` state).
role: 'option' as const, ...this.runtimePart.renderProps(),
'aria-selected': this.isSelected, // Soma-only: native tabindex + the data-* the morfo declares WITHOUT a
// value (soma owns value/label/highlighted/disabled/state) + handlers.
tabindex: this.opts.disabled.current ? undefined : -1, tabindex: this.opts.disabled.current ? undefined : -1,
'data-state': this.isSelected ? 'checked' : 'unchecked', 'data-state': this.isSelected ? 'checked' : 'unchecked',
'data-highlighted': this.provider.highlightedId === this.opts.id.current ? '' : undefined, 'data-highlighted': this.provider.highlightedId === this.opts.id.current ? '' : undefined,
@ -804,15 +808,19 @@ export class ComboboxGroupProvider {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this, owner: this,
context: ComboboxGroupProvider.ctx context: ComboboxGroupProvider.ctx,
// Per-part source for the morfo's `aria-labelledby` (partRef
// 'group-heading'). The heading id is per-group-instance (set by the
// nested GroupHeading), so it's registered here → `renderProps()`
// resolves aria-labelledby + its part-present condition from the morfo.
parts: { 'group-heading': () => this.headingId.current || undefined }
}); });
} }
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: role, aria-labelledby (from the per-part heading id).
role: 'group' as const, ...this.runtimePart.renderProps()
'aria-labelledby': this.headingId.current || undefined
} as const) } as const)
); );
} }
@ -873,8 +881,8 @@ export class ComboboxSeparatorProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: role. No soma-specific extras.
role: 'separator' as const ...this.runtimePart.renderProps()
} as const) } as const)
); );
} }
@ -934,7 +942,11 @@ export class ComboboxSelectedTagsProvider {
/** Live snapshot of `{ value, label }` for every selected entry. Drives the chip loop. */ /** Live snapshot of `{ value, label }` for every selected entry. Drives the chip loop. */
readonly items = $derived.by(() => this.provider.getSelectedItems()); readonly items = $derived.by(() => this.provider.getSelectedItems());
readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props } as const)); readonly props = $derived.by(() =>
// Morfo-declared: role, aria-label (translationRef 'selected-tags'),
// data-empty (propRef 'selectedTagsEmpty'). No soma-specific extras.
this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const)
);
} }
// ── SelectedTag ───────────────────────────────────────────────────────────── // ── SelectedTag ─────────────────────────────────────────────────────────────
@ -1024,8 +1036,11 @@ export class ComboboxSelectedTagRemoveProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: type, aria-label (translationRef 'remove' — the morfo
type: 'button' as const, // declares it; previously the provider never applied it).
...this.runtimePart.renderProps(),
// Soma-only: data-disabled (morfo declares it WITHOUT a value), the
// native disabled attr, the handler.
'data-disabled': boolToEmptyStrOrUndef(this.disabled), 'data-disabled': boolToEmptyStrOrUndef(this.disabled),
disabled: this.disabled || undefined, disabled: this.disabled || undefined,
onclick: this.onclick onclick: this.onclick
@ -1062,14 +1077,12 @@ export class ComboboxClearButtonProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: type, aria-label (translationRef 'clear'), data-disabled
type: 'button' as const, // (propRef 'clearDisabled' = disabled || isEmpty).
'aria-label': this.provider.soma.langs.ts(COMBOBOX_LANGS.CLEAR), ...this.runtimePart.renderProps(),
// Soma-only: native tabindex + the native disabled attr + the handler.
tabindex: -1, tabindex: -1,
disabled: this.provider.opts.disabled.current || this.provider.isEmpty || undefined, disabled: this.provider.opts.disabled.current || this.provider.isEmpty || undefined,
'data-disabled': boolToEmptyStrOrUndef(
this.provider.opts.disabled.current || this.provider.isEmpty
),
onclick: this.onclick onclick: this.onclick
} as const) } as const)
); );

@ -1,5 +1,5 @@
import { context, type WithRefOpts } from '../../provider'; import { context, type WithRefOpts } from '../../provider';
import { boolToEmptyStrOrUndef, boolToStr } from '$adom'; import { boolToEmptyStrOrUndef } from '$adom';
import { import {
readableActive, readableActive,
state, state,
@ -504,14 +504,15 @@ export class CommandInputProvider {
const selected = this.provider.opts.value.current; const selected = this.provider.opts.value.current;
const listId = this.provider.listId.current; const listId = this.provider.listId.current;
return this.runtimePart.assert({ return this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: role, aria-autocomplete, aria-expanded.
...this.runtimePart.renderProps(),
type: 'text' as const, type: 'text' as const,
role: 'combobox' as const,
autocomplete: 'off' as const, autocomplete: 'off' as const,
autocorrect: 'off' as const, autocorrect: 'off' as const,
spellcheck: false, spellcheck: false,
'aria-autocomplete': 'list' as const, // Soma-computed ids — the morfo's partRef('list')/propRef('selectedId')
'aria-expanded': true as const, // resolve to nothing (no `parts` source registered), and the value is a
// composed id string the morfo can't express.
'aria-controls': listId || undefined, 'aria-controls': listId || undefined,
'aria-activedescendant': selected ? `${listId}-item-${cssSafe(selected)}` : undefined, 'aria-activedescendant': selected ? `${listId}-item-${cssSafe(selected)}` : undefined,
value: this.provider.opts.search.current, value: this.provider.opts.search.current,
@ -552,8 +553,10 @@ export class CommandListProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: role. The morfo also declares aria-label via
role: 'listbox' as const, // translationRef (the default); soma overrides to honour an explicit
// `label` prop, falling back to the same translated string.
...this.runtimePart.renderProps(),
'aria-label': 'aria-label':
this.provider.opts.label.current ?? this.provider.soma.langs.ts(COMMAND_LANGS.LIST) this.provider.opts.label.current ?? this.provider.soma.langs.ts(COMMAND_LANGS.LIST)
} as const) } as const)
@ -631,7 +634,14 @@ export class CommandItemProvider {
this.runtimePart = this.provider.runtime.part('item', { this.runtimePart = this.provider.runtime.part('item', {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this owner: this,
// Per-part sources for the morfo's aria-selected/aria-disabled — the
// component-level runtime carries no sources. With these, renderProps()
// resolves those aria attrs from the morfo instead of re-declaring them.
props: {
selected: () => this.isSelected,
disabled: () => this.opts.disabled.current
}
}); });
dbg('Item.ctor', opts.value.current); dbg('Item.ctor', opts.value.current);
@ -717,11 +727,12 @@ export class CommandItemProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: role, aria-selected, aria-disabled (resolved from this
// part's registered selected/disabled sources).
...this.runtimePart.renderProps(),
// Soma-owned: composed aria id (overrides the registration id) + the
// data-* attrs the morfo declares WITHOUT a value (it owns the value).
id: this.idForAria, id: this.idForAria,
role: 'option' as const,
'aria-selected': this.isSelected,
'aria-disabled': boolToStr(this.opts.disabled.current),
'data-selected': boolToEmptyStrOrUndef(this.isSelected), 'data-selected': boolToEmptyStrOrUndef(this.isSelected),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-value': this.opts.value.current, 'data-value': this.opts.value.current,
@ -762,7 +773,13 @@ export class CommandLinkItemProvider {
this.runtimePart = this.provider.runtime.part('link-item', { this.runtimePart = this.provider.runtime.part('link-item', {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this owner: this,
// Per-part sources for the morfo's aria-selected/aria-disabled (see
// CommandItemProvider for the rationale).
props: {
selected: () => this.isSelected,
disabled: () => this.opts.disabled.current
}
}); });
const groupValue = this.group?.opts.value.current; const groupValue = this.group?.opts.value.current;
@ -831,12 +848,13 @@ export class CommandLinkItemProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: role, aria-selected, aria-disabled (resolved from this
// part's registered selected/disabled sources).
...this.runtimePart.renderProps(),
// Soma-owned: composed aria id, the native href the morfo doesn't model,
// and the data-* attrs the morfo declares WITHOUT a value.
id: this.idForAria, id: this.idForAria,
role: 'option' as const,
href: this.opts.href.current, href: this.opts.href.current,
'aria-selected': this.isSelected,
'aria-disabled': boolToStr(this.opts.disabled.current),
'data-selected': boolToEmptyStrOrUndef(this.isSelected), 'data-selected': boolToEmptyStrOrUndef(this.isSelected),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-value': this.opts.value.current, 'data-value': this.opts.value.current,
@ -898,8 +916,11 @@ export class CommandGroupProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: role. The morfo declares aria-labelledby via
role: 'group' as const, // partRef('group-heading'), but that can't resolve without a registered
// parts source — soma supplies the registered heading id. data-empty is
// declared without a value (soma owns it).
...this.runtimePart.renderProps(),
'aria-labelledby': this.headingId.current || undefined, 'aria-labelledby': this.headingId.current || undefined,
'data-empty': boolToEmptyStrOrUndef(!this.hasVisibleItems) 'data-empty': boolToEmptyStrOrUndef(!this.hasVisibleItems)
} as const) } as const)
@ -931,7 +952,8 @@ export class CommandGroupHeadingProvider {
} }
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props, 'aria-hidden': true as const } as const) // Morfo-declared: aria-hidden. No soma-specific extras.
this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const)
); );
} }
@ -957,10 +979,8 @@ export class CommandGroupItemsProvider {
} }
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ // Morfo-declared: role. No soma-specific extras.
...this.runtimePart.props, this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const)
role: 'presentation' as const
} as const)
); );
} }
@ -990,11 +1010,8 @@ export class CommandEmptyProvider {
readonly shouldRender = $derived.by(() => this.provider.isEmpty); readonly shouldRender = $derived.by(() => this.provider.isEmpty);
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ // Morfo-declared: role, aria-live. No soma-specific extras.
...this.runtimePart.props, this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const)
role: 'status' as const,
'aria-live': 'polite' as const
} as const)
); );
} }
@ -1015,7 +1032,9 @@ export class CommandLoadingProvider {
this.runtimePart = provider.runtime.part('loading', { this.runtimePart = provider.runtime.part('loading', {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this owner: this,
// Per-part source for the morfo's aria-valuenow (propRef('percent')).
props: { percent: () => this.percent.current }
}); });
} }
@ -1027,11 +1046,10 @@ export class CommandLoadingProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: role, aria-valuemin/max, aria-valuenow (from the
role: 'progressbar' as const, // registered percent source). data-value is declared without a value —
'aria-valuemin': 0, // soma owns it.
'aria-valuemax': 100, ...this.runtimePart.renderProps(),
'aria-valuenow': this.percent.current,
'data-value': this.percent.current 'data-value': this.percent.current
} as const) } as const)
); );
@ -1064,10 +1082,7 @@ export class CommandSeparatorProvider {
readonly shouldRender = $derived.by(() => this.opts.forceMount.current || !this.provider.isEmpty); readonly shouldRender = $derived.by(() => this.opts.forceMount.current || !this.provider.isEmpty);
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ // Morfo-declared: role, aria-orientation. No soma-specific extras.
...this.runtimePart.props, this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const)
role: 'separator' as const,
'aria-orientation': 'horizontal' as const
} as const)
); );
} }

@ -154,11 +154,10 @@ export class EditableProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // syncAttrs writes the morfo attrs (data-editing/empty/disabled/readonly/
'data-required': boolToEmptyStrOrUndef(this.opts.required.current), // required/invalid + aria-required/invalid) imperatively via dom.apply —
'data-invalid': boolToEmptyStrOrUndef(this.opts.invalid.current), // the provider no longer re-declares them here.
'aria-required': this.opts.required.current || undefined, ...this.runtimePart.props
'aria-invalid': this.opts.invalid.current || undefined
} as const) } as const)
); );
} }
@ -275,9 +274,11 @@ export class EditablePreviewProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: role=textbox, aria-readonly=true. data-placeholder-
role: 'textbox' as const, // shown/disabled/readonly are declared without a value (soma-owned).
'aria-readonly': true as const, ...this.runtimePart.renderProps(),
// Soma override: aria-label is the placeholder, removed when empty
// (the morfo's raw propRef would emit '' instead of absent).
'aria-label': this.provider.opts.placeholder.current || undefined, 'aria-label': this.provider.opts.placeholder.current || undefined,
hidden: this.provider.isEditing || undefined, hidden: this.provider.isEditing || undefined,
tabindex: this.provider.opts.disabled.current ? undefined : 0, tabindex: this.provider.opts.disabled.current ? undefined : 0,
@ -357,15 +358,16 @@ export class EditableInputProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: aria-disabled/invalid (literal 'true' present-when-
// prop-truthy). data-disabled/readonly/invalid are declared without a
// value (soma-owned). Resolved against the component-level sources.
...this.runtimePart.renderProps(),
hidden: !this.provider.isEditing || undefined, hidden: !this.provider.isEditing || undefined,
value: this.provider.draft, value: this.provider.draft,
placeholder: this.provider.opts.placeholder.current || undefined, placeholder: this.provider.opts.placeholder.current || undefined,
maxlength: this.provider.opts.maxLength.current ?? undefined, maxlength: this.provider.opts.maxLength.current ?? undefined,
disabled: this.provider.opts.disabled.current || undefined, disabled: this.provider.opts.disabled.current || undefined,
readonly: this.provider.opts.readonly.current || undefined, readonly: this.provider.opts.readonly.current || undefined,
'aria-disabled': this.provider.opts.disabled.current || undefined,
'aria-invalid': this.provider.opts.invalid.current || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current), 'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current), 'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
'data-invalid': boolToEmptyStrOrUndef(this.provider.opts.invalid.current), 'data-invalid': boolToEmptyStrOrUndef(this.provider.opts.invalid.current),
@ -417,10 +419,12 @@ export class EditableEditTriggerProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: type=button, data-editing ← editing. data-disabled/
type: 'button' as const, // readonly are declared without a value (soma-owned).
...this.runtimePart.renderProps(),
// Soma override: the label is computed via the component's langs (the
// morfo's commonRef resolves to the same id but soma owns the lookup).
'aria-label': this.provider.soma.langs.ts(EDITABLE_LANGS.EDIT), 'aria-label': this.provider.soma.langs.ts(EDITABLE_LANGS.EDIT),
'data-editing': boolToEmptyStrOrUndef(this.provider.isEditing),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current), 'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
disabled: this.isDisabled || undefined, disabled: this.isDisabled || undefined,
@ -464,10 +468,12 @@ export class EditableSubmitTriggerProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: type=button, data-editing ← editing. data-disabled/
type: 'button' as const, // readonly are declared without a value (soma-owned).
...this.runtimePart.renderProps(),
// Soma override: the label is computed via the component's langs (the
// morfo's commonRef resolves to the same id but soma owns the lookup).
'aria-label': this.provider.soma.langs.ts(EDITABLE_LANGS.SUBMIT), 'aria-label': this.provider.soma.langs.ts(EDITABLE_LANGS.SUBMIT),
'data-editing': boolToEmptyStrOrUndef(this.provider.isEditing),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current), 'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
disabled: this.isDisabled || undefined, disabled: this.isDisabled || undefined,
@ -501,13 +507,14 @@ export class EditableHiddenInputProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: aria-hidden=true. Native form attrs the morfo does
// not model stay soma-owned.
...this.runtimePart.renderProps(),
type: 'hidden' as const, type: 'hidden' as const,
name: this.provider.opts.name.current || undefined, name: this.provider.opts.name.current || undefined,
value: this.provider.opts.value.current, value: this.provider.opts.value.current,
required: this.provider.opts.required.current || undefined, required: this.provider.opts.required.current || undefined,
disabled: this.provider.opts.disabled.current || undefined, disabled: this.provider.opts.disabled.current || undefined,
'aria-hidden': 'true' as const,
tabindex: -1 tabindex: -1
} as const) } as const)
); );
@ -548,10 +555,12 @@ export class EditableCancelTriggerProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: type=button, data-editing ← editing. data-disabled/
type: 'button' as const, // readonly are declared without a value (soma-owned).
...this.runtimePart.renderProps(),
// Soma override: the label is computed via the component's langs (the
// morfo's commonRef resolves to the same id but soma owns the lookup).
'aria-label': this.provider.soma.langs.ts(EDITABLE_LANGS.CANCEL), 'aria-label': this.provider.soma.langs.ts(EDITABLE_LANGS.CANCEL),
'data-editing': boolToEmptyStrOrUndef(this.provider.isEditing),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current), 'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
disabled: this.isDisabled || undefined, disabled: this.isDisabled || undefined,

@ -1,5 +1,5 @@
import { context, type WithRefOpts } from '../../provider'; import { context, type WithRefOpts } from '../../provider';
import { boolToEmptyStrOrUndef, boolToStr } from '$adom'; import { boolToEmptyStrOrUndef } from '$adom';
import { readableActive, state, type ActiveProps, type StateProps } from '$libs/reactive'; import { readableActive, state, type ActiveProps, type StateProps } from '$libs/reactive';
import type { OnChangeFn, SomaMouseEvent } from '../../types'; import type { OnChangeFn, SomaMouseEvent } from '../../types';
import { KEYS } from '../../keyboard'; import { KEYS } from '../../keyboard';
@ -299,7 +299,9 @@ export class FileUploadDropzoneProvider {
this.runtimePart = this.provider.runtime.part('dropzone', { this.runtimePart = this.provider.runtime.part('dropzone', {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this owner: this,
// Per-part source for the morfo's `aria-disabled` (propRef('disabled')).
props: { disabled: () => this.provider.isDisabled }
}); });
} }
@ -350,11 +352,10 @@ export class FileUploadDropzoneProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // role, aria-label, aria-disabled are morfo-declared (resolved against
role: 'button' as const, // the part's `disabled` source) — no longer re-declared here.
...this.runtimePart.renderProps(),
tabindex: this.provider.isDisabled ? -1 : 0, tabindex: this.provider.isDisabled ? -1 : 0,
'aria-label': this.provider.soma.langs.ts(FILE_UPLOAD_LANGS.DROPZONE),
'aria-disabled': boolToStr(this.provider.isDisabled),
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
'data-dragging': boolToEmptyStrOrUndef(this.dragging), 'data-dragging': boolToEmptyStrOrUndef(this.dragging),
onclick: this.onclick, onclick: this.onclick,
@ -387,7 +388,9 @@ export class FileUploadTriggerProvider {
this.runtimePart = this.provider.runtime.part('trigger', { this.runtimePart = this.provider.runtime.part('trigger', {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this owner: this,
// Per-part source for the morfo's `aria-disabled` (propRef('disabled')).
props: { disabled: () => this.provider.isDisabled }
}); });
} }
@ -400,10 +403,9 @@ export class FileUploadTriggerProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // type, aria-label, aria-disabled are morfo-declared (resolved against
type: 'button' as const, // the part's `disabled` source) — no longer re-declared here.
'aria-label': this.provider.soma.langs.ts(FILE_UPLOAD_LANGS.TRIGGER), ...this.runtimePart.renderProps(),
'aria-disabled': boolToStr(this.provider.isDisabled),
disabled: this.provider.isDisabled || undefined, disabled: this.provider.isDisabled || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
onclick: this.onclick onclick: this.onclick
@ -492,9 +494,9 @@ export class FileUploadFileListProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // role + aria-label (morfo's `list` translationRef resolves to the same
role: 'list' as const, // idlangref FILE_UPLOAD_LANGS.LIST) are morfo-declared.
'aria-label': this.provider.soma.langs.ts(FILE_UPLOAD_LANGS.LIST) ...this.runtimePart.renderProps()
} as const) } as const)
); );
} }
@ -538,8 +540,9 @@ export class FileUploadItemProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // role is morfo-declared. data-file-name/type are morfo-declared WITHOUT
role: 'listitem' as const, // a value (soma owns the values).
...this.runtimePart.renderProps(),
'data-file-name': this.opts.file.current.name, 'data-file-name': this.opts.file.current.name,
'data-file-type': this.opts.file.current.type || undefined 'data-file-type': this.opts.file.current.type || undefined
} as const) } as const)
@ -694,9 +697,11 @@ export class FileUploadItemRemoveProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // type is morfo-declared.
type: 'button' as const, ...this.runtimePart.renderProps(),
disabled: this.item.provider.isDisabled || undefined, disabled: this.item.provider.isDisabled || undefined,
// Soma override: the morfo's aria-label is the bare "Remove file" text;
// the provider appends the file name for a per-item accessible label.
'aria-label': [ 'aria-label': [
this.item.provider.soma.langs.ts(FILE_UPLOAD_LANGS.REMOVE), this.item.provider.soma.langs.ts(FILE_UPLOAD_LANGS.REMOVE),
this.item.opts.file.current.name this.item.opts.file.current.name
@ -728,7 +733,14 @@ export class FileUploadItemProgressProvider {
this.runtimePart = this.item.provider.runtime.part('item-progress', { this.runtimePart = this.item.provider.runtime.part('item-progress', {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this owner: this,
// Per-part sources for the morfo's value-bound aria. `aria-valuenow`
// resolves from `percent`; `fileName` backs the morfo's aria-label
// (the provider overrides it below with the percent-annotated label).
props: {
percent: () => this.percent,
fileName: () => this.item.opts.file.current.name
}
}); });
} }
@ -736,11 +748,11 @@ export class FileUploadItemProgressProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // role, aria-valuenow (← percent source), aria-valuemin/max are
role: 'progressbar' as const, // morfo-declared. data-value is morfo-declared WITHOUT a value.
'aria-valuenow': this.percent, ...this.runtimePart.renderProps(),
'aria-valuemin': 0, // Soma override: the morfo's aria-label is the bare file name; the
'aria-valuemax': 100, // provider annotates it with the current percentage.
'aria-label': `${this.item.opts.file.current.name}: ${this.percent}%`, 'aria-label': `${this.item.opts.file.current.name}: ${this.percent}%`,
'data-value': this.percent 'data-value': this.percent
} as const) } as const)
@ -781,10 +793,10 @@ export class FileUploadClearTriggerProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // type + aria-label (morfo's `buttons.clear` commonRef resolves to the
type: 'button' as const, // same idlangref FILE_UPLOAD_LANGS.CLEAR) are morfo-declared.
...this.runtimePart.renderProps(),
disabled: this.isDisabled || undefined, disabled: this.isDisabled || undefined,
'aria-label': this.provider.soma.langs.ts(FILE_UPLOAD_LANGS.CLEAR),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
onclick: this.onclick onclick: this.onclick
} as const) } as const)

@ -180,9 +180,9 @@ describe('GridListProvider', () => {
expect(result.provider.props).toMatchObject({ expect(result.provider.props).toMatchObject({
role: 'grid', role: 'grid',
'aria-label': 'Grid list', 'aria-label': 'Grid list',
'aria-multiselectable': true, 'aria-multiselectable': 'true',
'aria-required': true, 'aria-required': 'true',
'aria-invalid': true, 'aria-invalid': 'true',
'data-selection-mode': 'multiple', 'data-selection-mode': 'multiple',
'data-required': '', 'data-required': '',
'data-invalid': '' 'data-invalid': ''
@ -201,7 +201,7 @@ describe('GridListProvider', () => {
}); });
expect(result.gamma.props).toMatchObject({ expect(result.gamma.props).toMatchObject({
tabindex: -1, tabindex: -1,
'aria-disabled': true, 'aria-disabled': 'true',
'data-disabled': '' 'data-disabled': ''
}); });

@ -73,7 +73,18 @@ export class GridListProvider {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this, owner: this,
context: GridListProvider.ctx context: GridListProvider.ctx,
// Per-part sources for the morfo's conditional aria booleans
// (aria-multiselectable/disabled/readonly/invalid/required). They
// are field-aware here, so renderProps() resolves the conditions
// against the same flags the provider previously hardcoded.
props: {
multiple: () => this.opts.selectionMode.current === 'multiple',
disabled: () => this.isDisabled,
readonly: () => this.isReadonly,
invalid: () => this.isInvalid,
required: () => this.isRequired
}
}); });
$effect(() => { $effect(() => {
@ -428,15 +439,14 @@ export class GridListProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: role=grid + aria-multiselectable/disabled/readonly/
role: 'grid' as const, // invalid/required (conditional, resolved from this part's sources).
...this.runtimePart.renderProps(),
// Soma-owned: aria-label honours Field/labelledby (overrides the
// morfo's translationRef default); aria-labelledby + the data-* flags
// are not value-backed in the morfo, so soma supplies them.
'aria-label': this.resolvedAriaLabel.current, 'aria-label': this.resolvedAriaLabel.current,
'aria-labelledby': this.opts.ariaLabelledby.current, 'aria-labelledby': this.opts.ariaLabelledby.current,
'aria-multiselectable': this.opts.selectionMode.current === 'multiple' ? true : undefined,
'aria-disabled': this.isDisabled ? true : undefined,
'aria-readonly': this.isReadonly ? true : undefined,
'aria-invalid': this.isInvalid ? true : undefined,
'aria-required': this.isRequired ? true : undefined,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.isReadonly), 'data-readonly': boolToEmptyStrOrUndef(this.isReadonly),
'data-invalid': boolToEmptyStrOrUndef(this.isInvalid), 'data-invalid': boolToEmptyStrOrUndef(this.isInvalid),
@ -473,7 +483,10 @@ export class GridListRowProvider {
this.runtimePart = this.provider.runtime.part('row', { this.runtimePart = this.provider.runtime.part('row', {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this owner: this,
// row-local disabled (includes provider disabled) → renderProps
// resolves the morfo's conditional aria-disabled.
props: { disabled: () => this.isDisabled }
}); });
} }
@ -499,12 +512,14 @@ export class GridListRowProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: role=row + conditional aria-disabled.
role: 'row' as const, ...this.runtimePart.renderProps(),
// Soma-owned: roving tabindex; aria-selected is removed in 'none'
// mode (overrides the morfo's stateRef); the data-* are not
// value-backed in the morfo, so soma supplies them.
tabindex: this.isDisabled ? -1 : this.isRovingTarget ? 0 : -1, tabindex: this.isDisabled ? -1 : this.isRovingTarget ? 0 : -1,
'aria-selected': 'aria-selected':
this.provider.opts.selectionMode.current === 'none' ? undefined : this.isSelected, this.provider.opts.selectionMode.current === 'none' ? undefined : this.isSelected,
'aria-disabled': this.isDisabled ? true : undefined,
'data-value': this.opts.value.current, 'data-value': this.opts.value.current,
'data-text-value': this.opts.textValue.current, 'data-text-value': this.opts.textValue.current,
'data-state': this.isSelected ? ('selected' as const) : ('unselected' as const), 'data-state': this.isSelected ? ('selected' as const) : ('unselected' as const),
@ -550,8 +565,8 @@ export class GridListCellProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: role=gridcell.
role: 'gridcell' as const, ...this.runtimePart.renderProps(),
onkeydown: this.onkeydown onkeydown: this.onkeydown
} as const) } as const)
); );
@ -579,7 +594,11 @@ export class GridListSelectionCheckboxProvider {
this.runtimePart = this.provider.runtime.part('selection-checkbox', { this.runtimePart = this.provider.runtime.part('selection-checkbox', {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this owner: this,
// The morfo's conditional aria-disabled reads prop `disabled`; the
// checkbox is non-interactive when the grid is disabled OR readonly,
// so feed the combined flag → renderProps resolves the condition.
props: { disabled: () => this.provider.isDisabled || this.provider.isReadonly }
}); });
} }
@ -616,12 +635,15 @@ export class GridListSelectionCheckboxProvider {
this.rowValue = this.findRowValue(el); this.rowValue = this.findRowValue(el);
const checked = this.rowValue ? this.provider.isSelected(this.rowValue) : false; const checked = this.rowValue ? this.provider.isSelected(this.rowValue) : false;
return this.runtimePart.assert({ return this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared: role=checkbox + conditional aria-disabled.
...this.runtimePart.renderProps(),
// Soma-owned: native type=button; aria-checked is computed from the
// enclosing row association; aria-label honours an explicit prop
// (overrides the morfo's translationRef default); data-* are not
// value-backed in the morfo.
type: 'button' as const, type: 'button' as const,
role: 'checkbox' as const,
'aria-checked': checked, 'aria-checked': checked,
'aria-label': this.resolvedAriaLabel, 'aria-label': this.resolvedAriaLabel,
'aria-disabled': this.provider.isDisabled || this.provider.isReadonly || undefined,
'data-state': checked ? ('checked' as const) : ('unchecked' as const), 'data-state': checked ? ('checked' as const) : ('unchecked' as const),
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled || this.provider.isReadonly), 'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled || this.provider.isReadonly),
onclick: this.onclick onclick: this.onclick

@ -140,8 +140,8 @@ describe('ListboxProvider', () => {
role: 'listbox', role: 'listbox',
dir: 'ltr', dir: 'ltr',
'aria-orientation': 'vertical', 'aria-orientation': 'vertical',
'aria-multiselectable': true, 'aria-multiselectable': 'true',
'aria-required': true, 'aria-required': 'true',
'aria-label': 'Options', 'aria-label': 'Options',
'data-orientation': 'vertical', 'data-orientation': 'vertical',
'data-required': '', 'data-required': '',
@ -183,7 +183,7 @@ describe('ListboxProvider', () => {
expect(result.alpha.props).toMatchObject({ expect(result.alpha.props).toMatchObject({
role: 'option', role: 'option',
tabindex: 0, tabindex: 0,
'aria-selected': false, 'aria-selected': 'false',
'data-state': 'unselected', 'data-state': 'unselected',
'data-value': 'alpha' 'data-value': 'alpha'
}); });
@ -273,12 +273,12 @@ describe('ListboxProvider', () => {
}); });
expect(result.item.props).toMatchObject({ expect(result.item.props).toMatchObject({
'aria-selected': true, 'aria-selected': 'true',
'data-state': 'selected' 'data-state': 'selected'
}); });
expect(result.indicator.isPresent).toBe(true); expect(result.indicator.isPresent).toBe(true);
expect(result.indicator.props).toMatchObject({ expect(result.indicator.props).toMatchObject({
'aria-hidden': true, 'aria-hidden': 'true',
'data-state': 'selected' 'data-state': 'selected'
}); });
expect(result.group.props).toMatchObject({ expect(result.group.props).toMatchObject({

@ -80,7 +80,19 @@ export class ListboxProvider {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this, owner: this,
context: ListboxProvider.ctx context: ListboxProvider.ctx,
// Per-part sources so `renderProps()` resolves the morfo's role +
// aria-orientation/multiselectable/disabled/readonly/required/invalid
// instead of the provider re-declaring them. `multiple` maps the
// morfo's boolean prop to this component's `selectionMode`.
props: {
orientation: () => this.opts.orientation.current,
multiple: () => this.opts.selectionMode.current === 'multiple',
disabled: () => this.isDisabled,
readonly: () => this.isReadonly,
required: () => this.isRequired,
invalid: () => this.isInvalid
}
}); });
$effect(() => { $effect(() => {
@ -334,17 +346,17 @@ export class ListboxProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared (role, aria-orientation/multiselectable/disabled/
role: 'listbox' as const, // readonly/required/invalid, default aria-label) resolved against this
// part's registered sources — no longer re-declared here.
...this.runtimePart.renderProps(),
dir: this.resolvedDir.current, dir: this.resolvedDir.current,
'aria-orientation': this.opts.orientation.current, // Soma override: honours explicit ariaLabel prop + suppresses when
'aria-multiselectable': this.opts.selectionMode.current === 'multiple' ? true : undefined, // aria-labelledby is set (overrides the morfo's default translation;
'aria-disabled': this.isDisabled || undefined, // undefined correctly removes it). aria-labelledby is not morfo-modeled.
'aria-readonly': this.isReadonly || undefined,
'aria-required': this.isRequired || undefined,
'aria-invalid': this.isInvalid || undefined,
'aria-label': this.resolvedAriaLabel.current, 'aria-label': this.resolvedAriaLabel.current,
'aria-labelledby': this.opts.ariaLabelledby.current, 'aria-labelledby': this.opts.ariaLabelledby.current,
// Soma-owned data-* (morfo declares them without a value source).
'data-orientation': this.opts.orientation.current, 'data-orientation': this.opts.orientation.current,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.isReadonly), 'data-readonly': boolToEmptyStrOrUndef(this.isReadonly),
@ -390,7 +402,11 @@ export class ListboxItemProvider {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this, owner: this,
context: ListboxItemProvider.ctx context: ListboxItemProvider.ctx,
// Per-part sources so `renderProps()` resolves the morfo's aria-selected
// (stateRef 'selected') and aria-disabled (prop-truthy 'disabled').
states: { selected: () => this.isSelected },
props: { disabled: () => this.isDisabled }
}); });
} }
@ -437,11 +453,11 @@ export class ListboxItemProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared (role, aria-selected, aria-disabled) resolved against
role: 'option' as const, // this item's registered sources — no longer re-declared here.
...this.runtimePart.renderProps(),
tabindex: this.isRovingTarget ? 0 : -1, tabindex: this.isRovingTarget ? 0 : -1,
'aria-selected': this.isSelected, // Soma-owned data-* (morfo declares them without a value source).
'aria-disabled': this.isDisabled || undefined,
'data-state': this.isSelected ? 'selected' : 'unselected', 'data-state': this.isSelected ? 'selected' : 'unselected',
'data-highlighted': boolToEmptyStrOrUndef(this.highlighted), 'data-highlighted': boolToEmptyStrOrUndef(this.highlighted),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
@ -487,8 +503,9 @@ export class ListboxItemIndicatorProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared aria-hidden resolved from renderProps.
'aria-hidden': true as const, ...this.runtimePart.renderProps(),
// Soma-owned data-* (morfo declares no data on this part).
'data-state': this.item.isSelected ? 'selected' : 'unselected' 'data-state': this.item.isSelected ? 'selected' : 'unselected'
} as const) } as const)
); );
@ -523,15 +540,18 @@ export class ListboxGroupProvider {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this, owner: this,
context: ListboxGroupProvider.ctx context: ListboxGroupProvider.ctx,
// Per-part source so `renderProps()` resolves the morfo's
// aria-labelledby (partRef 'group-label' + part-present condition)
// against the registered GroupLabel id.
parts: { 'group-label': () => this.labelId || undefined }
}); });
} }
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared role + aria-labelledby resolved from renderProps.
role: 'group' as const, ...this.runtimePart.renderProps()
'aria-labelledby': this.labelId || undefined
} as const) } as const)
); );
} }

@ -267,6 +267,10 @@ export class SelectTriggerProvider {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this, owner: this,
// Per-part source for the morfo's `aria-activedescendant` ← highlightedId
// (the component-level sources don't carry it). Empty → undefined so the
// attr is omitted while nothing is highlighted.
props: { highlightedId: () => this.provider.highlightedId || undefined },
onRefChange: (el) => { onRefChange: (el) => {
this.provider.triggerRef.current = el; this.provider.triggerRef.current = el;
} }
@ -370,14 +374,13 @@ export class SelectTriggerProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared (type, role, aria-haspopup, aria-expanded,
type: 'button' as const, // aria-controls, aria-activedescendant, aria-required) resolved against
role: 'combobox' as const, // this part's sources — no longer re-declared here.
'aria-haspopup': 'listbox' as const, ...this.runtimePart.renderProps(),
'aria-expanded': this.provider.opts.open.current, // Soma-only: aria-invalid is not on the morfo's Trigger; the data-*
'aria-controls': this.provider.contentId.current || undefined, // below are declared by the morfo WITHOUT a value (soma owns them);
'aria-activedescendant': this.provider.highlightedId || undefined, // `disabled` is a native attr the morfo doesn't model.
'aria-required': this.provider.opts.required.current || undefined,
'aria-invalid': this.provider.opts.invalid.current || undefined, 'aria-invalid': this.provider.opts.invalid.current || undefined,
'data-state': getDataOpenClosed(this.provider.opts.open.current), 'data-state': getDataOpenClosed(this.provider.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current), 'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
@ -596,10 +599,12 @@ export class SelectContentProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared (role, aria-labelledby ← trigger part, aria-
role: 'listbox' as const, // multiselectable ← multiple prop) resolved against the component
'aria-labelledby': this.provider.triggerId.current || undefined, // sources — no longer re-declared here.
'aria-multiselectable': this.provider.opts.type.current === 'multiple' || undefined, ...this.runtimePart.renderProps(),
// Soma-only: data-state is declared by the morfo WITHOUT a value;
// floating / presence / dismissal props + tabindex the morfo can't model.
'data-state': getDataOpenClosed(this.provider.opts.open.current), 'data-state': getDataOpenClosed(this.provider.opts.open.current),
...this.floating.props, ...this.floating.props,
...this.provider.contentPresence.transitionAttrs, ...this.provider.contentPresence.transitionAttrs,
@ -664,7 +669,13 @@ export class SelectItemProvider {
this.runtimePart = this.provider.runtime.part('item', { this.runtimePart = this.provider.runtime.part('item', {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this owner: this,
// Per-instance sources for the morfo's value-bound aria: `aria-selected`
// ← `checked` state, `aria-disabled` ← `disabled` prop (the component-
// level sources only carry the root's open/disabled/etc., not per-item
// selection). With these, renderProps() resolves both from the morfo.
states: { checked: () => this.isSelected },
props: { disabled: () => this.opts.disabled.current }
}); });
// Register label for display text (avoids DOM queries). // Register label for display text (avoids DOM queries).
@ -697,10 +708,12 @@ export class SelectItemProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // Morfo-declared (role, aria-selected ← checked state, aria-disabled
role: 'option' as const, // ← disabled prop) resolved against this item's sources — no longer
'aria-selected': this.isSelected, // re-declared here.
'aria-disabled': this.opts.disabled.current || undefined, ...this.runtimePart.renderProps(),
// Soma-only: tabindex + the data-* below (all declared by the morfo
// WITHOUT a value, so soma owns them).
tabindex: this.opts.disabled.current ? undefined : -1, tabindex: this.opts.disabled.current ? undefined : -1,
'data-state': this.isSelected ? 'checked' : 'unchecked', 'data-state': this.isSelected ? 'checked' : 'unchecked',
'data-highlighted': this.provider.highlightedId === this.opts.id.current ? '' : undefined, 'data-highlighted': this.provider.highlightedId === this.opts.id.current ? '' : undefined,
@ -767,16 +780,17 @@ export class SelectGroupProvider {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this, owner: this,
context: SelectGroupProvider.ctx context: SelectGroupProvider.ctx,
// Per-group part source for the morfo's `aria-labelledby` ←
// group-heading (resolved + gated by its part-present condition).
parts: { 'group-heading': () => this.headingId.current || undefined }
}); });
} }
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ // Morfo-declared (role, aria-labelledby ← group-heading part) resolved
...this.runtimePart.props, // against this group's sources — no longer re-declared here.
role: 'group' as const, this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const)
'aria-labelledby': this.headingId.current || undefined
} as const)
); );
} }
@ -835,10 +849,8 @@ export class SelectSeparatorProvider {
} }
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ // Morfo-declared (role) resolved here — no longer re-declared.
...this.runtimePart.props, this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const)
role: 'separator' as const
} as const)
); );
} }

@ -147,8 +147,11 @@ export class TableColumnHeaderProvider {
: 'none'; : 'none';
return this.runtimePart.assert({ return this.runtimePart.assert({
...this.runtimePart.props, // role=columnheader is morfo-declared; renderProps supplies it.
role: 'columnheader' as const, ...this.runtimePart.renderProps(),
// aria-sort is the computed ARIA token (ascending/descending/none),
// gated on sortability — soma owns the value the morfo's propRef
// can't compute.
'aria-sort': canSort ? ariaSort : undefined, 'aria-sort': canSort ? ariaSort : undefined,
style: buildColumnStyle(this.table, this.header.id), style: buildColumnStyle(this.table, this.header.id),
'data-sortable': boolToEmptyStrOrUndef(canSort), 'data-sortable': boolToEmptyStrOrUndef(canSort),
@ -235,8 +238,8 @@ export class TableSortTriggerProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // type=button is morfo-declared; renderProps supplies it.
type: 'button' as const, ...this.runtimePart.renderProps(),
'data-sortable': boolToEmptyStrOrUndef(this.canSort), 'data-sortable': boolToEmptyStrOrUndef(this.canSort),
'data-sorted': this.sorted || undefined, 'data-sorted': this.sorted || undefined,
onclick: this.onclick, onclick: this.onclick,
@ -323,8 +326,11 @@ export class TableRowProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // role=row is morfo-declared; renderProps supplies it.
role: 'row' as const, ...this.runtimePart.renderProps(),
// aria-rowindex is 1-based (morfo's `index` propRef is the raw 0-based
// value); aria-selected is gated on selection mode — soma owns both
// computed values.
'aria-rowindex': this.row.index + 1, 'aria-rowindex': this.row.index + 1,
'aria-selected': this.table.selectionMode !== 'none' ? this.isSelected : undefined, 'aria-selected': this.table.selectionMode !== 'none' ? this.isSelected : undefined,
tabindex: this.table.selectionMode !== 'none' ? 0 : undefined, tabindex: this.table.selectionMode !== 'none' ? 0 : undefined,
@ -372,8 +378,10 @@ export class TableCellProvider {
const cell = this.opts.cell.current; const cell = this.opts.cell.current;
const pinned = this.table.getIsColumnPinned(cell.column.id); const pinned = this.table.getIsColumnPinned(cell.column.id);
return this.runtimePart.assert({ return this.runtimePart.assert({
...this.runtimePart.props, // role=gridcell is morfo-declared; renderProps supplies it.
role: 'gridcell' as const, ...this.runtimePart.renderProps(),
// aria-colindex is 1-based (morfo's `colIndex` propRef is raw 0-based) —
// soma owns the computed value.
'aria-colindex': this.opts.colIndex.current + 1, 'aria-colindex': this.opts.colIndex.current + 1,
style: buildColumnStyle(this.table, cell.column.id), style: buildColumnStyle(this.table, cell.column.id),
'data-pinned': pinned || undefined 'data-pinned': pinned || undefined
@ -511,9 +519,12 @@ export class TableRowDetailProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // role=row is morfo-declared; renderProps supplies it.
...this.runtimePart.renderProps(),
// Deterministic DOM id derived from row.id (so the sibling Trigger's
// aria-controls can target it across the <tr> boundary) — overrides
// the part's generated id.
id: tableRowDetailDomId(this.row.id), id: tableRowDetailDomId(this.row.id),
role: 'row' as const,
hidden: this.isOpen ? undefined : true, hidden: this.isOpen ? undefined : true,
'data-state': this.isOpen ? ('open' as const) : ('closed' as const) 'data-state': this.isOpen ? ('open' as const) : ('closed' as const)
} as const) } as const)
@ -598,8 +609,13 @@ export class TableRowDetailTriggerProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // type=button is morfo-declared; renderProps supplies it.
type: 'button' as const, ...this.runtimePart.renderProps(),
// aria-expanded is the live boolean (morfo's stateRef('open') has no
// registered state source here); aria-controls targets the sibling
// RowDetail by its deterministic id (not a registered part-ref across
// the <tr> boundary); aria-label honours an explicit `ariaLabel` prop
// and varies by open/closed state. Soma owns all three.
'aria-expanded': this.isOpen, 'aria-expanded': this.isOpen,
'aria-controls': tableRowDetailDomId(this.row.id), 'aria-controls': tableRowDetailDomId(this.row.id),
'aria-label': this.resolvedAriaLabel, 'aria-label': this.resolvedAriaLabel,

@ -76,7 +76,15 @@ export class TreeGridProvider {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this, owner: this,
context: TreeGridProvider.ctx context: TreeGridProvider.ctx,
// Per-part sources for the morfo's conditional aria (aria-multiselectable/
// disabled/readonly). With these, `renderProps()` resolves them from the
// morfo instead of the provider re-declaring the literals.
props: {
multiple: () => this.opts.selectionMode.current === 'multiple',
disabled: () => this.opts.disabled.current,
readonly: () => this.opts.readonly.current
}
}); });
$effect(() => { $effect(() => {
return () => this.typeahead.destroy(); return () => this.typeahead.destroy();
@ -443,13 +451,14 @@ export class TreeGridProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // role, aria-multiselectable/disabled/readonly are morfo-declared and
role: 'treegrid' as const, // resolved from this part's registered sources.
...this.runtimePart.renderProps(),
// Soma override: label honours `ariaLabelledby` (→ no aria-label) and
// the explicit `ariaLabel` prop over the morfo's translation default.
'aria-label': this.resolvedAriaLabel.current, 'aria-label': this.resolvedAriaLabel.current,
'aria-labelledby': this.opts.ariaLabelledby.current, 'aria-labelledby': this.opts.ariaLabelledby.current,
'aria-multiselectable': this.opts.selectionMode.current === 'multiple' ? true : undefined, // Soma-owned data-* (morfo declares them without a value).
'aria-disabled': this.opts.disabled.current ? true : undefined,
'aria-readonly': this.opts.readonly.current ? true : undefined,
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current), 'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current),
'data-empty': boolToEmptyStrOrUndef(this.isEmpty), 'data-empty': boolToEmptyStrOrUndef(this.isEmpty),
@ -480,7 +489,8 @@ export class TreeGridHeaderProvider {
} }
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props, role: 'row' as const } as const) // role is morfo-declared (role: 'row').
this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const)
); );
} }
@ -506,10 +516,8 @@ export class TreeGridColumnHeaderProvider {
} }
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ // role is morfo-declared (role: 'columnheader').
...this.runtimePart.props, this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const)
role: 'columnheader' as const
} as const)
); );
} }
@ -553,7 +561,17 @@ export class TreeGridRowProvider {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this, owner: this,
context: TreeGridRowProvider.ctx context: TreeGridRowProvider.ctx,
// Per-part sources for the morfo's value-bound aria (aria-level/expanded
// and the conditional aria-disabled). `aria-selected` stays a soma
// override (it must suppress when selectionMode is 'none'), so the
// `selected` state is intentionally NOT registered here.
props: {
level: () => this.level,
expanded: () => this.isExpanded,
hasChildren: () => this.opts.hasChildren.current,
disabled: () => this.isDisabled
}
}); });
} }
@ -585,14 +603,16 @@ export class TreeGridRowProvider {
readonly props = $derived.by(() => { readonly props = $derived.by(() => {
const hasChildren = this.opts.hasChildren.current; const hasChildren = this.opts.hasChildren.current;
return this.runtimePart.assert({ return this.runtimePart.assert({
...this.runtimePart.props, // role, aria-level, aria-expanded and aria-disabled are morfo-declared
role: 'row' as const, // and resolved from this part's registered sources.
...this.runtimePart.renderProps(),
tabindex: this.isDisabled ? -1 : this.isRovingTarget ? 0 : -1, tabindex: this.isDisabled ? -1 : this.isRovingTarget ? 0 : -1,
'aria-level': this.level, // Soma override: aria-selected is suppressed entirely when the grid is
'aria-expanded': hasChildren ? this.isExpanded : undefined, // not selectable (selectionMode === 'none'); the morfo's stateRef would
// always emit "true"/"false".
'aria-selected': 'aria-selected':
this.provider.opts.selectionMode.current === 'none' ? undefined : this.isSelected, this.provider.opts.selectionMode.current === 'none' ? undefined : this.isSelected,
'aria-disabled': this.isDisabled ? true : undefined, // Soma-owned data-* (morfo declares them without a value).
'data-value': this.opts.value.current, 'data-value': this.opts.value.current,
'data-text-value': this.opts.textValue.current, 'data-text-value': this.opts.textValue.current,
'data-state': this.isSelected ? ('selected' as const) : ('unselected' as const), 'data-state': this.isSelected ? ('selected' as const) : ('unselected' as const),
@ -633,10 +653,8 @@ export class TreeGridRowChildrenProvider {
readonly isExpanded = $derived.by(() => this.row.isExpanded); readonly isExpanded = $derived.by(() => this.row.isExpanded);
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ // role is morfo-declared (role: 'group').
...this.runtimePart.props, this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const)
role: 'group' as const
} as const)
); );
} }
@ -662,10 +680,8 @@ export class TreeGridCellProvider {
} }
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ // role is morfo-declared (role: 'gridcell').
...this.runtimePart.props, this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const)
role: 'gridcell' as const
} as const)
); );
} }
@ -708,11 +724,13 @@ export class TreeGridExpandTriggerProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // role, type and aria-hidden are morfo-declared.
type: 'button' as const, ...this.runtimePart.renderProps(),
tabindex: -1, tabindex: -1,
// Soma override: aria-label reflects expand/collapse state and honours
// the explicit `ariaLabel` prop over the morfo's translation default.
'aria-label': this.resolvedAriaLabel, 'aria-label': this.resolvedAriaLabel,
'aria-hidden': true as const, // Soma-owned data-* (morfo declares it without a value).
'data-state': this.row.isExpanded ? ('open' as const) : ('closed' as const), 'data-state': this.row.isExpanded ? ('open' as const) : ('closed' as const),
onclick: this.onclick onclick: this.onclick
} as const) } as const)

@ -290,11 +290,16 @@ export class TreeViewProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // `role` is morfo-declared (static `tree`), resolved by renderProps().
...this.runtimePart.renderProps(),
dir: this.opts.dir.current, dir: this.opts.dir.current,
role: 'tree' as const, // Soma override: provider models `multiple` via `selectionMode` and emits
// the boolean attribute (morfo declares the default `aria-multiselectable`
// keyed on a `multiple` prop the component-level sources don't register).
'aria-multiselectable': this.opts.selectionMode.current === 'multiple' || undefined, 'aria-multiselectable': this.opts.selectionMode.current === 'multiple' || undefined,
// Consumer override > labelledby reference > default translation. // Soma override of the morfo's default translation label: consumer
// override > labelledby reference > default translation. Setting it to
// undefined (labelledby present) correctly removes the morfo default.
'aria-label': this.opts.ariaLabelledby.current 'aria-label': this.opts.ariaLabelledby.current
? undefined ? undefined
: (this.opts.ariaLabel.current ?? this.soma.langs.ts(TREE_VIEW_LANGS.LABEL)), : (this.opts.ariaLabel.current ?? this.soma.langs.ts(TREE_VIEW_LANGS.LABEL)),
@ -351,12 +356,16 @@ export class TreeViewBranchProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // `role` is morfo-declared (static `treeitem`), resolved by renderProps().
role: 'treeitem' as const, ...this.runtimePart.renderProps(),
// Soma overrides — boolean ARIA state the morfo expresses as 'true'/'false'
// strings (aria-boolean) or present/absent; this provider emits the raw
// boolean, and `aria-level` is the computed depth+1 (morfo carries raw depth).
'aria-expanded': this.isExpanded, 'aria-expanded': this.isExpanded,
'aria-selected': this.isSelected, 'aria-selected': this.isSelected,
'aria-level': this.opts.depth.current + 1, 'aria-level': this.opts.depth.current + 1,
'aria-disabled': this.isDisabled || undefined, 'aria-disabled': this.isDisabled || undefined,
// Soma-owned data-* (the morfo declares these without a value source).
'data-value': this.opts.value.current, 'data-value': this.opts.value.current,
'data-state': this.isExpanded ? 'open' : 'closed', 'data-state': this.isExpanded ? 'open' : 'closed',
'data-selected': boolToEmptyStrOrUndef(this.isSelected), 'data-selected': boolToEmptyStrOrUndef(this.isSelected),
@ -447,8 +456,9 @@ export class TreeViewBranchContentProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // `role` is morfo-declared (static `group`), resolved by renderProps().
role: 'group' as const, ...this.runtimePart.renderProps(),
// Soma-owned data-* (the morfo declares these without a value source).
'data-state': this.branch.isExpanded ? 'open' : 'closed', 'data-state': this.branch.isExpanded ? 'open' : 'closed',
'data-depth': this.branch.opts.depth.current 'data-depth': this.branch.opts.depth.current
} as const) } as const)
@ -568,12 +578,16 @@ export class TreeViewItemProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // `role` is morfo-declared (static `treeitem`), resolved by renderProps().
role: 'treeitem' as const, ...this.runtimePart.renderProps(),
// Soma overrides — boolean ARIA state the morfo expresses as a 'true'
// string / present-absent; this provider emits the raw boolean, and
// `aria-level` is the computed depth+1 (morfo carries raw depth).
'aria-selected': this.isSelected, 'aria-selected': this.isSelected,
'aria-level': this.opts.depth.current + 1, 'aria-level': this.opts.depth.current + 1,
'aria-disabled': this.isDisabled || undefined, 'aria-disabled': this.isDisabled || undefined,
tabindex: this.provider.tabStopValue === this.opts.value.current ? 0 : -1, tabindex: this.provider.tabStopValue === this.opts.value.current ? 0 : -1,
// Soma-owned data-* (the morfo declares these without a value source).
'data-value': this.opts.value.current, 'data-value': this.opts.value.current,
'data-selected': boolToEmptyStrOrUndef(this.isSelected), 'data-selected': boolToEmptyStrOrUndef(this.isSelected),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),

@ -1,5 +1,5 @@
import { context, type WithRefOpts } from '../../provider'; import { context, type WithRefOpts } from '../../provider';
import { boolToEmptyStrOrUndef, boolToStr } from '$adom'; import { boolToEmptyStrOrUndef } from '$adom';
import { state, type ActiveProps, type StateProps } from '$libs/reactive'; import { state, type ActiveProps, type StateProps } from '$libs/reactive';
import type { SomaKeyboardEvent, SomaMouseEvent, OnChangeFn } from '../../types'; import type { SomaKeyboardEvent, SomaMouseEvent, OnChangeFn } from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard'; import { KEYS, getDirectionalKeys } from '../../keyboard';
@ -80,7 +80,10 @@ export class YearGridProvider {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this, owner: this,
context: YearGridProvider.ctx context: YearGridProvider.ctx,
// Source for the morfo's `aria-disabled` (propRef('disabled')) so
// renderProps() resolves it instead of the provider re-declaring it.
props: { disabled: () => opts.disabled.current }
}); });
// Initial focused year follows selection or placeholder. // Initial focused year follows selection or placeholder.
this.focusedYear = this.opts.value.current?.year ?? this.opts.placeholder.current.year; this.focusedYear = this.opts.value.current?.year ?? this.opts.placeholder.current.year;
@ -232,11 +235,13 @@ export class YearGridProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, // role + aria-disabled are morfo-declared, resolved via renderProps.
role: 'application' as const, ...this.runtimePart.renderProps(),
dir: this.opts.dir.current, dir: this.opts.dir.current,
// Computed accessible name (ariaLabel + heading) — overrides the morfo's
// raw `ariaLabel` propRef (which the provider doesn't source).
'aria-label': this.fullLabel, 'aria-label': this.fullLabel,
'aria-disabled': boolToStr(this.opts.disabled.current), // Soma-owned data values (the morfo declares these attrs without a value).
'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current), 'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current) 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current)
} as const) } as const)
@ -298,19 +303,20 @@ abstract class YearGridPaginationButtonProvider {
readonly opts: WithRefOpts; readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart; readonly runtimePart: SomaRuntimePart;
readonly provider: YearGridProvider; readonly provider: YearGridProvider;
protected readonly labelRef: string;
protected abstract disabled(): boolean; protected abstract disabled(): boolean;
protected abstract trigger(target?: HTMLElement): void; protected abstract trigger(target?: HTMLElement): void;
protected constructor(opts: WithRefOpts, part: 'prev-button' | 'next-button', labelRef: string) { protected constructor(opts: WithRefOpts, part: 'prev-button' | 'next-button') {
this.opts = opts; this.opts = opts;
this.provider = YearGridProvider.require(); this.provider = YearGridProvider.require();
this.runtimePart = this.provider.runtime.part(part, { this.runtimePart = this.provider.runtime.part(part, {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this owner: this,
// Source for the morfo's `aria-disabled` (propRef('disabled')) — the
// button-local bound check (isPrev/isNextButtonDisabled).
props: { disabled: () => this.disabled() }
}); });
this.labelRef = labelRef;
} }
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => { readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
@ -321,10 +327,9 @@ abstract class YearGridPaginationButtonProvider {
readonly props = $derived.by(() => { readonly props = $derived.by(() => {
const disabled = this.disabled(); const disabled = this.disabled();
return this.runtimePart.assert({ return this.runtimePart.assert({
...this.runtimePart.props, // type, aria-label (commonRef year-grid.prev/next-page) and aria-disabled
type: 'button' as const, // are morfo-declared, resolved via renderProps.
'aria-label': this.provider.soma.langs.ts(this.labelRef), ...this.runtimePart.renderProps(),
'aria-disabled': boolToStr(disabled),
'data-disabled': boolToEmptyStrOrUndef(disabled), 'data-disabled': boolToEmptyStrOrUndef(disabled),
disabled, disabled,
onclick: this.onclick onclick: this.onclick
@ -337,7 +342,7 @@ export class YearGridPrevButtonProvider extends YearGridPaginationButtonProvider
return new YearGridPrevButtonProvider(opts); return new YearGridPrevButtonProvider(opts);
} }
private constructor(opts: WithRefOpts) { private constructor(opts: WithRefOpts) {
super(opts, 'prev-button', YEAR_GRID_LANGS.PREV_PAGE); super(opts, 'prev-button');
} }
protected disabled() { protected disabled() {
return this.provider.isPrevButtonDisabled; return this.provider.isPrevButtonDisabled;
@ -352,7 +357,7 @@ export class YearGridNextButtonProvider extends YearGridPaginationButtonProvider
return new YearGridNextButtonProvider(opts); return new YearGridNextButtonProvider(opts);
} }
private constructor(opts: WithRefOpts) { private constructor(opts: WithRefOpts) {
super(opts, 'next-button', YEAR_GRID_LANGS.NEXT_PAGE); super(opts, 'next-button');
} }
protected disabled() { protected disabled() {
return this.provider.isNextButtonDisabled; return this.provider.isNextButtonDisabled;
@ -385,18 +390,25 @@ export class YearGridGridProvider {
this.runtimePart = this.provider.runtime.part('grid', { this.runtimePart = this.provider.runtime.part('grid', {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this owner: this,
// Sources for the morfo's `aria-readonly`/`aria-disabled`
// (propRef('readonly')/propRef('disabled')) so renderProps resolves them.
props: {
readonly: () => this.provider.opts.readonly.current,
disabled: () => this.provider.opts.disabled.current
}
}); });
} }
readonly props = $derived.by(() => { readonly props = $derived.by(() => {
const headingId = this.opts.labelledBy.current ?? this.provider.headingId.current; const headingId = this.opts.labelledBy.current ?? this.provider.headingId.current;
return this.runtimePart.assert({ return this.runtimePart.assert({
...this.runtimePart.props, // role, aria-readonly and aria-disabled are morfo-declared, resolved
role: 'grid' as const, // via renderProps.
'aria-labelledby': headingId || undefined, ...this.runtimePart.renderProps(),
'aria-readonly': boolToStr(this.provider.opts.readonly.current), // Computed: honours the `labelledBy` prop override + the registered
'aria-disabled': boolToStr(this.provider.opts.disabled.current) // heading id (overrides the morfo's raw partRef('heading')).
'aria-labelledby': headingId || undefined
} as const); } as const);
}); });
} }
@ -428,7 +440,13 @@ export class YearGridCellProvider {
id: opts.id, id: opts.id,
ref: opts.ref, ref: opts.ref,
owner: this, owner: this,
context: YearGridCellProvider.ctx context: YearGridCellProvider.ctx,
// Sources for the morfo's `aria-selected`/`aria-disabled`
// (propRef('selected')/propRef('disabled')) — the cell-local flags.
props: {
selected: () => this.selected,
disabled: () => this.disabled
}
}); });
} }
@ -461,12 +479,12 @@ export class YearGridCellProvider {
readonly props = $derived.by(() => { readonly props = $derived.by(() => {
const tabindex = this.focused && !this.disabled ? 0 : -1; const tabindex = this.focused && !this.disabled ? 0 : -1;
return this.runtimePart.assert({ return this.runtimePart.assert({
...this.runtimePart.props, // role, aria-selected and aria-disabled are morfo-declared, resolved
role: 'gridcell' as const, // via renderProps.
...this.runtimePart.renderProps(),
// Native button type the morfo doesn't model.
type: 'button' as const, type: 'button' as const,
tabindex, tabindex,
'aria-selected': boolToStr(this.selected),
'aria-disabled': boolToStr(this.disabled),
'data-selected': boolToEmptyStrOrUndef(this.selected), 'data-selected': boolToEmptyStrOrUndef(this.selected),
'data-current': boolToEmptyStrOrUndef(this.current), 'data-current': boolToEmptyStrOrUndef(this.current),
'data-disabled': boolToEmptyStrOrUndef(this.disabled), 'data-disabled': boolToEmptyStrOrUndef(this.disabled),

@ -51,7 +51,23 @@ const MIGRATED_CEILINGS: Record<string, number> = {
'time-range-picker': 0, 'time-range-picker': 0,
calendar: 2, // computed aria-label + aria-labelledby calendar: 2, // computed aria-label + aria-labelledby
'month-grid': 2, // computed aria-label + aria-labelledby 'month-grid': 2, // computed aria-label + aria-labelledby
'range-calendar': 1 // computed aria-label 'range-calendar': 1, // computed aria-label
// Batch 3 (workflow-migrated, centrally verified — tests + check green).
// Residue is all legit value overrides: computed aria-labels (carousel
// "slide N", editable/file-upload triggers), per-instance dynamic values
// (table aria-rowindex/colindex/sort, tree-view/grid row aria-level/selected
// per node), and computed refs (command aria-activedescendant/controls).
// tree-view/table keep their per-node multi-archetype aria hardcoded.
select: 0,
listbox: 1, // computed aria-label
carousel: 6, // computed aria-labels (provider/item/triggers/indicator)
'tree-view': 11, // per-node aria (level/selected/expanded) — multi-archetype
'grid-list': 4, // per-row aria-selected + selection-checkbox computed label
'tree-grid': 3, // computed aria-label + per-row aria-selected + trigger label
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
}; };
describe('soma↔morfo attr de-dup guard', () => { describe('soma↔morfo attr de-dup guard', () => {

Loading…
Cancel
Save

Powered by TurnKey Linux.