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
- 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 carrytabindex: -1(:803) so they take focus. - evidence: the Input keydown opens then
dom.focus(items[0])AND setshighlightedId(:387-394); the Content keydown computescurrentIndexfromhighlightedId(virtual) yet alsodom.focus(target)(:684, real). So the component simultaneously (a) advertisesaria-activedescendanton the input and (b) moves real focus to the option. The morfo'saria-activedescendantdeclaration says "virtual"; the code does "real". - impact: with focus moved onto an option, the input's
aria-activedescendantis 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 hasaria-activedescendantset 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, neverdom.focus(item). (Or, if real focus is intended, droparia-activedescendantfrom 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
- 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
activeElementstays 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
- 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')whiletexts.toggle = '#?components.combobox.toggle|Toggle'(:11) is declared. - evidence: every other component references a
textsentry by its KEY (Dialog:v.translationRef('content.roledescription', 'dialog window')). Combobox passes the resolved absolute idlangref string as the key, so thetextsentries (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
textskeys dead. Smoke's translation-key check should be confirmed against these (see VALIDATOR_GAPS). - proposed-fix: use
v.translationRef('toggle', 'Toggle')etc., referencing thetextskeys. - fix-status: open
MEDIUM: Per-item isSelected reads the whole value array through the provider (A31 O(N²))
- 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
valuearray (.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
- 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 asselect.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) andresolveItemEldelegates toresolveListItemElwhich escapesdata-value(list-selection.ts:145). No injection. (This is the safe pattern Select'sscrollSelectedIntoViewfails to use — see select-001.) - A33:
labelRegistryisSvelteMap(: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.tsextraction (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).