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.
svelte-kit-vice/audit/components/combobox.md

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 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

  • 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

  • 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²))

  • 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

  • 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).

Powered by TurnKey Linux.