You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
116 lines
8.5 KiB
116 lines
8.5 KiB
# Audit: combobox
|
|
audit-version: 1
|
|
audited-at: 2026-06-26
|
|
scope: ['soma', 'sema'] (eidos recipe present though 'eidos' not in morfo scope — systemic SCOPE-DRIFT)
|
|
files:
|
|
- provider: src/uix/soma/components/combobox/combobox-provider.svelte.ts (present)
|
|
- morfo: src/uix/morfo/components/combobox.ts (present)
|
|
- recipe: src/uix/eidos/components/combobox/combobox.css (558L) + lib/recipes/base.ts §combobox (present)
|
|
- demo: web/routes/uix/components/combobox (present, not inspected this pass)
|
|
- test: src/uix/soma/components/combobox/combobox-provider.svelte.test.ts (present, jsdom)
|
|
- readme: present (not inspected this pass)
|
|
|
|
## Summary
|
|
Combobox is well-built in several respects: the A33 `labelRegistry` clone-reassign incident is fixed (now
|
|
`SvelteMap`), dimension C is clean (`CSS.escape` on the highlighted-id query + the shared `resolveListItemEl`
|
|
helper escapes `data-value`), dismissal excludes both trigger and input (A22), and the inputValue auto-sync /
|
|
mode-reset effects are loop-safe (`untrack` + guards). The headline defect is a **focus-strategy violation
|
|
(A17)**: the morfo declares `aria-activedescendant` (virtual focus) but the provider moves **real DOM focus**
|
|
onto items (`dom.focus(items[0])`), mixing the two strategies and breaking the APG combobox contract (focus
|
|
must stay on the textbox). Plus a `translationRef` misuse, the systemic A31 + magic-z-index, and a jsdom-only
|
|
test gap right where the focus bug lives.
|
|
|
|
Counts: CRITICAL 0 · HIGH 2 · MEDIUM 3 · LOW 0.
|
|
|
|
## Findings
|
|
|
|
### HIGH: Mixes virtual focus (`aria-activedescendant`) with real DOM focus (`.focus()` on items) — A17 violation + breaks APG combobox <!-- id: combobox-001 -->
|
|
- dimension: B, A
|
|
- rule: A17 ("Never mix [virtual + DOM focus] in the same component. If the trigger has aria-activedescendant,
|
|
items must NOT call .focus()") + APG Combobox pattern (focus stays on the textbox) + morfo↔code drift
|
|
- location: morfo combobox.ts:106 (Input declares `aria-activedescendant`) vs provider:392, 408, 661, 684
|
|
(`this.provider.soma.dom.focus(...)` on items / input) + the items carry `tabindex: -1` (:803) so they take focus.
|
|
- evidence: the Input keydown opens then `dom.focus(items[0])` AND sets `highlightedId` (:387-394); the Content
|
|
keydown computes `currentIndex` from `highlightedId` (virtual) yet also `dom.focus(target)` (:684, real). So
|
|
the component simultaneously (a) advertises `aria-activedescendant` on the input and (b) moves real focus to
|
|
the option. The morfo's `aria-activedescendant` declaration says "virtual"; the code does "real".
|
|
- impact: with focus moved onto an option, the input's `aria-activedescendant` is meaningless (it only applies
|
|
while focus is on the input). Screen readers get a contradictory model (an option both has DOM focus and is
|
|
the input's active descendant). APG's combobox keeps focus on the textbox; moving it to options is the
|
|
listbox-with-DOM-focus pattern — picking one is required.
|
|
- repro: open the listbox, ArrowDown, inspect `document.activeElement` (an option) while the input still has
|
|
`aria-activedescendant` set to that option.
|
|
- proposed-fix: commit to virtual focus (the morfo's declared model): keep DOM focus on the input, drive
|
|
selection purely via `highlightedId`/`data-highlighted` + `scrollIntoView`, never `dom.focus(item)`. (Or, if
|
|
real focus is intended, drop `aria-activedescendant` from the morfo and the highlightedId machinery — but
|
|
that diverges from APG combobox.)
|
|
- fix-status: fixed (bac67315)
|
|
|
|
### HIGH: Tests pin selection logic only — keyboard/focus (where the A17 bug lives), dismissal-exclusion, custom-value, backspace untested; jsdom-only <!-- id: combobox-002 -->
|
|
- dimension: F
|
|
- rule: dimension-F honesty check
|
|
- location: combobox-provider.svelte.test.ts (3 tests: single-select, multi toggle + inputValue, getItems disabled-filter)
|
|
- evidence: no test exercises Input/Content `onkeydown` (the focus moves in combobox-001), the dismissal
|
|
trigger/input exclusion (`isValidEvent` + `onInteractOutside`, :594-626), `commitCustomValue`,
|
|
backspace-remove-last, or the inputValue auto-sync effect. `@vitest-environment jsdom` (:1) — focus isn't
|
|
faithful, so even a keyboard test here couldn't validate the A17 behavior.
|
|
- impact: the focus-strategy bug and every keyboard path ship unverified.
|
|
- proposed-fix: add provider tests for the keyboard routes + dismissal exclusion + custom value; add a
|
|
client/Playwright test that asserts `activeElement` stays on the input (the A17 contract).
|
|
- fix-status: open
|
|
|
|
### MEDIUM: `aria-label` parts pass the full idlangref as the `translationRef` key instead of the `texts` key <!-- id: combobox-003 -->
|
|
- dimension: A
|
|
- rule: A3 / A34 (translation namespace) + consistency with the canonical `v.translationRef(key, fallback)` form
|
|
- location: morfo combobox.ts:142, 168, 291, 323 — e.g. `v.translationRef('#?components.combobox.toggle|Toggle')`
|
|
while `texts.toggle = '#?components.combobox.toggle|Toggle'` (:11) is declared.
|
|
- evidence: every other component references a `texts` entry by its KEY (Dialog:
|
|
`v.translationRef('content.roledescription', 'dialog window')`). Combobox passes the resolved absolute
|
|
idlangref string as the key, so the `texts` entries (`toggle`/`clear`/`selectedTags`/`selectedTagRemove`) are
|
|
declared but never referenced by key — the indirection is bypassed.
|
|
- impact: at best redundant (the string happens to be a valid idlangref so it may resolve); at worst a
|
|
missing-key lookup. Either way it's inconsistent with the framework's pattern and leaves the `texts` keys
|
|
dead. Smoke's translation-key check should be confirmed against these (see VALIDATOR_GAPS).
|
|
- proposed-fix: use `v.translationRef('toggle', 'Toggle')` etc., referencing the `texts` keys.
|
|
- fix-status: open
|
|
|
|
### MEDIUM: Per-item `isSelected` reads the whole `value` array through the provider (A31 O(N²)) <!-- id: combobox-004 -->
|
|
- dimension: B
|
|
- rule: A31
|
|
- location: combobox-provider.svelte.ts:782 (`isSelected = $derived.by(() => this.provider.isSelected(value))`)
|
|
→ :182-184 (`.includes`)
|
|
- evidence: identical to select-007 — each Item's derivation calls a provider method reading the shared `value`
|
|
array (`.includes`, O(N)); on value change all N items re-derive → O(N²).
|
|
- impact: degrades on large multi-select lists (the A31 "5 fine / 30 hangs" class).
|
|
- proposed-fix: lift `selectedSet = new SvelteSet(value)` on the provider; item derivation does `.has(value)`.
|
|
(Shared with Select — see SHARED_EXTRACTION / systemic A31-LIST-SELECTION.)
|
|
- fix-status: open
|
|
|
|
### MEDIUM: `content-z: '80'` magic z-index literal <!-- id: combobox-005 -->
|
|
- dimension: E-bis
|
|
- rule: THEMING §35 Bloque C (z-index magic → `--z-index-*` tokens)
|
|
- location: lib/recipes/base.ts:4254 (`'content-z': '80'`); combobox.css:261.
|
|
- evidence: bare `80` (same value as `select.content-z`). Fourth overlay with a hardcoded content-z
|
|
(select 80, dialog 70, popover 75/60, combobox 80) → SYSTEMIC z-ladder finding.
|
|
- impact: see select-009 / popover-002.
|
|
- proposed-fix: tokenize to the canonical overlay z-ladder.
|
|
- fix-status: open
|
|
|
|
## No-findings dimensions
|
|
- **C (selectors):** the Enter handler uses `#${CSS.escape(highlightedId)}` (:417-419) and `resolveItemEl`
|
|
delegates to `resolveListItemEl` which escapes `data-value` (list-selection.ts:145). No injection. (This is
|
|
the safe pattern Select's `scrollSelectedIntoView` fails to use — see select-001.)
|
|
- **A33:** `labelRegistry` is `SvelteMap` (:97) — the documented A33 clone-reassign incident is resolved.
|
|
- **B (loop-safety):** inputValue auto-sync (:151-160) and mode-switch reset (:166-175) effects guard writes
|
|
and use `untrack`; no feedback loop. Dismissal excludes trigger + input (A22, :594-626).
|
|
- **D (frontier, severe half):** provider imports only soma layers + morfo — zero eidos imports.
|
|
- **E-bis (colors):** combobox.css has no raw hex / raw px-rem font literals in the scanned surface (only the
|
|
z-index literal above). (Full per-selector morfo-part diff not exhaustively run this pass — the CSS is 558L.)
|
|
|
|
## Style observations (opinion, non-blocking)
|
|
- The A17 violation is the single most important fix: the morfo already commits to virtual focus, so aligning
|
|
the provider to it (rather than the reverse) keeps the APG-correct model and removes the real-focus paths.
|
|
- The `list-selection.ts` extraction (shared with Select) is a good example of dimension-G done right — the
|
|
selection state machine is one pure, testable module. The remaining duplication (the directional-nav index
|
|
math) is the part still copied per-component (see select-003 / SHARED_EXTRACTION).
|