From 7bf5d047c30db52ce60283fe7d48564d7bf4cb3b Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 12 Jun 2026 13:34:57 +0200 Subject: [PATCH] refactor(soma): de-dup attrs in 12 providers via renderProps (batch 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/uix/morfo/components/command.ts | 12 ++- src/uix/morfo/components/file-upload.ts | 18 +++- src/uix/morfo/components/year-grid.ts | 59 ++++++++++-- .../carousel/carousel-provider.svelte.ts | 52 ++++++---- .../combobox/combobox-provider.svelte.ts | 93 ++++++++++-------- .../command/command-provider.svelte.ts | 95 +++++++++++-------- .../editable/editable-provider.svelte.ts | 53 ++++++----- .../file-upload-provider.svelte.ts | 66 +++++++------ .../grid-list-provider.svelte.test.ts | 8 +- .../grid-list/grid-list-provider.svelte.ts | 58 +++++++---- .../listbox/listbox-provider.svelte.test.ts | 10 +- .../listbox/listbox-provider.svelte.ts | 60 ++++++++---- .../select/select-provider.svelte.ts | 66 +++++++------ .../components/table/table-provider.svelte.ts | 40 +++++--- .../tree-grid/tree-grid-provider.svelte.ts | 74 +++++++++------ .../tree-view/tree-view-provider.svelte.ts | 32 +++++-- .../year-grid/year-grid-provider.svelte.ts | 70 +++++++++----- src/uix/soma/soma-attr-audit.test.ts | 18 +++- 18 files changed, 575 insertions(+), 309 deletions(-) diff --git a/src/uix/morfo/components/command.ts b/src/uix/morfo/components/command.ts index 39e6f9e75..8db280a36 100644 --- a/src/uix/morfo/components/command.ts +++ b/src/uix/morfo/components/command.ts @@ -111,12 +111,14 @@ export const commandMorfo = { { attr: 'aria-selected', value: v.propRef('selected'), - severity: 'optional' + severity: 'optional', + ariaBoolean: true }, { attr: 'aria-disabled', value: v.propRef('disabled'), - severity: 'optional' + severity: 'optional', + ariaBoolean: true } ] }, @@ -137,12 +139,14 @@ export const commandMorfo = { { attr: 'aria-selected', value: v.propRef('selected'), - severity: 'optional' + severity: 'optional', + ariaBoolean: true }, { attr: 'aria-disabled', value: v.propRef('disabled'), - severity: 'optional' + severity: 'optional', + ariaBoolean: true } ] }, diff --git a/src/uix/morfo/components/file-upload.ts b/src/uix/morfo/components/file-upload.ts index f174bdc9b..1a457a752 100644 --- a/src/uix/morfo/components/file-upload.ts +++ b/src/uix/morfo/components/file-upload.ts @@ -120,7 +120,14 @@ export const fileUploadMorfo = { value: v.translationRef('#?components.file-upload.dropzone|Upload files'), 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: [ { key: 'Enter', action: 'open-file-picker' }, @@ -143,7 +150,14 @@ export const fileUploadMorfo = { value: v.translationRef('#?components.file-upload.trigger|Select files'), 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 + } ] }, { diff --git a/src/uix/morfo/components/year-grid.ts b/src/uix/morfo/components/year-grid.ts index e24c92016..cd5f66c20 100644 --- a/src/uix/morfo/components/year-grid.ts +++ b/src/uix/morfo/components/year-grid.ts @@ -51,7 +51,14 @@ export const yearGridMorfo = { ], aria: [ { 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: [ { key: 'ArrowRight', action: 'next-year' }, @@ -102,7 +109,14 @@ export const yearGridMorfo = { value: v.commonRef('year-grid.prev-page', 'Previous years'), 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'), 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' }, severity: 'recommended' }, - { attr: 'aria-readonly', value: v.propRef('readonly'), severity: 'optional' }, - { attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' } + // Provider emitted `boolToStr(...)` (always "true"/"false") for both; + // `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' } ], aria: [ - { attr: 'aria-selected', value: v.propRef('selected'), severity: 'optional' }, - { attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' } + // Provider emitted `boolToStr(...)` (always "true"/"false") for both; + // `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 + } ] } ] diff --git a/src/uix/soma/components/carousel/carousel-provider.svelte.ts b/src/uix/soma/components/carousel/carousel-provider.svelte.ts index ca404c525..285f75aa8 100644 --- a/src/uix/soma/components/carousel/carousel-provider.svelte.ts +++ b/src/uix/soma/components/carousel/carousel-provider.svelte.ts @@ -439,13 +439,17 @@ export class CarouselProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'region' as const, - 'aria-roledescription': 'carousel', + // role + aria-roledescription are morfo-declared, resolved by renderProps. + ...this.runtimePart.renderProps(), + // 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, tabindex: this.isDisabled ? -1 : 0, dir: this.opts.dir.current, 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-dir': this.opts.dir.current, 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), @@ -600,10 +604,12 @@ export class CarouselItemProvider { // gap is the default `0px`. const basis = spv === 1 ? '100%' : `calc((100% - ${spv - 1} * ${gap}) / ${spv})`; return this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'group' as const, - 'aria-roledescription': 'slide', + // role + aria-roledescription are morfo-declared, resolved by renderProps. + ...this.runtimePart.renderProps(), + // 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}`, + // data-* are contract-only in the morfo (no resolvable `value`) — soma owns them. 'data-state': this.isActive ? 'active' : 'inactive', 'data-index': this.opts.index.current, 'data-orientation': this.provider.opts.orientation.current, @@ -649,8 +655,10 @@ export class CarouselPrevTriggerProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' as const, + // type + role + aria-label (default) are morfo-declared, resolved by renderProps. + ...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), disabled: this.isDisabled || undefined, 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), @@ -692,8 +700,10 @@ export class CarouselNextTriggerProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' as const, + // type + role + aria-label (default) are morfo-declared, resolved by renderProps. + ...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), disabled: this.isDisabled || undefined, 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), @@ -721,15 +731,18 @@ export class CarouselIndicatorGroupProvider { this.runtimePart = this.provider.runtime.part('indicator-group', { id: opts.id, 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(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'tablist' as const, - 'aria-orientation': this.provider.opts.orientation.current, + // role + aria-orientation are morfo-declared, resolved by renderProps. + ...this.runtimePart.renderProps(), + // data-orientation is contract-only in the morfo (no resolvable `value`) — soma owns it. 'data-orientation': this.provider.opts.orientation.current } as const) ); @@ -775,12 +788,17 @@ export class CarouselIndicatorProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' as const, - role: 'tab' as const, + // type + role are morfo-declared, resolved by renderProps. + ...this.runtimePart.renderProps(), + // 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-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}`, 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-index': this.opts.index.current, onclick: this.onclick diff --git a/src/uix/soma/components/combobox/combobox-provider.svelte.ts b/src/uix/soma/components/combobox/combobox-provider.svelte.ts index 45f2a92dc..1bf2239c6 100644 --- a/src/uix/soma/components/combobox/combobox-provider.svelte.ts +++ b/src/uix/soma/components/combobox/combobox-provider.svelte.ts @@ -14,7 +14,6 @@ import type { } from '../../types'; import { KEYS } from '../../keyboard'; import { Soma } from '../../core/soma.svelte'; -import { COMBOBOX_LANGS } from './langs'; import { Presence } from '../../layers/presence.svelte'; import { Dismissal, type DismissalBehavior } from '../../layers/dismissal.svelte'; @@ -338,7 +337,12 @@ export class ComboboxInputProvider { this.runtimePart = this.provider.runtime.part('input', { id: opts.id, 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.inputRef = opts.ref; @@ -426,16 +430,12 @@ export class ComboboxInputProvider { readonly props = $derived.by(() => 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, - 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), disabled: this.provider.opts.disabled.current || undefined, value: this.provider.opts.inputValue.current, @@ -476,13 +476,11 @@ export class ComboboxTriggerProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' as const, + // Morfo-declared: type, aria-label/haspopup/expanded/controls. + ...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, - '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-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current), disabled: this.provider.opts.disabled.current || undefined, @@ -658,10 +656,10 @@ export class ComboboxContentProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'listbox' as const, - 'aria-labelledby': this.provider.inputId.current || undefined, - 'aria-multiselectable': this.provider.opts.type.current === 'multiple' || undefined, + // Morfo-declared: role, aria-labelledby, aria-multiselectable. + ...this.runtimePart.renderProps(), + // Soma-only: data-state (morfo declares it WITHOUT a value), the + // floating/presence/dismissal layer attrs, native tabindex + handler. 'data-state': getDataOpenClosed(this.provider.opts.open.current), ...this.floating.props, ...this.provider.contentPresence.transitionAttrs, @@ -727,7 +725,12 @@ export class ComboboxItemProvider { this.runtimePart = this.provider.runtime.part('item', { id: opts.id, 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. @@ -760,9 +763,10 @@ export class ComboboxItemProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'option' as const, - 'aria-selected': this.isSelected, + // Morfo-declared: role, aria-selected (from the per-part `checked` state). + ...this.runtimePart.renderProps(), + // 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, 'data-state': this.isSelected ? 'checked' : 'unchecked', 'data-highlighted': this.provider.highlightedId === this.opts.id.current ? '' : undefined, @@ -804,15 +808,19 @@ export class ComboboxGroupProvider { id: opts.id, ref: opts.ref, 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(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'group' as const, - 'aria-labelledby': this.headingId.current || undefined + // Morfo-declared: role, aria-labelledby (from the per-part heading id). + ...this.runtimePart.renderProps() } as const) ); } @@ -873,8 +881,8 @@ export class ComboboxSeparatorProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'separator' as const + // Morfo-declared: role. No soma-specific extras. + ...this.runtimePart.renderProps() } as const) ); } @@ -934,7 +942,11 @@ export class ComboboxSelectedTagsProvider { /** Live snapshot of `{ value, label }` for every selected entry. Drives the chip loop. */ 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 ───────────────────────────────────────────────────────────── @@ -1024,8 +1036,11 @@ export class ComboboxSelectedTagRemoveProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' as const, + // Morfo-declared: type, aria-label (translationRef 'remove' — the morfo + // 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), disabled: this.disabled || undefined, onclick: this.onclick @@ -1062,14 +1077,12 @@ export class ComboboxClearButtonProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' as const, - 'aria-label': this.provider.soma.langs.ts(COMBOBOX_LANGS.CLEAR), + // Morfo-declared: type, aria-label (translationRef 'clear'), data-disabled + // (propRef 'clearDisabled' = disabled || isEmpty). + ...this.runtimePart.renderProps(), + // Soma-only: native tabindex + the native disabled attr + the handler. tabindex: -1, disabled: this.provider.opts.disabled.current || this.provider.isEmpty || undefined, - 'data-disabled': boolToEmptyStrOrUndef( - this.provider.opts.disabled.current || this.provider.isEmpty - ), onclick: this.onclick } as const) ); diff --git a/src/uix/soma/components/command/command-provider.svelte.ts b/src/uix/soma/components/command/command-provider.svelte.ts index c5144e83f..479e2d3df 100644 --- a/src/uix/soma/components/command/command-provider.svelte.ts +++ b/src/uix/soma/components/command/command-provider.svelte.ts @@ -1,5 +1,5 @@ import { context, type WithRefOpts } from '../../provider'; -import { boolToEmptyStrOrUndef, boolToStr } from '$adom'; +import { boolToEmptyStrOrUndef } from '$adom'; import { readableActive, state, @@ -504,14 +504,15 @@ export class CommandInputProvider { const selected = this.provider.opts.value.current; const listId = this.provider.listId.current; return this.runtimePart.assert({ - ...this.runtimePart.props, + // Morfo-declared: role, aria-autocomplete, aria-expanded. + ...this.runtimePart.renderProps(), type: 'text' as const, - role: 'combobox' as const, autocomplete: 'off' as const, autocorrect: 'off' as const, spellcheck: false, - 'aria-autocomplete': 'list' as const, - 'aria-expanded': true as const, + // Soma-computed ids — the morfo's partRef('list')/propRef('selectedId') + // 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-activedescendant': selected ? `${listId}-item-${cssSafe(selected)}` : undefined, value: this.provider.opts.search.current, @@ -552,8 +553,10 @@ export class CommandListProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'listbox' as const, + // Morfo-declared: role. The morfo also declares aria-label via + // translationRef (the default); soma overrides to honour an explicit + // `label` prop, falling back to the same translated string. + ...this.runtimePart.renderProps(), 'aria-label': this.provider.opts.label.current ?? this.provider.soma.langs.ts(COMMAND_LANGS.LIST) } as const) @@ -631,7 +634,14 @@ export class CommandItemProvider { this.runtimePart = this.provider.runtime.part('item', { id: opts.id, 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); @@ -717,11 +727,12 @@ export class CommandItemProvider { readonly props = $derived.by(() => 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, - role: 'option' as const, - 'aria-selected': this.isSelected, - 'aria-disabled': boolToStr(this.opts.disabled.current), 'data-selected': boolToEmptyStrOrUndef(this.isSelected), 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), 'data-value': this.opts.value.current, @@ -762,7 +773,13 @@ export class CommandLinkItemProvider { this.runtimePart = this.provider.runtime.part('link-item', { id: opts.id, 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; @@ -831,12 +848,13 @@ export class CommandLinkItemProvider { readonly props = $derived.by(() => 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, - role: 'option' as const, href: this.opts.href.current, - 'aria-selected': this.isSelected, - 'aria-disabled': boolToStr(this.opts.disabled.current), 'data-selected': boolToEmptyStrOrUndef(this.isSelected), 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), 'data-value': this.opts.value.current, @@ -898,8 +916,11 @@ export class CommandGroupProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'group' as const, + // Morfo-declared: role. The morfo declares aria-labelledby via + // 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, 'data-empty': boolToEmptyStrOrUndef(!this.hasVisibleItems) } as const) @@ -931,7 +952,8 @@ export class CommandGroupHeadingProvider { } 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(() => - this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'presentation' as const - } as const) + // Morfo-declared: role. No soma-specific extras. + this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const) ); } @@ -990,11 +1010,8 @@ export class CommandEmptyProvider { readonly shouldRender = $derived.by(() => this.provider.isEmpty); readonly props = $derived.by(() => - this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'status' as const, - 'aria-live': 'polite' as const - } as const) + // Morfo-declared: role, aria-live. No soma-specific extras. + this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const) ); } @@ -1015,7 +1032,9 @@ export class CommandLoadingProvider { this.runtimePart = provider.runtime.part('loading', { id: opts.id, 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(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'progressbar' as const, - 'aria-valuemin': 0, - 'aria-valuemax': 100, - 'aria-valuenow': this.percent.current, + // Morfo-declared: role, aria-valuemin/max, aria-valuenow (from the + // registered percent source). data-value is declared without a value — + // soma owns it. + ...this.runtimePart.renderProps(), 'data-value': this.percent.current } as const) ); @@ -1064,10 +1082,7 @@ export class CommandSeparatorProvider { readonly shouldRender = $derived.by(() => this.opts.forceMount.current || !this.provider.isEmpty); readonly props = $derived.by(() => - this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'separator' as const, - 'aria-orientation': 'horizontal' as const - } as const) + // Morfo-declared: role, aria-orientation. No soma-specific extras. + this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const) ); } diff --git a/src/uix/soma/components/editable/editable-provider.svelte.ts b/src/uix/soma/components/editable/editable-provider.svelte.ts index 361391572..67b546af4 100644 --- a/src/uix/soma/components/editable/editable-provider.svelte.ts +++ b/src/uix/soma/components/editable/editable-provider.svelte.ts @@ -154,11 +154,10 @@ export class EditableProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - 'data-required': boolToEmptyStrOrUndef(this.opts.required.current), - 'data-invalid': boolToEmptyStrOrUndef(this.opts.invalid.current), - 'aria-required': this.opts.required.current || undefined, - 'aria-invalid': this.opts.invalid.current || undefined + // syncAttrs writes the morfo attrs (data-editing/empty/disabled/readonly/ + // required/invalid + aria-required/invalid) imperatively via dom.apply — + // the provider no longer re-declares them here. + ...this.runtimePart.props } as const) ); } @@ -275,9 +274,11 @@ export class EditablePreviewProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'textbox' as const, - 'aria-readonly': true as const, + // Morfo-declared: role=textbox, aria-readonly=true. data-placeholder- + // shown/disabled/readonly are declared without a value (soma-owned). + ...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, hidden: this.provider.isEditing || undefined, tabindex: this.provider.opts.disabled.current ? undefined : 0, @@ -357,15 +358,16 @@ export class EditableInputProvider { readonly props = $derived.by(() => 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, value: this.provider.draft, placeholder: this.provider.opts.placeholder.current || undefined, maxlength: this.provider.opts.maxLength.current ?? undefined, disabled: this.provider.opts.disabled.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-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current), 'data-invalid': boolToEmptyStrOrUndef(this.provider.opts.invalid.current), @@ -417,10 +419,12 @@ export class EditableEditTriggerProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' as const, + // Morfo-declared: type=button, data-editing ← editing. data-disabled/ + // 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), - 'data-editing': boolToEmptyStrOrUndef(this.provider.isEditing), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), 'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current), disabled: this.isDisabled || undefined, @@ -464,10 +468,12 @@ export class EditableSubmitTriggerProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' as const, + // Morfo-declared: type=button, data-editing ← editing. data-disabled/ + // 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), - 'data-editing': boolToEmptyStrOrUndef(this.provider.isEditing), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), 'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current), disabled: this.isDisabled || undefined, @@ -501,13 +507,14 @@ export class EditableHiddenInputProvider { readonly props = $derived.by(() => 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, name: this.provider.opts.name.current || undefined, value: this.provider.opts.value.current, required: this.provider.opts.required.current || undefined, disabled: this.provider.opts.disabled.current || undefined, - 'aria-hidden': 'true' as const, tabindex: -1 } as const) ); @@ -548,10 +555,12 @@ export class EditableCancelTriggerProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' as const, + // Morfo-declared: type=button, data-editing ← editing. data-disabled/ + // 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), - 'data-editing': boolToEmptyStrOrUndef(this.provider.isEditing), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), 'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current), disabled: this.isDisabled || undefined, diff --git a/src/uix/soma/components/file-upload/file-upload-provider.svelte.ts b/src/uix/soma/components/file-upload/file-upload-provider.svelte.ts index 4a39d3a49..1733b29a8 100644 --- a/src/uix/soma/components/file-upload/file-upload-provider.svelte.ts +++ b/src/uix/soma/components/file-upload/file-upload-provider.svelte.ts @@ -1,5 +1,5 @@ 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 type { OnChangeFn, SomaMouseEvent } from '../../types'; import { KEYS } from '../../keyboard'; @@ -299,7 +299,9 @@ export class FileUploadDropzoneProvider { this.runtimePart = this.provider.runtime.part('dropzone', { id: opts.id, 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(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'button' as const, + // role, aria-label, aria-disabled are morfo-declared (resolved against + // the part's `disabled` source) — no longer re-declared here. + ...this.runtimePart.renderProps(), 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-dragging': boolToEmptyStrOrUndef(this.dragging), onclick: this.onclick, @@ -387,7 +388,9 @@ export class FileUploadTriggerProvider { this.runtimePart = this.provider.runtime.part('trigger', { id: opts.id, 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(() => this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' as const, - 'aria-label': this.provider.soma.langs.ts(FILE_UPLOAD_LANGS.TRIGGER), - 'aria-disabled': boolToStr(this.provider.isDisabled), + // type, aria-label, aria-disabled are morfo-declared (resolved against + // the part's `disabled` source) — no longer re-declared here. + ...this.runtimePart.renderProps(), disabled: this.provider.isDisabled || undefined, 'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled), onclick: this.onclick @@ -492,9 +494,9 @@ export class FileUploadFileListProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'list' as const, - 'aria-label': this.provider.soma.langs.ts(FILE_UPLOAD_LANGS.LIST) + // role + aria-label (morfo's `list` translationRef resolves to the same + // idlangref FILE_UPLOAD_LANGS.LIST) are morfo-declared. + ...this.runtimePart.renderProps() } as const) ); } @@ -538,8 +540,9 @@ export class FileUploadItemProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'listitem' as const, + // role is morfo-declared. data-file-name/type are morfo-declared WITHOUT + // a value (soma owns the values). + ...this.runtimePart.renderProps(), 'data-file-name': this.opts.file.current.name, 'data-file-type': this.opts.file.current.type || undefined } as const) @@ -694,9 +697,11 @@ export class FileUploadItemRemoveProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' as const, + // type is morfo-declared. + ...this.runtimePart.renderProps(), 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': [ this.item.provider.soma.langs.ts(FILE_UPLOAD_LANGS.REMOVE), this.item.opts.file.current.name @@ -728,7 +733,14 @@ export class FileUploadItemProgressProvider { this.runtimePart = this.item.provider.runtime.part('item-progress', { id: opts.id, 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(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'progressbar' as const, - 'aria-valuenow': this.percent, - 'aria-valuemin': 0, - 'aria-valuemax': 100, + // role, aria-valuenow (← percent source), aria-valuemin/max are + // morfo-declared. data-value is morfo-declared WITHOUT a value. + ...this.runtimePart.renderProps(), + // Soma override: the morfo's aria-label is the bare file name; the + // provider annotates it with the current percentage. 'aria-label': `${this.item.opts.file.current.name}: ${this.percent}%`, 'data-value': this.percent } as const) @@ -781,10 +793,10 @@ export class FileUploadClearTriggerProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' as const, + // type + aria-label (morfo's `buttons.clear` commonRef resolves to the + // same idlangref FILE_UPLOAD_LANGS.CLEAR) are morfo-declared. + ...this.runtimePart.renderProps(), disabled: this.isDisabled || undefined, - 'aria-label': this.provider.soma.langs.ts(FILE_UPLOAD_LANGS.CLEAR), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), onclick: this.onclick } as const) diff --git a/src/uix/soma/components/grid-list/grid-list-provider.svelte.test.ts b/src/uix/soma/components/grid-list/grid-list-provider.svelte.test.ts index 8eeb444f5..491dfb830 100644 --- a/src/uix/soma/components/grid-list/grid-list-provider.svelte.test.ts +++ b/src/uix/soma/components/grid-list/grid-list-provider.svelte.test.ts @@ -180,9 +180,9 @@ describe('GridListProvider', () => { expect(result.provider.props).toMatchObject({ role: 'grid', 'aria-label': 'Grid list', - 'aria-multiselectable': true, - 'aria-required': true, - 'aria-invalid': true, + 'aria-multiselectable': 'true', + 'aria-required': 'true', + 'aria-invalid': 'true', 'data-selection-mode': 'multiple', 'data-required': '', 'data-invalid': '' @@ -201,7 +201,7 @@ describe('GridListProvider', () => { }); expect(result.gamma.props).toMatchObject({ tabindex: -1, - 'aria-disabled': true, + 'aria-disabled': 'true', 'data-disabled': '' }); diff --git a/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts b/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts index b54b1b38b..312efb609 100644 --- a/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts +++ b/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts @@ -73,7 +73,18 @@ export class GridListProvider { id: opts.id, ref: opts.ref, 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(() => { @@ -428,15 +439,14 @@ export class GridListProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'grid' as const, + // Morfo-declared: role=grid + aria-multiselectable/disabled/readonly/ + // 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-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-readonly': boolToEmptyStrOrUndef(this.isReadonly), 'data-invalid': boolToEmptyStrOrUndef(this.isInvalid), @@ -473,7 +483,10 @@ export class GridListRowProvider { this.runtimePart = this.provider.runtime.part('row', { id: opts.id, 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(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'row' as const, + // Morfo-declared: role=row + conditional aria-disabled. + ...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, 'aria-selected': this.provider.opts.selectionMode.current === 'none' ? undefined : this.isSelected, - 'aria-disabled': this.isDisabled ? true : undefined, 'data-value': this.opts.value.current, 'data-text-value': this.opts.textValue.current, 'data-state': this.isSelected ? ('selected' as const) : ('unselected' as const), @@ -550,8 +565,8 @@ export class GridListCellProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'gridcell' as const, + // Morfo-declared: role=gridcell. + ...this.runtimePart.renderProps(), onkeydown: this.onkeydown } as const) ); @@ -579,7 +594,11 @@ export class GridListSelectionCheckboxProvider { this.runtimePart = this.provider.runtime.part('selection-checkbox', { id: opts.id, 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); const checked = this.rowValue ? this.provider.isSelected(this.rowValue) : false; 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, - role: 'checkbox' as const, 'aria-checked': checked, 'aria-label': this.resolvedAriaLabel, - 'aria-disabled': this.provider.isDisabled || this.provider.isReadonly || undefined, 'data-state': checked ? ('checked' as const) : ('unchecked' as const), 'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled || this.provider.isReadonly), onclick: this.onclick diff --git a/src/uix/soma/components/listbox/listbox-provider.svelte.test.ts b/src/uix/soma/components/listbox/listbox-provider.svelte.test.ts index e23cda81e..ecd4929d9 100644 --- a/src/uix/soma/components/listbox/listbox-provider.svelte.test.ts +++ b/src/uix/soma/components/listbox/listbox-provider.svelte.test.ts @@ -140,8 +140,8 @@ describe('ListboxProvider', () => { role: 'listbox', dir: 'ltr', 'aria-orientation': 'vertical', - 'aria-multiselectable': true, - 'aria-required': true, + 'aria-multiselectable': 'true', + 'aria-required': 'true', 'aria-label': 'Options', 'data-orientation': 'vertical', 'data-required': '', @@ -183,7 +183,7 @@ describe('ListboxProvider', () => { expect(result.alpha.props).toMatchObject({ role: 'option', tabindex: 0, - 'aria-selected': false, + 'aria-selected': 'false', 'data-state': 'unselected', 'data-value': 'alpha' }); @@ -273,12 +273,12 @@ describe('ListboxProvider', () => { }); expect(result.item.props).toMatchObject({ - 'aria-selected': true, + 'aria-selected': 'true', 'data-state': 'selected' }); expect(result.indicator.isPresent).toBe(true); expect(result.indicator.props).toMatchObject({ - 'aria-hidden': true, + 'aria-hidden': 'true', 'data-state': 'selected' }); expect(result.group.props).toMatchObject({ diff --git a/src/uix/soma/components/listbox/listbox-provider.svelte.ts b/src/uix/soma/components/listbox/listbox-provider.svelte.ts index a42bb3acf..b6471b735 100644 --- a/src/uix/soma/components/listbox/listbox-provider.svelte.ts +++ b/src/uix/soma/components/listbox/listbox-provider.svelte.ts @@ -80,7 +80,19 @@ export class ListboxProvider { id: opts.id, ref: opts.ref, 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(() => { @@ -334,17 +346,17 @@ export class ListboxProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'listbox' as const, + // Morfo-declared (role, aria-orientation/multiselectable/disabled/ + // 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, - 'aria-orientation': this.opts.orientation.current, - 'aria-multiselectable': this.opts.selectionMode.current === 'multiple' ? true : undefined, - 'aria-disabled': this.isDisabled || undefined, - 'aria-readonly': this.isReadonly || undefined, - 'aria-required': this.isRequired || undefined, - 'aria-invalid': this.isInvalid || undefined, + // Soma override: honours explicit ariaLabel prop + suppresses when + // aria-labelledby is set (overrides the morfo's default translation; + // undefined correctly removes it). aria-labelledby is not morfo-modeled. 'aria-label': this.resolvedAriaLabel.current, 'aria-labelledby': this.opts.ariaLabelledby.current, + // Soma-owned data-* (morfo declares them without a value source). 'data-orientation': this.opts.orientation.current, 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), 'data-readonly': boolToEmptyStrOrUndef(this.isReadonly), @@ -390,7 +402,11 @@ export class ListboxItemProvider { id: opts.id, ref: opts.ref, 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(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'option' as const, + // Morfo-declared (role, aria-selected, aria-disabled) resolved against + // this item's registered sources — no longer re-declared here. + ...this.runtimePart.renderProps(), tabindex: this.isRovingTarget ? 0 : -1, - 'aria-selected': this.isSelected, - 'aria-disabled': this.isDisabled || undefined, + // Soma-owned data-* (morfo declares them without a value source). 'data-state': this.isSelected ? 'selected' : 'unselected', 'data-highlighted': boolToEmptyStrOrUndef(this.highlighted), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), @@ -487,8 +503,9 @@ export class ListboxItemIndicatorProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - 'aria-hidden': true as const, + // Morfo-declared aria-hidden resolved from renderProps. + ...this.runtimePart.renderProps(), + // Soma-owned data-* (morfo declares no data on this part). 'data-state': this.item.isSelected ? 'selected' : 'unselected' } as const) ); @@ -523,15 +540,18 @@ export class ListboxGroupProvider { id: opts.id, ref: opts.ref, 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(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'group' as const, - 'aria-labelledby': this.labelId || undefined + // Morfo-declared role + aria-labelledby resolved from renderProps. + ...this.runtimePart.renderProps() } as const) ); } diff --git a/src/uix/soma/components/select/select-provider.svelte.ts b/src/uix/soma/components/select/select-provider.svelte.ts index 2ffdd4995..730e7904d 100644 --- a/src/uix/soma/components/select/select-provider.svelte.ts +++ b/src/uix/soma/components/select/select-provider.svelte.ts @@ -267,6 +267,10 @@ export class SelectTriggerProvider { id: opts.id, ref: opts.ref, 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) => { this.provider.triggerRef.current = el; } @@ -370,14 +374,13 @@ export class SelectTriggerProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' 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, + // Morfo-declared (type, role, aria-haspopup, aria-expanded, + // aria-controls, aria-activedescendant, aria-required) resolved against + // this part's sources — no longer re-declared here. + ...this.runtimePart.renderProps(), + // Soma-only: aria-invalid is not on the morfo's Trigger; the data-* + // below are declared by the morfo WITHOUT a value (soma owns them); + // `disabled` is a native attr the morfo doesn't model. 'aria-invalid': this.provider.opts.invalid.current || undefined, 'data-state': getDataOpenClosed(this.provider.opts.open.current), 'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current), @@ -596,10 +599,12 @@ export class SelectContentProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'listbox' as const, - 'aria-labelledby': this.provider.triggerId.current || undefined, - 'aria-multiselectable': this.provider.opts.type.current === 'multiple' || undefined, + // Morfo-declared (role, aria-labelledby ← trigger part, aria- + // multiselectable ← multiple prop) resolved against the component + // sources — no longer re-declared here. + ...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), ...this.floating.props, ...this.provider.contentPresence.transitionAttrs, @@ -664,7 +669,13 @@ export class SelectItemProvider { this.runtimePart = this.provider.runtime.part('item', { id: opts.id, 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). @@ -697,10 +708,12 @@ export class SelectItemProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'option' as const, - 'aria-selected': this.isSelected, - 'aria-disabled': this.opts.disabled.current || undefined, + // Morfo-declared (role, aria-selected ← checked state, aria-disabled + // ← disabled prop) resolved against this item's sources — no longer + // re-declared here. + ...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, 'data-state': this.isSelected ? 'checked' : 'unchecked', 'data-highlighted': this.provider.highlightedId === this.opts.id.current ? '' : undefined, @@ -767,16 +780,17 @@ export class SelectGroupProvider { id: opts.id, ref: opts.ref, 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(() => - this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'group' as const, - 'aria-labelledby': this.headingId.current || undefined - } as const) + // Morfo-declared (role, aria-labelledby ← group-heading part) resolved + // against this group's sources — no longer re-declared here. + this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const) ); } @@ -835,10 +849,8 @@ export class SelectSeparatorProvider { } readonly props = $derived.by(() => - this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'separator' as const - } as const) + // Morfo-declared (role) resolved here — no longer re-declared. + this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const) ); } diff --git a/src/uix/soma/components/table/table-provider.svelte.ts b/src/uix/soma/components/table/table-provider.svelte.ts index 674a75377..12a835fc9 100644 --- a/src/uix/soma/components/table/table-provider.svelte.ts +++ b/src/uix/soma/components/table/table-provider.svelte.ts @@ -147,8 +147,11 @@ export class TableColumnHeaderProvider { : 'none'; return this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'columnheader' as const, + // role=columnheader is morfo-declared; renderProps supplies it. + ...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, style: buildColumnStyle(this.table, this.header.id), 'data-sortable': boolToEmptyStrOrUndef(canSort), @@ -235,8 +238,8 @@ export class TableSortTriggerProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' as const, + // type=button is morfo-declared; renderProps supplies it. + ...this.runtimePart.renderProps(), 'data-sortable': boolToEmptyStrOrUndef(this.canSort), 'data-sorted': this.sorted || undefined, onclick: this.onclick, @@ -323,8 +326,11 @@ export class TableRowProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'row' as const, + // role=row is morfo-declared; renderProps supplies it. + ...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-selected': this.table.selectionMode !== 'none' ? this.isSelected : undefined, tabindex: this.table.selectionMode !== 'none' ? 0 : undefined, @@ -372,8 +378,10 @@ export class TableCellProvider { const cell = this.opts.cell.current; const pinned = this.table.getIsColumnPinned(cell.column.id); return this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'gridcell' as const, + // role=gridcell is morfo-declared; renderProps supplies it. + ...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, style: buildColumnStyle(this.table, cell.column.id), 'data-pinned': pinned || undefined @@ -511,9 +519,12 @@ export class TableRowDetailProvider { readonly props = $derived.by(() => 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 boundary) — overrides + // the part's generated id. id: tableRowDetailDomId(this.row.id), - role: 'row' as const, hidden: this.isOpen ? undefined : true, 'data-state': this.isOpen ? ('open' as const) : ('closed' as const) } as const) @@ -598,8 +609,13 @@ export class TableRowDetailTriggerProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' as const, + // type=button is morfo-declared; renderProps supplies it. + ...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 boundary); aria-label honours an explicit `ariaLabel` prop + // and varies by open/closed state. Soma owns all three. 'aria-expanded': this.isOpen, 'aria-controls': tableRowDetailDomId(this.row.id), 'aria-label': this.resolvedAriaLabel, diff --git a/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts b/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts index 4ba2363df..6f12e23a5 100644 --- a/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts +++ b/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts @@ -76,7 +76,15 @@ export class TreeGridProvider { id: opts.id, ref: opts.ref, 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(() => { return () => this.typeahead.destroy(); @@ -443,13 +451,14 @@ export class TreeGridProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'treegrid' as const, + // role, aria-multiselectable/disabled/readonly are morfo-declared and + // 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-labelledby': this.opts.ariaLabelledby.current, - 'aria-multiselectable': this.opts.selectionMode.current === 'multiple' ? true : undefined, - 'aria-disabled': this.opts.disabled.current ? true : undefined, - 'aria-readonly': this.opts.readonly.current ? true : undefined, + // Soma-owned data-* (morfo declares them without a value). 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), 'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current), 'data-empty': boolToEmptyStrOrUndef(this.isEmpty), @@ -480,7 +489,8 @@ export class TreeGridHeaderProvider { } 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(() => - this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'columnheader' as const - } as const) + // role is morfo-declared (role: 'columnheader'). + this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const) ); } @@ -553,7 +561,17 @@ export class TreeGridRowProvider { id: opts.id, ref: opts.ref, 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(() => { const hasChildren = this.opts.hasChildren.current; return this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'row' as const, + // role, aria-level, aria-expanded and aria-disabled are morfo-declared + // and resolved from this part's registered sources. + ...this.runtimePart.renderProps(), tabindex: this.isDisabled ? -1 : this.isRovingTarget ? 0 : -1, - 'aria-level': this.level, - 'aria-expanded': hasChildren ? this.isExpanded : undefined, + // Soma override: aria-selected is suppressed entirely when the grid is + // not selectable (selectionMode === 'none'); the morfo's stateRef would + // always emit "true"/"false". 'aria-selected': 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-text-value': this.opts.textValue.current, '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 props = $derived.by(() => - this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'group' as const - } as const) + // role is morfo-declared (role: 'group'). + this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const) ); } @@ -662,10 +680,8 @@ export class TreeGridCellProvider { } readonly props = $derived.by(() => - this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'gridcell' as const - } as const) + // role is morfo-declared (role: 'gridcell'). + this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const) ); } @@ -708,11 +724,13 @@ export class TreeGridExpandTriggerProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' as const, + // role, type and aria-hidden are morfo-declared. + ...this.runtimePart.renderProps(), 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-hidden': true as const, + // Soma-owned data-* (morfo declares it without a value). 'data-state': this.row.isExpanded ? ('open' as const) : ('closed' as const), onclick: this.onclick } as const) diff --git a/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts b/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts index 698147be0..9e023bddc 100644 --- a/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts +++ b/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts @@ -290,11 +290,16 @@ export class TreeViewProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, + // `role` is morfo-declared (static `tree`), resolved by renderProps(). + ...this.runtimePart.renderProps(), 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, - // 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 ? undefined : (this.opts.ariaLabel.current ?? this.soma.langs.ts(TREE_VIEW_LANGS.LABEL)), @@ -351,12 +356,16 @@ export class TreeViewBranchProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'treeitem' as const, + // `role` is morfo-declared (static `treeitem`), resolved by renderProps(). + ...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-selected': this.isSelected, 'aria-level': this.opts.depth.current + 1, 'aria-disabled': this.isDisabled || undefined, + // Soma-owned data-* (the morfo declares these without a value source). 'data-value': this.opts.value.current, 'data-state': this.isExpanded ? 'open' : 'closed', 'data-selected': boolToEmptyStrOrUndef(this.isSelected), @@ -447,8 +456,9 @@ export class TreeViewBranchContentProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'group' as const, + // `role` is morfo-declared (static `group`), resolved by renderProps(). + ...this.runtimePart.renderProps(), + // Soma-owned data-* (the morfo declares these without a value source). 'data-state': this.branch.isExpanded ? 'open' : 'closed', 'data-depth': this.branch.opts.depth.current } as const) @@ -568,12 +578,16 @@ export class TreeViewItemProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'treeitem' as const, + // `role` is morfo-declared (static `treeitem`), resolved by renderProps(). + ...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-level': this.opts.depth.current + 1, 'aria-disabled': this.isDisabled || undefined, 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-selected': boolToEmptyStrOrUndef(this.isSelected), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), diff --git a/src/uix/soma/components/year-grid/year-grid-provider.svelte.ts b/src/uix/soma/components/year-grid/year-grid-provider.svelte.ts index 7fdd0de15..f7859c1e7 100644 --- a/src/uix/soma/components/year-grid/year-grid-provider.svelte.ts +++ b/src/uix/soma/components/year-grid/year-grid-provider.svelte.ts @@ -1,5 +1,5 @@ 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 type { SomaKeyboardEvent, SomaMouseEvent, OnChangeFn } from '../../types'; import { KEYS, getDirectionalKeys } from '../../keyboard'; @@ -80,7 +80,10 @@ export class YearGridProvider { id: opts.id, ref: opts.ref, 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. this.focusedYear = this.opts.value.current?.year ?? this.opts.placeholder.current.year; @@ -232,11 +235,13 @@ export class YearGridProvider { readonly props = $derived.by(() => this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'application' as const, + // role + aria-disabled are morfo-declared, resolved via renderProps. + ...this.runtimePart.renderProps(), 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-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-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current) } as const) @@ -298,19 +303,20 @@ abstract class YearGridPaginationButtonProvider { readonly opts: WithRefOpts; readonly runtimePart: SomaRuntimePart; readonly provider: YearGridProvider; - protected readonly labelRef: string; protected abstract disabled(): boolean; 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.provider = YearGridProvider.require(); this.runtimePart = this.provider.runtime.part(part, { id: opts.id, 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) => { @@ -321,10 +327,9 @@ abstract class YearGridPaginationButtonProvider { readonly props = $derived.by(() => { const disabled = this.disabled(); return this.runtimePart.assert({ - ...this.runtimePart.props, - type: 'button' as const, - 'aria-label': this.provider.soma.langs.ts(this.labelRef), - 'aria-disabled': boolToStr(disabled), + // type, aria-label (commonRef year-grid.prev/next-page) and aria-disabled + // are morfo-declared, resolved via renderProps. + ...this.runtimePart.renderProps(), 'data-disabled': boolToEmptyStrOrUndef(disabled), disabled, onclick: this.onclick @@ -337,7 +342,7 @@ export class YearGridPrevButtonProvider extends YearGridPaginationButtonProvider return new YearGridPrevButtonProvider(opts); } private constructor(opts: WithRefOpts) { - super(opts, 'prev-button', YEAR_GRID_LANGS.PREV_PAGE); + super(opts, 'prev-button'); } protected disabled() { return this.provider.isPrevButtonDisabled; @@ -352,7 +357,7 @@ export class YearGridNextButtonProvider extends YearGridPaginationButtonProvider return new YearGridNextButtonProvider(opts); } private constructor(opts: WithRefOpts) { - super(opts, 'next-button', YEAR_GRID_LANGS.NEXT_PAGE); + super(opts, 'next-button'); } protected disabled() { return this.provider.isNextButtonDisabled; @@ -385,18 +390,25 @@ export class YearGridGridProvider { this.runtimePart = this.provider.runtime.part('grid', { id: opts.id, 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(() => { const headingId = this.opts.labelledBy.current ?? this.provider.headingId.current; return this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'grid' as const, - 'aria-labelledby': headingId || undefined, - 'aria-readonly': boolToStr(this.provider.opts.readonly.current), - 'aria-disabled': boolToStr(this.provider.opts.disabled.current) + // role, aria-readonly and aria-disabled are morfo-declared, resolved + // via renderProps. + ...this.runtimePart.renderProps(), + // Computed: honours the `labelledBy` prop override + the registered + // heading id (overrides the morfo's raw partRef('heading')). + 'aria-labelledby': headingId || undefined } as const); }); } @@ -428,7 +440,13 @@ export class YearGridCellProvider { id: opts.id, ref: opts.ref, 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(() => { const tabindex = this.focused && !this.disabled ? 0 : -1; return this.runtimePart.assert({ - ...this.runtimePart.props, - role: 'gridcell' as const, + // role, aria-selected and aria-disabled are morfo-declared, resolved + // via renderProps. + ...this.runtimePart.renderProps(), + // Native button type the morfo doesn't model. type: 'button' as const, tabindex, - 'aria-selected': boolToStr(this.selected), - 'aria-disabled': boolToStr(this.disabled), 'data-selected': boolToEmptyStrOrUndef(this.selected), 'data-current': boolToEmptyStrOrUndef(this.current), 'data-disabled': boolToEmptyStrOrUndef(this.disabled), diff --git a/src/uix/soma/soma-attr-audit.test.ts b/src/uix/soma/soma-attr-audit.test.ts index ff181d2ac..b79abfcb9 100644 --- a/src/uix/soma/soma-attr-audit.test.ts +++ b/src/uix/soma/soma-attr-audit.test.ts @@ -51,7 +51,23 @@ const MIGRATED_CEILINGS: Record = { 'time-range-picker': 0, calendar: 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', () => {