|
|
|
|
|
# Audit: toggle-group
|
|
|
|
|
|
audit-version: 1
|
|
|
|
|
|
audited-at: 2026-06-26
|
|
|
|
|
|
scope:
|
|
|
|
|
|
method: adversarially-verified workflow; HIGH lead-verified. B6 ground-truth: checkbox/toggle/switch commit-toggle = sequence post (lag-fixed); radio-group/tabs/accordion/stepper set state at call-site (pre OK); toggle-group + checkbox-group carry the A31 .includes pattern (SYS-7); slider has no gesture-layer A6 leak.
|
|
|
|
|
|
provider: G:\dev\svelte\vicen\src\uix\soma\components\toggle-group\toggle-group-provider.svelte.ts
|
|
|
|
|
|
sequence-audit: Toggle-group is NOT a commit-style toggle-in-handler control like Checkbox/Toggle. It is a ROVING SELECTION GROUP (like RadioGroup/Tabs) where state is set at the call-site (toggleItem called from handler, toggleItem sets state immediately). Morfo correctly declares sequence='post'. The 'post' seque
|
|
|
|
|
|
|
|
|
|
|
|
## Summary
|
|
|
|
|
|
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 1 · LOW 0.
|
|
|
|
|
|
|
|
|
|
|
|
## Findings
|
|
|
|
|
|
### MEDIUM: A31: per-item `$derived` calling a provider method reading global state (.includes) = O(N² — toggle-group-001 <!-- id: toggle-group-001 -->
|
|
|
|
|
|
- dimension: A31: per-item O(N²) derived
|
|
|
|
|
|
- rule: A31: per-item `$derived` calling a provider method reading global state (.includes) = O(N²) (SYS-7 — radio-group/toggle-group items are candidates).
|
|
|
|
|
|
- location: toggle-group-provider.svelte.ts:154–170 (isRovingTarget derived)
|
|
|
|
|
|
- evidence: Line 154–170: `readonly isRovingTarget = $derived.by(() => { const items = this.provider.getItems(); ... const firstPressed = items.find((item) => { const value = item.getAttribute('data-value'); return value ? this.provider.isItemPressed(value) : false; }); ... })`. Each item's isRovingTarget performs a linear find() over all items to locate the first pressed item. With N items, this is called N times, yielding O(N²) total calls to isItemPressed. The .find() loop on line 162–165 scans the entire items array per item instance.
|
|
|
|
|
|
- impact: On groups with many items (20+), roving tabindex computation becomes a performance bottleneck. The search is linear but repeated N times instead of cached at provider level. For a 50-item group, ~2500 iterations; for 100 items, ~10,000.
|
|
|
|
|
|
- proposed-fix: Cache the roving-target calculation at the provider level (memoized derived or a dedicated provider method returning the pressed/first-item in a single pass). Each item then simply checks `provider.rovingTargetValue === this.opts.value.current` (O(1) per item).
|
|
|
|
|
|
- verify: [confirmed] CONFIRMED A31/SYS-7. toggle-group-provider.svelte.ts:154-170 `isRovingTarget = $derived.by(() => { const items = this.provider.getItems(); ... const firstPressed = items.find((item) => { const value = item.getAttribute('data-value'); return value ? this.provider.isItemPressed(value) : false }) ... })`. Each item runs getItems() (full querySelectorAll at :97-98) AND a .find() over all items, each calling isItemPressed -> opts.value.current.includes(value) (:64-66). That is O(N) per item across N items = O(N^2), and the .find walk creates a reactive read of opts.value per item. DIVERGENCE evidence: the doctrinal sibling radio-group solves exactly this — radio-group-provider.svelte.ts:223-226 `isRovingTarget` returns O(1) `if (this.isChecked) return true` then defers to isFallbackTabStop, whose DOM walk at :154-158 is wrapped in `untrack(() => this.getItems()[0] === el)` with a comment naming '(A14 + A31)' and the A35 cascade. toggle-group has neither the O(1) pressed short-circuit nor untrack. Severity MEDIUM is correct (perf/reactivity debt, not a correctness/a11y break).
|
|
|
|
|
|
- fix-status: open
|
|
|
|
|
|
|
|
|
|
|
|
## No-findings dimensions
|
|
|
|
|
|
A: Contract (morfo 'as const satisfies Morfo') - CLEAN (line 101). Parts registered via runtime.part('provider', 'item') exist in morfo and vice versa (all 2-of-3 rules met). data-{c}/-{part} naming correct. Structural identity confirmed: toggle-group items declare data-toggle in morfo (line 77), reusing Toggle recipe. No aria/data mismatch. No scope-drift — morfo declares scope: ['soma', 'sema', 'eidos'] (line 10). No inert events — commit-toggle carries valid sequence: 'post' (line 34)., B: Behavior (soma) - SEQUENCE CLEAN (line 34: 'post'). State set in handler (toggleItem called from onclick at line 174, sets state at line 89). Matches post sequence — no lag risk. ROVING TABINDEX CLEAN (line 223: correctly derives tabindex=0 for first pressed item or first enabled item; all other items get -1). Only ONE item ever gets tabindex=0. RTL CLEAN (line 190: uses getDirectionalKeys(dir, orientation), no manual key mapping). No gesture/observer used (not needed). No setTimeout/setInterval/ResizeObserver. No Soma imports of Eidos (all imports clean: context, createAttrs, morfo, Soma, SomaRuntime)., C: DOM-selector - CLEAN. querySelector calls (line 97) use framework attrs only. No consumer value interpolation., D: Frontier (soma-eidos contract) - CLEAN. Soma does NOT import eidos (verified line 1–10). data-variant/color/size/attached/block are eidos VISUAL attrs on Provider (toggle-group.svelte:58–62) — per doctrine, not violations. syncAttrs:true on runtime.part (line 60) syncs only morfo-declared attrs (data-orientation, data-disabled); visual attrs pass through props without double-write or divergence., E: TSC composition - CLEAN. Per doctrine, toggle-group uses sanctioned cross-recipe composition pattern (lines 4104–4120 in base.ts: toggle-group.composition targets toggle-group-item and sets --toggle-palette-* via group data-color). Items receive data-variant/size from eidos context (toggle-group-item.svelte:27–28) so Toggle recipe rules fire on item's [data-toggle][data-variant][data-size]. No invented cross-recipe pattern., F: Tests - CLEAN. Provider test exists (toggle-group-provider.svelte.test.ts). jsdom environment, tests cover single/multiple modes, roving tabindex, keyboard navigation (roving nav with Home/End, directional keys). Interaction-heavy keyboard paths (lines 203–242) are tested., G: Redundancy - CLEAN. Roving logic is unique to this component (not duplicated). Structural identity is the canonical pattern (not redundancy). No re-implemented selection logic.
|
|
|
|
|
|
|
|
|
|
|
|
## Theming facts (E-bis)
|
|
|
|
|
|
- magic z-index: 1 (line 88, local stacking in attached mode) | 2 (line 94, local stacking for focus ring)
|
|
|
|
|
|
- magic literals: none
|
|
|
|
|
|
- undeclared parts: none
|
|
|
|
|
|
- roles clean: true · variants clean: true
|
|
|
|
|
|
|
|
|
|
|
|
## Tests (F)
|
|
|
|
|
|
- exists: true · env: jsdom (@vitest-environment jsdom)
|
|
|
|
|
|
- covers: Single selection toggle (toggleItem); Multiple mode selection; Deselectable=false (required selection); Roving tabindex (first pressed or first enabled item gets tabindex=0); Keyboard navigation (ArrowRight, ArrowLeft, Home, End); Focus skipping disabled items; Multiple selection toggle/untoggle
|
|
|
|
|
|
- untested: RTL keyboard navigation (ArrowRight vs ArrowLeft swap in RTL context); Vertical orientation keyboard nav (ArrowUp vs ArrowDown); Loop=false boundary behavior (edge wrapping); Disabled cascade from provider to items; Event fallbackTarget cascade selector match (runtime.trigger commit-toggle)
|