[Feature] ToggleGroup: match shadcn (Base UI + vega)
Brings ToggleGroup in line with shadcn's Base UI build and the vega style, following a shadcn audit of the component (first audit for it, so this PR also adds its component map, fixtures and divergences).
Before and after
Columns: RubyUI on main, RubyUI on this PR, shadcn (Base UI + vega). The fixture has a joined single group (Alignment, focus on Left), a multiple group with spacing 1, a vertical joined group in size sm, and a disabled group.




What changes
Single groups now render role="group" with <button aria-pressed> items, as Base UI does. They used to be a radiogroup of role="radio" buttons whose arrow keys moved focus without checking anything. Pressing the pressed item of a single group now clears the value (the hidden input is sent empty).
Both modes use a roving tabindex: one tab stop per group, arrow keys move between items and wrap. Multiple groups used to make every item a tab stop with no arrow keys. Arrows follow orientation (Left/Right when horizontal, flipped in RTL; Up/Down when vertical), and the new loop_focus: false stops them at the ends. Enter and Space are left to the native button. The controller keeps a tab stop when the current one is removed or disabled.
Root and items emit shadcn's data-slot, data-variant, data-size, data-spacing, data-pressed and data-disabled (plus data-multiple and data-orientation on the root). Classes are still chosen in Ruby. data-state and data-value stay because the controller and the Toggle classes read them.
ToggleGroupItem no longer sets px-3 w-auto min-w-0, so items keep the size's min-width (36px default, 32px sm). Joined groups (spacing: 0) get vega's px-2, the inline icon padding and data-[state=on]:bg-muted.
spacing now defaults to 2, as in shadcn. The docs' Spacing example shows spacing: 0 next to the default.
Breaking changes
ToggleGroupwithoutspacing:renders separated items (gap-2). Passspacing: 0to keep the joined look.- Single groups expose
aria-pressedinstead ofrole="radio"/aria-checked, and clicking the pressed item clears the selection. Code or tests that query[role=radio]oraria-checkedneed updating. Toggledropsdata-[state=on]:bg-muted. An app that copies the new Toggle but keeps an older ToggleGroup (radio items withdata-state, noaria-pressed) loses the pressed background, so update Toggle and ToggleGroup together. Installing or updating ToggleGroup already pulls Toggle in throughdependencies.yml.
Toggle (#649)
Part of what the audit found lived in RubyUI::Toggle, which ToggleGroupItem inherits (gap, padding, pressed and hover colors, the dark invalid ring, inline icon padding). #649 fixed those and is merged into this branch. With it, the items match vega element by element in light and dark.
#649 kept data-[state=on]:bg-muted in Toggle only so the old radio-mode ToggleGroup items kept their pressed style. Items now always carry aria-pressed, so this PR drops that class and aria-pressed:bg-muted covers them.
Deliberate differences
Registered in audit/divergences/toggle_group.yml:
- With a selection, Tab enters the group on the pressed item. Base UI always enters on the first item. Landing on the current choice tells keyboard users the value right away; with no selection both enter on the first item.
valueis the initial value and the state reaches forms through hidden inputs (name). There is noonValueChangeorpressed/onPressedChange, since Phlex has no controlled state.
One more difference follows the APG instead of shadcn: shadcn passes orientation to the DOM as data-orientation but not to the Base UI primitive, so its vertical groups still navigate with Left/Right. RubyUI uses Up/Down there (APG toolbar keyboard interaction).
Follow-ups
- shadcn accepts any
spacingnumber through--gap; RubyUI still accepts 0..4. Opening it up would mean switching togap-[--spacing(var(--gap))]and an inline style. - Report the vertical arrow-key behavior upstream to shadcn.
- Browser tests for the controller (vertical Up/Down, no wrap with
loop_focus: false, tab stop repair). The gem has no JS test runner anddocs/test/systemisn't wired into CI; this PR checked those paths with Playwright against the audit fixture.
Review
Codex reviewed the diff against main and found two issues, both fixed here: removing or disabling the tab-stop item left the group unreachable by Tab, and with loop_focus: false an arrow key at the end scrolled the page.
Cubic's first round (six findings) is fixed too: the server HTML has a single tab stop, the controller normalizes tab stops and rebuilds hidden inputs when items change, and loop_focus only accepts true or false.
How to test
cd gem && bundle exec rake;cd docs && bin/rails test;cd audit && bundle exec rake;cd mcp && bundle exec rake.bin/devindocs/, open/docs/toggle_group.- Tab into a single group: focus lands on the pressed item. Arrows move focus without changing the value; Space presses; pressing the pressed item clears it.
- Tab into the Multiple example: one tab stop, arrows move between items.
- Vertical example: Up/Down move, Left/Right do nothing.
- Submit a form around a group and check the hidden inputs.
Audit checklist on main
shadcn audit ruby-ui/ruby_ui
base 1998c5e8 head 1998c5e8 shadcn 8a353667
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
✅ Theme neutral tokens match shadcn
ToggleGroup (ref: bases/base/ui/toggle-group.tsx + style-vega.css @ 8a353667)
✅ Look focus-visible ring 3px oklab(0.708 0 0 / 0.5), border oklch(0.708 0 0), z-index 10 (dark: 0.556)
Requested: run `baseline toggle_group 8a3536678a528cc012b9e62484290e79d0be410f` in the fix PR and commit audit/component_map/toggle_group.yml + audit/fixtures/toggle_group/ (local today). Override of collect's ⏸: collect waited for focus on Center (data-audit-trigger) but Base UI makes the first item the tab stop, so shadcn's trigger never got focus. Tabbing in and reading the focused item on both sides gives identical ring/border/z-index. Screens: screens/toggle_group-keyfocus-*.
↳ computed styles (Playwright probe, Tab into fixture)
✅ Look column-gap: normal; gap: normal; row-gap: normal
ToggleGroup — Override: in a flex container `gap: normal` computes to 0 (CSS Box Alignment), so RubyUI (no gap class at spacing 0) and shadcn gap-[--spacing(0)] lay out the same. Spacing 1/2 groups match (4px/8px).
↳ https://developer.mozilla.org/en-US/docs/Web/CSS/column-gap#normal
❌ Look color: oklch(0.205 0 0); column-gap: 8px; gap: 8px; padding-left: 12px; padding-right: 12px; row-gap: 8px
vega: color: oklch(0.145 0 0); column-gap: 4px; gap: 4px; padding-left: 10px; padding-left: 8px; padding-right: 10px; padding-right: 8px; row-gap: 4px
ToggleGroupItem — closed/light, closed/dark. Three root causes: (1) padding: RubyUI ToggleGroupItem::JOIN_BASE `px-3` overrides Toggle's px-2 on every item, vega gives `px-2.5` (cn-toggle-size-*, style-vega.css:1358-1368) and `group-data-[spacing=0]:px-2` in joined groups (:1375); (2) gap: Toggle BASE `gap-2` vs vega `gap-1` (:1342); (3) pressed text: RubyUI `data-[state=on]:bg-accent data-[state=on]:text-accent-foreground` vs vega `data-[state=on]:bg-muted` / `aria-pressed:bg-muted` with text left at foreground (oklch 0.205 vs 0.145; bg computes the same in neutral). Requested: (1) padding: drop px-3 from JOIN_BASE; px-2 when spacing 0, px-2.5 otherwise (Toggle sizes move to px-2.5). (2) gap-1 in Toggle::BASE_CLASSES. (3) pressed: data-[state=on]:bg-muted without text-accent-foreground, in Toggle.
↳ style-vega.css:1342-1376 + computed styles (shadcn-audit collect)
⏸ Look properties: font-family, width
ToggleGroup, ToggleGroupItem — font-family differs only in the fallback list (Inter Variable loaded on both). The width diffs are NOT the font: they equal 2× the padding diff (e.g. Left 51.72 vs 43.72 = 2×(12−8)px) and are graded on the ToggleGroupItem padding line. Kept as is: environment only; width is fixed by the padding request.
❌ Look min-w-0 w-auto (ToggleGroupItem::JOIN_BASE) → min-width 0px
vega: min-w-9 / min-w-8 / min-w-10 kept from cn-toggle-size-* → min-width 36px (sm 32px)
Computed in the browser on all 11 items. Visible with one-letter or icon-only items (B / I / U): RubyUI items shrink below the square 36px. Requested: remove w-auto min-w-0 from JOIN_BASE so the size min-w applies.
↳ style-vega.css:1358-1368
❌ Look hover:text-muted-foreground (default variant) / hover:bg-accent hover:text-accent-foreground (outline) → hover text oklch(0.556) / oklch(0.205)
vega: hover:bg-muted hover:text-foreground (both variants) → hover text oklch(0.145)
Hovered Italic (default) and Left (outline) on both sides; background matches (0.97), text color differs. Lives in RubyUI::Toggle BASE/VARIANT classes, so the Toggle component shares it. Requested: hover:bg-muted hover:text-foreground in Toggle, drop outline's hover:bg-accent/text-accent-foreground.
↳ style-vega.css:1342, :1354
❌ Look no dark:aria-invalid:ring-destructive/40; no has-data-[icon=inline-start|end] padding
vega: dark:aria-invalid:ring-destructive/40 (cn-toggle); has-data-[icon=inline-end]:pr-2 / inline-start:pl-2 per size, pr-1.5/pl-1.5 in joined groups
From the class strings (fixture has no invalid item and no data-icon). Shared with RubyUI::Toggle. Requested: add dark:aria-invalid:ring-destructive/40 and has-data-[icon=inline-start|end] padding (1.5 in joined groups).
↳ style-vega.css:1342, :1358-1368, :1375
❌ HTML root: data-orientation, data-spacing, data-controller…; item: data-state=on|off, data-value, no data-slot
shadcn: root: data-slot=toggle-group, data-variant, data-size, data-spacing, data-orientation, data-multiple, data-disabled; item: data-slot=toggle-group-item, data-variant, data-size, data-spacing, data-pressed, data-disabled
Read from both DOMs. Without data-slot/data-variant the vega selectors (group-data-[spacing=0]/toggle-group, data-[variant=outline]) can't be ported as-is; RubyUI picks those classes in Ruby instead. Requested: emit data-slot/data-variant/data-size/data-spacing on root and items, data-pressed alongside data-state; keep picking classes in Ruby (as Separator #641).
↳ bases/base/ui/toggle-group.tsx:38-46, :66-74
❌ Props spacing: 0 (default)
shadcn: spacing = 2 (default)
A ToggleGroup with no spacing renders joined in RubyUI and separated by 8px in shadcn. Requested: default spacing 2; docs joined examples pass spacing: 0; changelog note.
↳ bases/base/ui/toggle-group.tsx:26
❌ Props no loopFocus (arrow keys always wrap)
shadcn: loopFocus (Base UI ToggleGroup.Props, default true)
Requested: add loop_focus: true (Stimulus value).
↳ @base-ui/react 1.9.0 toggle-group/ToggleGroup.d.ts
❌ Props value is the initial value only; no onValueChange; items: no pressed/defaultPressed/onPressedChange
shadcn: value + onValueChange (controlled), Toggle pressed/defaultPressed/onPressedChange
React controlled-state callbacks; server-rendered RubyUI exposes state through hidden inputs (name) instead. Candidate for a divergence entry. Suggested divergence: Props, rubyui 'value sets the initial state; state reaches forms via hidden inputs (name)', shadcn 'value/onValueChange, pressed/defaultPressed/onPressedChange', reason 'Server-rendered Phlex has no controlled state or JS callbacks'.
↳ @base-ui/react 1.9.0 toggle-group/ToggleGroup.d.ts, toggle/Toggle.d.ts
✅ Props variant, size, spacing, orientation, type (:single/:multiple), value, disabled (group and item)
multiple ↔ type: :multiple; defaultValue ↔ value. Item-level disabled: kwarg works (it reaches Toggle and overrides the group value; verified in Ruby). rubyui_only: name (hidden inputs for forms).
↳ bases/base/ui/toggle-group.tsx:22-35; component_map/toggle_group.yml
⏸ ARIA Focus/ARIA snapshot not taken on shadcn by collect (Base UI's tab stop is the first item, not the pressed Center). The focus target difference itself is graded on the Behavior line 'Tab lands on'. Requested: single mode follows Base UI (role=group, <button aria-pressed>, clicking the pressed item clears it); keep Tab on the pressed item and register that as a Behavior divergence.
⚠️ ARIA snapshot: only here: - radiogroup "Alignment": | - radio "Left" | - radio "Center" [checked] | - radio "Right"; snapshot: only here: - radiogroup "Size": | - radio "Small" | - radio "Medium" [checked] | - radio "Large"
shadcn: snapshot: only here: - group "Alignment": | - button "Left" | - button "Center" [pressed] | - button "Right"; snapshot: only here: - group "Size": | - button "Small" | - button "Medium" [pressed] | - button "Large"
ToggleGroup — single type: RubyUI renders role=radiogroup; Base UI renders role=group for both single and multiple (multiple matches on both sides). Requested: single mode follows Base UI (role=group, <button aria-pressed>, clicking the pressed item clears it); keep Tab on the pressed item and register that as a Behavior divergence.
↳ accessibility tree (shadcn-audit collect); bases/base/ui/toggle-group.tsx:38-46
⚠️ ARIA snapshot: only here: - radio "Center" [checked]; snapshot: only here: - radio "Large"; snapshot: only here: - radio "Left"; snapshot: only here: - radio "Medium" [checked]; snapshot: only here: - radio "Right"; snapshot: only here: - radio "Small"
shadcn: snapshot: only here: - button "Center" [pressed]; snapshot: only here: - button "Large"; snapshot: only here: - button "Left"; snapshot: only here: - button "Medium" [pressed]; snapshot: only here: - button "Right"; snapshot: only here: - button "Small"
ToggleGroupItem — single type: RubyUI role=radio + aria-checked; Base UI keeps <button aria-pressed> in single mode. Multiple: both aria-pressed (match). Requested: single mode follows Base UI (role=group, <button aria-pressed>, clicking the pressed item clears it); keep Tab on the pressed item and register that as a Behavior divergence.
↳ accessibility tree (shadcn-audit collect)
⚠️ Behavior single group: Tab lands on the pressed item (Center)
shadcn: Tab lands on the first item (Left); pressed Center has tabindex=-1
RubyUI follows APG radio (focus the checked radio), consistent with its radio roles. Requested: single mode follows Base UI (role=group, <button aria-pressed>, clicking the pressed item clears it); keep Tab on the pressed item and register that as a Behavior divergence.
↳ bases/base/ui/toggle-group.tsx (Base UI CompositeRoot); Playwright probe
⚠️ Behavior single: clicking/Enter on the pressed item keeps it pressed (value can't be cleared)
shadcn: single: clicking the pressed item unpresses it (value becomes [])
Radio-like vs toggle-like semantics. Requested: single mode follows Base UI (role=group, <button aria-pressed>, clicking the pressed item clears it); keep Tab on the pressed item and register that as a Behavior divergence.
↳ Playwright probe; @base-ui/react 1.9.0 ToggleGroup
⚠️ Behavior multiple: every item is a tab stop (tabindex=0), arrow keys do nothing
shadcn: multiple: roving tabindex (one tab stop per group), ArrowLeft/Right move focus
Tab order RubyUI: Center, Bold, Italic, Underline, Medium; shadcn: Left, Bold, Small. Requested: roving tabindex + arrow keys for multiple too.
↳ Playwright probe; @base-ui/react 1.9.0 ToggleGroup
⚠️ Behavior ArrowDown/ArrowUp also move focus in a horizontal group
shadcn: horizontal group ignores ArrowDown (focus stays)
Vertical group: Base UI also moved with ArrowRight after a programmatic focus; that run is not conclusive for vertical. Requested: respect orientation (horizontal ←/→, vertical ↑/↓, Home/End both); re-check vertical on shadcn with real focus.
↳ Playwright probe
✅ Behavior single: arrows move focus without changing the pressed item, Space/Enter press; Home/End; wrap; disabled group skipped by Tab
↳ Playwright probe
✅ Animation transition color, box-shadow 150ms
↳ computed styles (Playwright probe)
✅ Native Stimulus: roving tabindex, arrow keys, hidden inputs
Roving focus/arrow keys have no native equivalent; no row of the native table applies.
↳ ../.claude/skills/shadcn-audit/references/native-html.md#when-native-would-lose-behavior-shadcn-provides
✅ Spec shadcn side observed: valid toggle-button semantics.
↳ https://www.w3.org/TR/wai-aria-1.2/#aria-pressed
⚠️ Spec role=radio items: arrow keys move focus but don't check
shadcn: n/a (shadcn uses aria-pressed buttons, not radios)
Recommendation (APG): in a radio group, Right/Down Arrow move focus and check the newly focused radio. RubyUI alone uses the radio roles. Requested: single mode follows Base UI (role=group, <button aria-pressed>, clicking the pressed item clears it); keep Tab on the pressed item and register that as a Behavior divergence.
↳ https://www.w3.org/WAI/ARIA/apg/patterns/radio/#keyboardinteraction
Audit checklist on this PR
shadcn audit ruby-ui/ruby_ui
base 1998c5e8 head be39304f shadcn 8a353667
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
✅ Theme neutral tokens match shadcn
ToggleGroup (ref: bases/base/ui/toggle-group.tsx + style-vega.css @ 8a353667)
✅ Look column-gap: normal; gap: normal; row-gap: normal
ToggleGroup — Override: `gap: normal` in a flex container computes to 0, same layout as shadcn's gap-[--spacing(0)] in joined groups.
↳ https://developer.mozilla.org/en-US/docs/Web/CSS/column-gap#normal
⏸ Look properties: font-family
ToggleGroup, ToggleGroupItem — explained by environment diff: font Font fallback list only; every other computed style matches per element.
✅ Look ToggleGroupItem: padding, gap, min-width, pressed bg/text match per element, light and dark
With the Toggle from #649 merged; no per-element diff left on the items.
↳ style-vega.css:1342-1375 + computed styles (shadcn-audit collect)
✅ Look hover bg-muted / text-foreground (default and outline), light and dark
Hovered Italic, Left and Medium on both sides.
↳ style-vega.css:1342, :1354
✅ Look dark:aria-invalid:ring-destructive/40; has-data-[icon] padding per size and in joined groups
From the class strings (Toggle #649 + JOINED).
↳ style-vega.css:1342, :1358-1375
✅ HTML root data-slot/variant/size/spacing/orientation (+ data-multiple, data-disabled); items data-slot/variant/size/spacing, data-pressed, data-disabled
data-state/data-value stay for the controller and Toggle classes.
↳ bases/base/ui/toggle-group.tsx:38-46, :66-74
✅ Props spacing: 2 (default)
↳ bases/base/ui/toggle-group.tsx:26
✅ Props loop_focus: true
↳ @base-ui/react 1.9.0 toggle-group/ToggleGroup.d.ts
✅ Props value sets the initial state; hidden inputs (name) — intentional
Server-rendered Phlex has no controlled state or JS callbacks; listen to clicks on the items or read the hidden inputs.
↳ audit/divergences/toggle_group.yml
✅ Props variant, size, spacing, orientation, type, value, disabled (group and item), loop_focus
rubyui_only: name (hidden inputs).
↳ bases/base/ui/toggle-group.tsx:22-35
✅ ARIA ToggleGroup, ToggleGroupItem: accessibility tree matches
↳ accessibility tree (shadcn-audit collect)
✅ Behavior with a selection, Tab enters on the pressed item (Medium) — intentional
Landing on the current choice tells keyboard users the group's value at once; with no selection both enter on the first item (Left).
↳ audit/divergences/toggle_group.yml
✅ Behavior single: pressing the pressed item clears it (hidden input value empty)
↳ Playwright probe
✅ Behavior multiple: one tab stop per group, ←/→ move focus with wrap, Space toggles
↳ Playwright probe
✅ Behavior horizontal: ←/→ (↓ ignored); vertical: ↑/↓ (←/→ ignored) — follows spec
Diverges from shadcn, follows the APG: a vertical group navigates with Up/Down. Observed with real keyboard focus on both sides.
↳ https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/#keyboardinteraction
✅ Behavior single: arrows move focus only; Space/Enter press; Home/End; wrap; disabled group skipped
↳ Playwright probe
✅ Animation transition color, box-shadow 150ms
↳ computed styles (Playwright probe)
✅ Native Stimulus: roving tabindex, arrows; native <button> activation for Enter/Space
The controller no longer re-implements Enter/Space; the native button fires click.
↳ ../.claude/skills/shadcn-audit/references/native-html.md#when-native-would-lose-behavior-shadcn-provides
✅ Spec role=group + <button aria-pressed> in both modes
The APG radio warning is gone with the radio roles.
↳ https://www.w3.org/TR/wai-aria-1.2/#aria-pressed
🤖 Generated with Claude Code