8.8 KiB
Audit: dropdown-menu
audit-version: 1 audited-at: 2026-06-26 scope: ['soma', 'sema', 'eidos'] (eidos IS declared — no scope-drift, unlike select/dialog/popover/combobox) files:
- provider: src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts (present)
- morfo: src/uix/morfo/components/dropdown-menu.ts (present)
- recipe: src/uix/eidos/components/dropdown-menu/dropdown-menu.css + lib/recipes/base.ts §dropdown-menu (present)
- demo: web/routes/uix/components/dropdown-menu (present, not inspected this pass)
- test: src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.test.ts (present, jsdom, 6 tests)
- readme: present (not inspected this pass)
Summary
The most disciplined provider of Batch 1. Consistent roving DOM focus (no virtual/real mix — the morfo declares
NO aria-activedescendant, the provider uses real dom.focus, A17-correct), uniform renderProps() usage
(no double-writes), a clean eidos→soma frontier, correct data-disabled/accessibleWhenDisabled handling, and
fully self-managed cleanup — including SafePolygon, which registers its document listeners inside an internal
$effect via dom.listen (verified: no A6 leak). It is the only Batch-1 overlay that correctly declares
eidos in its morfo scope. No HIGH findings. The remaining issues are the systemic directional-nav duplication,
magic theming literals, a checkbox/radio toggle that emits no semantic event, and a navigation-untested gap.
Counts: CRITICAL 0 · HIGH 0 · MEDIUM 4 · LOW 1.
Findings
MEDIUM: Directional-nav index math duplicated (Content + SubContent, and across Select/Combobox) — incl. the loop off-by
- dimension: G, B
- rule: dimension-G redundancy + the loop-boundary edge case
- location: dropdown-menu-provider.svelte.ts:384-422 (Content.onkeydown) and :1165-1209 (SubContent.onkeydown)
- evidence: both handlers re-implement the identical next/prev/Home/End + typeahead index math
(
(i+1)%len/Math.min/Math.max/(i-1+len)%len). It's also the same math as Select content route (select-003) and Combobox content route. ThecurrentIndex === -1+ prev + loop path computes(-1-1+n)%n = n-2(lands second-to-last, not last) — the same off-by as select-003; reachable when keydown fires with focus on the container rather than an item. - impact: four copies of one algorithm (one bug fixed in one place misses the others); the loop off-by is a latent edge-case bug.
- proposed-fix: extract a pure
nextIndex(curr, key, len, {loop, dir, orientation})helper, unit-test the boundaries once, and consume it in all menu/select/combobox keyboard routes. (See SHARED_EXTRACTION DIRECTIONAL-NAV.) - fix-status: open
MEDIUM: Checkbox/Radio item activation mutates state but emits no semantic event (unlike plain Item)
- dimension: A, B
- rule: M-3.7 (every state-mutating keyboard action needs a semantic event) + the morfo only declares
commit-selectonitem - location: provider
MenuItemProvider.onclick/onkeydownfireruntime.trigger('commit-select', …)(:510, :521), butMenuCheckboxItemProvider.activate(:735-745) andMenuRadioItemProvider.activate(:860-865) mutatechecked/group value and call onlyonSelect()+handleClose()— noruntime.trigger. - evidence: toggling a checkbox-item or selecting a radio-item changes state with no perceptual emit; the morfo
declares no event for
checkbox-item/radio-item. - impact: with
closeOnSelect: false(the common case for checkbox menus) there is zero sema feedback on toggle, while a plain Item select getscommit.select + affirm. Inconsistent perceptual contract; a checkbox toggle is canonicallycommit.toggle. - repro: open a menu with a
closeOnSelect={false}CheckboxItem, toggle it — nodata-eventis stamped. - proposed-fix: declare
commit-toggle(checkbox) /commit-select(radio) events on those parts in the morfo and fire them fromactivate(), or document the silence as deliberate in the README. - fix-status: open
MEDIUM: Magic theming literals — content-z: '80', item-disabled-opacity: '0.55', item-height: '2rem'
- dimension: E-bis
- rule: THEMING §35 (z-index/opacity magic → tokens) + §5 (size from
--list-*/--control-height-*/--space-*) + §4 - location: lib/recipes/base.ts:4279 (
'content-z': '80'), :4292 ('item-disabled-opacity': '0.55'), :4286 ('item-height': '2rem') - evidence:
content-z: '80'(5th overlay with a hardcoded content-z — systemic z-ladder).0.55should bevar(--opacity-disabled)(the canonical disabled opacity Dialog uses).2remitem-height is a raw rem where the list-surface size layer (--list-*, per project memory 2026-06-21) should drive it. - impact: opacity/size/z values that won't track theme/density/scale changes; the disabled opacity diverges
from the canonical
--opacity-disabledused elsewhere. - proposed-fix:
content-z→ canonical overlay z token;item-disabled-opacity→var(--opacity-disabled); confirmitem-heightshould bridge--list-item-height-*. - fix-status: open
MEDIUM: Arrow-key NAVIGATION index math is untested (activation/groups/submenu ARE tested); jsdom-only
- dimension: F
- rule: dimension-F honesty check
- location: dropdown-menu-provider.svelte.test.ts (6 tests)
- evidence: the suite is the strongest of Batch 1 — it covers trigger toggle, item scoping + disabled policy,
click/keyboard activation + close, checkbox/radio groups, submenu open (hover/click/ArrowRight), and
group/separator a11y. But no test calls
Content.onkeydown/SubContent.onkeydownwith ArrowDown/ArrowUp/ Home/End to exercise the navigation index math (dropdown-menu-001) or the loop boundary.@vitest-environment jsdom(:1) —dom.focusmoves aren't faithful. - impact: the duplicated nav math + loop off-by ship unverified.
- proposed-fix: add Content/SubContent navigation tests asserting the focused index after each arrow key incl. the loop wrap; add a client/Playwright test for real focus movement.
- fix-status: open
LOW: open/close/commit-select set state imperatively alongside a non-awaited void trigger (sequence not runtime-sequenced)
- dimension: B
- rule: two-moments doctrine + the Dialog pattern (events wired into the runtime so
triggersequences emit→handler) - location: runtime created with
{}(no events map, :93);handleOpen/handleClosedovoid this.runtime.trigger(...)then setopensynchronously (:116-140). - evidence: unlike Dialog (which wires
events: { open, close }soruntime.triggerawaits emit before the handler), dropdown-menu setsopenimperatively next to a fire-and-forget emit, so the morfo'ssequence: 'pre'is not enforced by the runtime — it relies on Presence keeping content mounted during exit. - impact: works in practice (Presence + the 'pre' content still exists), and the non-await is likely a
deliberate snappiness choice for a high-frequency surface; but it diverges from the canonical sequencing and
makes
sequence: 'pre'advisory here. - proposed-fix: either wire the open/close handlers into the runtime
eventsmap (Dialog parity) or document that menus intentionally don't await the emit. Decide once and apply to all menu-family components. - fix-status: open
No-findings dimensions
- A17 (focus strategy): consistent roving DOM focus — morfo declares no
aria-activedescendant, provider uses realdom.focus. No mixing (contrast Combobox). Clean. - C (selectors):
getItemscomposes attribute selectors with.closest()scoping (A10), no user-value interpolation. Clean. - D (frontier, severe half): provider imports only soma layers + morfo — zero eidos imports.
- A6 (cleanup): typeahead
destroy(:111), SubTrigger open timer (:1007), andSafePolygon(self-manages via an internal$effect+dom.listendisposers, verified safe-polygon.ts:209-215) all clean up. No leak. - A (renderProps discipline): every part spreads
renderProps()and adds only soma-owned extras (data-state/tabindex/handlers) — no syncAttrs+manual double-write (contrast Dialog/Popover). - A30: SubTrigger ref mirror via
onRefChange, not$effect(:988). - SCOPE: morfo scope correctly includes
eidos— the only Batch-1 overlay without the scope-drift.
Style observations (opinion, non-blocking)
- Use dropdown-menu as the positive reference for: roving-focus consistency, uniform
renderProps()(no double-write), self-managed layer cleanup, and correcteidos-in-scope declaration. Its main debts are shared, systemic ones (nav duplication, magic literals), not local mistakes. - The checkbox/radio silent-toggle (dropdown-menu-002) is the one genuinely local contract gap worth an explicit decision.