[Feature] Toggle: match shadcn (Base UI + vega)

Merged
CI
2d ago
+251-14

Brings RubyUI::Toggle in line with shadcn's Base UI Toggle and the vega style. The ToggleGroup PR builds on this one, so every fix from the toggle_group audit that lives in RubyUI::Toggle is here too.

Changes

  • Vega classes: gap-1, px-2.5 on sm, default and lg, hover:bg-muted hover:text-foreground on both variants (the default variant used to fade its text on hover), aria-pressed:bg-muted for the pressed state, dark:aria-invalid:ring-destructive/40, and has-data-[icon=inline-start|end] padding per size, the same convention Button uses.
  • The button now has data-slot="toggle", data-pressed and data-disabled, as in Base UI. data-state is gone, and the controller keeps data-pressed in sync.
  • data-[state=on]:bg-muted stays in Toggle's classes for ToggleGroupItem: in single mode it is still role=radio + aria-checked with data-state, which aria-pressed: doesn't reach. Keeping it in Toggle means an app that regenerates only Toggle keeps its existing ToggleGroup's pressed style.
  • Docs: a With Icon example (aria-label for icon-only, data-icon next to text).
  • Audit: map, fixtures, divergences (span wrapper, onPressedChange as the ruby-ui--toggle:change event, value as the form value) and baseline at shadcn 8a353667. MCP registry rebuilt.

Breaking for custom styles

Classes that targeted data-[state=on] / data-[state=off] on a standalone Toggle need to move to aria-pressed: / aria-[pressed=false]: (or data-pressed: / not-data-pressed:). ToggleGroupItem still renders data-state until the ToggleGroup PR.

Before / after

The "main" row is the audit fixture before this PR; the Underline toggle (with data-icon) was added to the fixture afterwards.

Closed, light
Closed, dark
Focus, light
Hover, light
Hover, dark

shadcn audit 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
Toggle  (ref: bases/base/ui/toggle.tsx + style-vega.css @ 8a353667)
  ❌ Look       color: oklch(0.205 0 0); column-gap: 8px; gap: 8px; padding-left: 6px; padding-left: 8px; padding-right: 6px; padding-right: 8px; row-gap: 8px
               vega: color: oklch(0.145 0 0); column-gap: 4px; gap: 4px; padding-left: 10px; padding-right: 10px; row-gap: 4px
               Toggle — closed/focus, light/dark. Two root causes: (1) gap-2 in RubyUI BASE_CLASSES vs vega .cn-toggle gap-1 (8px vs 4px), all toggles; (2) padding: size default px-2 vs px-2.5 (8px vs 10px), size sm px-1.5 vs px-2.5 (6px vs 10px), lg px-2.5 matches; sm/default/text toggles 4–8px narrower. Pressed toggles [1],[3] light: color oklch(0.205) (data-[state=on]:text-accent-foreground) vs oklch(0.145) inherited foreground; vega only sets aria-pressed:bg-muted (bg identical in neutral, accent = muted). Requested: use gap-1 and px-2.5 for sm/default/lg in toggle.rb; color part via the pressed/hover decision below. Requested: run `baseline toggle 8a3536678a528cc012b9e62484290e79d0be410f` in the fix PR.
               ↳ style-vega.css:1343, style-vega.css:1358-1368
  ⏸ Look       properties: font-family, width
               font-family stack differs only by fallbacks after Inter Variable (both render Inter); width diffs are the padding line above. Kept as is: environment font fallbacks; width resolves with the padding fix.
  ✅ ARIA       Toggle: accessibility tree matches
               ↳ accessibility tree (shadcn-audit collect)
  ❌ Look       hover:text-muted-foreground (default) / hover:bg-accent hover:text-accent-foreground (outline); hover colors: default oklch(0.556) light, oklch(0.708) dark; outline oklch(0.205) light
               vega: hover:bg-muted hover:text-foreground (all variants); hover color oklch(0.145) light, oklch(0.985) dark
               Hover state, measured with Playwright (mouse over each toggle). Default variant text gets lighter on hover in RubyUI (muted-foreground) where shadcn keeps foreground; hovering a pressed toggle keeps accent-foreground in RubyUI. Screens: toggle-hover-*.png (Italic outline hovered). Requested: aria-pressed:bg-muted instead of data-[state=on]:bg-accent/text-accent-foreground; hover:bg-muted hover:text-foreground for every variant (drop outline hover:bg-accent hover:text-accent-foreground).
               ↳ style-vega.css:1343, style-vega.css:1355
  ❌ Look       no dark:aria-invalid:ring-destructive/40 (dark focus ring on invalid: destructive / 0.2)
               vega: dark:aria-invalid:ring-destructive/40 (dark focus ring on invalid: destructive / 0.4)
               Measured: focused aria-invalid toggle, dark theme, box-shadow ring alpha 0.2 vs 0.4. Light matches. Requested: add dark:aria-invalid:ring-destructive/40 to BASE_CLASSES.
               ↳ style-vega.css:1343
  ❌ Look       no has-data-[icon=inline-start]/[icon=inline-end] padding rules
               vega: size default/lg: has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2; sm: pr-1.5/pl-1.5
               From source only: the fixture's icons carry no data-icon, so the rule wasn't exercised in the browser. Same convention RubyUI Button already follows (data-icon="inline-start"). Requested: add has-data-[icon=inline-start/end] padding per size, plus a data-icon toggle in the fixture.
               ↳ style-vega.css:1358-1368
  ❌ HTML       <span class="contents" data-controller="ruby-ui--toggle"><button type="button" aria-pressed data-state="on|off">…</button>[<input type="hidden">]</span>; no data-slot, no data-pressed, no data-disabled
               shadcn: <button type="button" aria-pressed data-slot="toggle" [data-pressed] [data-disabled]>…</button>
               Rendered DOM on both sides. Missing data-slot="toggle" and Base UI state attributes (data-pressed, data-disabled); RubyUI uses data-state=on/off instead, which also drives its pressed style. The display:contents span wrapper (Stimulus controller + optional hidden input for name:) has no shadcn counterpart; it doesn't affect layout or the a11y tree. Requested: add data-slot="toggle", data-pressed and data-disabled (controller keeps data-pressed in sync), drop data-state. Suggested divergence: the display:contents span wrapper (HTML).
               ↳ bases/base/ui/toggle.tsx:37
  ✅ Props      variant, size, pressed, disabled
               pressed and defaultPressed both map to pressed: (server-rendered, no controlled mode). rubyui_only: name, value, unpressed_value (hidden form input; Base UI Toggle doesn't submit forms), wrapper.
               ↳ bases/base/ui/toggle.tsx:29-33
  ❌ Props      no onPressedChange kwarg (controller dispatches ruby-ui--toggle:change with detail.pressed); no ToggleGroup value
               shadcn: onPressedChange, value (Base UI Toggle.Props, @base-ui/react 1.9.0)
               onPressedChange: RubyUI's equivalent is the bubbling ruby-ui--toggle:change DOM event. value: identifies the toggle inside a ToggleGroup in Base UI; RubyUI's value: is the hidden input's form value, a different thing. Suggested divergence: onPressedChange → ruby-ui--toggle:change event; value → RubyUI value: is the form value (Props).
               ↳ .shadcn-audit/sandbox/node_modules/@base-ui/react/toggle (Toggle.Props)
  ✅ Behavior   click, Space and Enter flip aria-pressed; disabled click ignored; Tab order identical
               Driven in headless Chromium 1280×800 on both fixtures: click on first toggle false→true, Space →false, Enter →true; click on disabled toggle stays false.
               ↳ bases/base/ui/toggle.tsx:37
  ✅ Animation  transition: color, box-shadow 0.15s cubic-bezier(0.4,0,0.2,1)
               ↳ style-vega.css:1343
  ✅ Native     native <button type="button"> + aria-pressed; JS only flips state — native
               No native toggle-button element; <input type=checkbox> would change the role. Native disabled used.
               ↳ https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-pressed
  ✅ Spec       button with aria-pressed true/false, label constant across states — follows spec
               Toggle button pattern: role button, aria-pressed, label doesn't change when pressed. Both sides comply (icon-only toggles use aria-label).
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/button/#wai-ariaroles,states,andproperties
shadcn audit on this PR
shadcn audit  ruby-ui/ruby_ui
  base 1998c5e8  head 4cb15704  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
Toggle  (ref: bases/base/ui/toggle.tsx + style-vega.css @ 8a353667)
  ⏸ Look       properties: font-family
               Font stack differs only in fallbacks after Inter Variable (both render Inter); no width differences remain.
  ✅ ARIA       Toggle: accessibility tree matches
               ↳ accessibility tree (shadcn-audit collect)
  ✅ Look       gap-1; px-2.5 (sm/default/lg); aria-pressed:bg-muted; hover:bg-muted hover:text-foreground; dark:aria-invalid:ring-destructive/40; has-data-[icon=inline-start|end] padding
               Computed styles equal on all 10 toggles in closed/focus, light/dark (collect), plus hover on every toggle and the focused invalid toggle in dark (Playwright). The data-icon toggle (Underline) gets pl-2 on both sides.
               ↳ style-vega.css:1342-1368
  ✅ HTML       <button type=button aria-pressed data-slot=toggle [data-pressed] [data-disabled]> inside <span class=contents> (controller + hidden input) — intentional
               Button attributes match Base UI; data-state removed. The display:contents wrapper is a registered divergence.
               ↳ audit/divergences/toggle.yml
  ✅ Props      variant, size, pressed, disabled
               rubyui_only: name, value, unpressed_value, wrapper.
               ↳ bases/base/ui/toggle.tsx:29-33
  ✅ Props      ruby-ui--toggle:change event; value: is the form value — intentional
               Registered divergences.
               ↳ audit/divergences/toggle.yml
  ✅ Behavior   click, Space and Enter flip aria-pressed and data-pressed; disabled click ignored; Tab order identical
               Driven in headless Chromium 1280×800 on both fixtures.
               ↳ bases/base/ui/toggle.tsx:37
  ✅ Animation  transition: color, box-shadow 0.15s
               ↳ style-vega.css:1343
  ✅ Native     native <button type="button"> + aria-pressed; JS only flips state — native
               No native toggle-button element; <input type=checkbox> would change the role. Native disabled used.
               ↳ https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-pressed
  ✅ Spec       button with aria-pressed true/false, label constant across states — follows spec
               Toggle button pattern: role button, aria-pressed, label doesn't change when pressed. Both sides comply (icon-only toggles use aria-label).
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/button/#wai-ariaroles,states,andproperties

Test steps

  • cd gem && bundle exec rake (Toggle and ToggleGroup tests, StandardRB)
  • cd audit && bundle exec rake; cd mcp && bundle exec rake test; cd docs && bin/rails test
  • /docs/toggle: click and press Space/Enter on a toggle; aria-pressed and data-pressed flip together and the pressed background is bg-muted. Hover keeps the text at the foreground color. On /docs/toggle_group, the pressed item in a single group keeps its background.

Follow-ups

  • The Stimulus controller has no JS test (the gem has no JS test runner); the click/Space/Enter cycle was checked in the browser during the audit.
  • ToggleGroup PR: move single mode to aria-pressed, then drop data-[state=on]:bg-muted from Toggle.

🤖 Generated with Claude Code


Summary by cubic

Brings RubyUI::Toggle in line with shadcn's Base UI Toggle and the vega style. The button now uses gap-1 and px-2.5 on sm/default/lg sizes, aria-pressed:bg-muted for the pressed state, hover:bg-muted hover:text-foreground on both variants (the default variant no longer fades its text on hover), dark:aria-invalid:ring-destructive/40, and has-data-[icon=inline-start|end] padding per size. It also carries data-slot="toggle", data-pressed, and data-disabled instead of data-state, with the Stimulus controller keeping data-pressed in sync. data-[state=on]:bg-muted stays in place so ToggleGroupItem in single mode keeps its pressed background. Adds audit fixtures, component map, divergences, a With Icon docs example, and rebuilds the MCP registry.

Breaking for custom styles

  • Classes targeting data-[state=on] or data-[state=off] on a standalone Toggle must move to aria-pressed: / aria-[pressed=false]: (or data-pressed: / not-data-pressed:). ToggleGroupItem still renders data-state until the ToggleGroup PR.