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

Merged
CI
2d ago
+722-83

What

Brings Sidebar in line with shadcn (Base UI + vega), following the audit in this branch. On desktop it now behaves like shadcn: same Tab order, Ctrl/⌘+B, trigger and rail toggles. The pixel diff against shadcn on the audit fixture went from about 860 px to 15 px per state.

Fixes

  • SidebarMenuItem rendered a <ul>, so screen readers saw every menu item as an empty nested list. It's an <li> now.
  • SidebarRail passed data: {tabindex: "-1"}, which renders data-tabindex, so the rail was a Tab stop with no name. It now has tabindex="-1", aria-label and title, as in shadcn.
  • SidebarMenuAction(show_on_hover: true) did nothing because the kwarg was never assigned.
  • Sidebar(variant: :bogus) raised NoMethodError (missing comma in raise ArgumentError).
  • The outline variant of SidebarMenuButton used hsl(var(--sidebar-border)). With the oklch theme that's invalid CSS, so the button had no ring. It uses var() like vega.

Matching shadcn

  • data-slot="sidebar-*" on every part, keeping data-sidebar. The trigger replaces Button's data-slot="button".
  • SidebarTrigger uses Button size: :icon_sm (32px, was 28px) and hides its icon from assistive tech.
  • Sidebar's class:/attrs go to the fixed container (data-slot="sidebar-container", which now carries data-side), as in shadcn. Note for upgraders: classes passed to a collapsible Sidebar used to land on the outer div.
  • Vega classes: floating ring-1 ring-sidebar-border shadow-sm, inset shadow-sm, and a hidden scrollbar on SidebarContent (scrollbar-width: none, because RubyUI's CSS has no no-scrollbar utility).
  • Open-state styling reads aria-expanded / data-open, which is what RubyUI's DropdownMenu trigger sets, instead of data-state=open.
  • SidebarMenuSubButton defaults to <a>. Note for upgraders: the default used to be <button>.

New

  • SidebarMenuButton(tooltip: "Inbox") shows a tooltip on the right while the desktop sidebar is collapsed.
  • SidebarGroupLabel(as:).
  • Ctrl+B / ⌘+B toggles the sidebar.
  • Each toggle dispatches ruby-ui--sidebar:change with detail.open, the counterpart of shadcn's onOpenChange.
  • Docs: tooltips in the example, plus "Tooltips" and "Keyboard shortcut and state" sections.

Audit

  • Component map with selectors, fixtures, and baseline sidebar 8a353667 for the weekly drift check.
  • Divergences: separator width (shadcn's data-horizontal:w-full beats w-auto and overflows the sidebar border by 8px), and Sidebar(open:) in place of the provider's defaultOpen/open/onOpenChange.
  • dependencies.yml gets a sidebar entry (Button, Input, Separator, Sheet, Skeleton, Tooltip). It had none, so installing the sidebar with the generator installed none of the components it renders.
  • MCP registry rebuilt.

Mobile, after merging #642

The mobile gaps came from Sheet and depended on #642 (branch sheet-match-shadcn). With main merged in, checked in the browser at 375px against the shadcn sandbox: the panel is a modal <dialog> announced as dialog "Sidebar" with its description, focus moves to the first field (the search input, same as shadcn), Esc closes it and focus returns to the trigger. The panel is 288px (18rem) where shadcn renders 281px, because shadcn's Sheet w-3/4 beats the sidebar's width; #642 keeps the sidebar width on purpose.

Merging main also needed two fixes here: SidebarSeparator and the mobile SheetContent now emit only their own data-slot (sidebar-separator, sidebar) instead of joining it with the new separator/sheet-content defaults. Separator is semantic by default since #641, so the separator role=none divergence was dropped: the accessibility tree now matches shadcn on every part.

Follow-ups

  • Tooltip visibility uses body:has([data-slot=sidebar][data-state=expanded]), because the tooltip is cloned into <body>. With two independent SidebarWrappers on one page, an expanded sidebar hides the tooltips of a collapsed one. Fixing it means tying the cloned tooltip back to its sidebar in the Tooltip controller, so it belongs in a separate change.

Screenshots

Crops of the area that changed, at 1.5×: RubyUI on main, this PR, shadcn.

Expanded (light)
Collapsed (light)
Expanded (dark)
Tooltip while collapsed

Audit checklist: main
shadcn audit  ruby-ui/ruby_ui
  base 25ccda6b  head 25ccda6b  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
Sidebar  (ref: bases/base/ui/sidebar.tsx + style-vega.css @ 8a353667)
  ❌ Look       display: block
               vega: display: list-item
               SidebarMenuItem — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Follows from the <ul> tag (HTML line). Requested: SidebarMenuItem renders <li>. Requested: run `baseline sidebar 8a3536678a528cc012b9e62484290e79d0be410f` and commit audit/component_map/sidebar.yml + audit/fixtures/sidebar/ in the fix PR.
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       properties: font-family, height, width
               SidebarWrapper, Sidebar, SidebarGap, SidebarContainer, SidebarInner, SidebarTrigger, SidebarRail, SidebarInset, SidebarInput, SidebarHeader, SidebarFooter, SidebarSeparator, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarGroupAction, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarMenuAction, SidebarMenuBadge, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton — explained by environment diff: font. Checked by hand per element: font-family differs only in the fallback list (both load Inter Variable); every width/height matches except SidebarTrigger (28 vs 32) and SidebarSeparator (239 vs 255), graded on their own lines. Kept as is: font fallback list only; sizes checked per element.
  ⚠️ ARIA       snapshot: only here:   - list: |   - list: |   - list: |   - list: |   - list: | - button |   - button "Toggle Sidebar": |     - img |     - text: Toggle Sidebar
               shadcn: snapshot: only here: - separator |   - listitem: |   - listitem: |   - listitem: |   - listitem: |   - listitem: | - button "Toggle Sidebar" |   - button "Toggle Sidebar"
               SidebarWrapper — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Root causes: SidebarMenuItem renders <ul> (list) not <li> (listitem); SidebarSeparator role=none vs separator; SidebarRail has no name; trigger SVG not aria-hidden (img). Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here:   - list: |   - list: |   - list: |   - list: |   - list: | - button
               shadcn: snapshot: only here: - separator |   - listitem: |   - listitem: |   - listitem: |   - listitem: |   - listitem: | - button "Toggle Sidebar"
               Sidebar — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here:   - list: |   - list: |   - list: |   - list: |   - list: | - button
               shadcn: snapshot: only here: - separator |   - listitem: |   - listitem: |   - listitem: |   - listitem: |   - listitem: | - button "Toggle Sidebar"
               SidebarContainer — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here:   - list: |   - list: |   - list: |   - list: |   - list: | - button
               shadcn: snapshot: only here: - separator |   - listitem: |   - listitem: |   - listitem: |   - listitem: |   - listitem: | - button "Toggle Sidebar"
               SidebarInner — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here: - button "Toggle Sidebar": |   - img |   - text: Toggle Sidebar
               shadcn: snapshot: only here: - button "Toggle Sidebar"
               SidebarTrigger — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. RubyUI's panel-left SVG has no aria-hidden, so it shows as an img inside the button; lucide-react sets aria-hidden="true". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG.
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here: - button
               shadcn: snapshot: only here: - button "Toggle Sidebar"
               SidebarRail — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. RubyUI rail has no aria-label/title (shadcn: aria-label and title "Toggle Sidebar", sidebar.tsx:294). Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar".
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here:   - button "Toggle Sidebar": |     - img |     - text: Toggle Sidebar
               shadcn: snapshot: only here:   - button "Toggle Sidebar"
               SidebarInset — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here:   - list:
               shadcn: snapshot: only here:   - listitem:
               SidebarFooter — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: (none)
               shadcn: snapshot: only here: - separator
               SidebarSeparator — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. RubyUI Separator defaults to decorative role=none; Base UI Separator renders role=separator. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here:   - list: |   - list: |   - list: |   - list:
               shadcn: snapshot: only here:   - listitem: |   - listitem: |   - listitem: |   - listitem:
               SidebarContent — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here:   - list: |   - list: |   - list: |   - list:
               shadcn: snapshot: only here:   - listitem: |   - listitem: |   - listitem: |   - listitem:
               SidebarGroup — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here:   - list: |   - list: |   - list: |   - list:
               shadcn: snapshot: only here:   - listitem: |   - listitem: |   - listitem: |   - listitem:
               SidebarGroupContent — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here:   - list:; snapshot: only here:   - list: |   - list: |   - list: |   - list:
               shadcn: snapshot: only here:   - listitem:; snapshot: only here:   - listitem: |   - listitem: |   - listitem: |   - listitem:
               SidebarMenu — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here: - list:
               shadcn: snapshot: only here: - listitem:
               SidebarMenuItem — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. SidebarMenuItem renders <ul> instead of <li> (see HTML line). Requested: SidebarMenuItem renders <li>.
               ↳ accessibility tree (shadcn-audit collect)
  ✅ ARIA       SidebarGap, SidebarInput, SidebarHeader, SidebarGroupLabel, SidebarGroupAction, SidebarMenuButton, SidebarMenuAction, SidebarMenuBadge, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton: accessibility tree matches
               ↳ accessibility tree (shadcn-audit collect)
  ❌ Look       SidebarTrigger: Button ghost/icon + h-7 w-7 → 28×28
               vega: SidebarTrigger: Button size=icon-sm → size-8 rounded-[min(var(--radius-md),10px)] → 32×32
               SidebarTrigger — width/height 28px vs 32px in every state (collect filed it under the font env ⏸); the Dashboard label shifts 4px left. Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG.
               ↳ bases/base/ui/sidebar.tsx:266 + style-vega.css:193
  ⚠️ Look       SidebarSeparator: mx-2 w-auto → 239px (fits inside the 255px sidebar)
               vega: SidebarSeparator: mx-2 w-auto, but Separator's data-horizontal:w-full wins → 255px, overflowing 8px past the sidebar border
               SidebarSeparator — judgment call: shadcn's width is a cascade side effect (attribute variant beats w-auto), visible as an 8px stub over the border at x=255–263. RubyUI renders what the classes intend. Suggested divergence (Look): keep mx-2 w-auto (239px); shadcn's 255px is a specificity side effect that overflows the border.
               ↳ sandbox DOM; bases/base/ui/separator.tsx:16 + style-vega.css:1142
  ❌ Look       SidebarMenuButton variant: :outline → shadow-[0_0_0_1px_hsl(var(--sidebar-border))] computes to box-shadow: none (hsl() of an oklch token is invalid)
               vega: shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]
               SidebarMenuButton outline — checked in the RubyUI preview: the rule ships but getComputedStyle gives none, so the outline variant has no ring at all (also on hover). Requested: outline variant uses shadow-[0_0_0_1px_var(--sidebar-border)] / hover var(--sidebar-accent).
               ↳ style-vega.css:1178
  ❌ Look       Sidebar variant: :floating inner → rounded-lg border border-sidebar-border shadow
               vega: sidebar-inner floating → rounded-lg ring-1 ring-sidebar-border shadow-sm
               Sidebar floating — from source (not in the fixture): border vs ring-1 (border adds 1px to the box), shadow vs shadow-sm. Requested: align to vega (floating ring-1 ring-sidebar-border shadow-sm; inset shadow-sm; content no-scrollbar).
               ↳ style-vega.css:1114
  ❌ Look       SidebarInset with variant: :inset → md:peer-data-[variant=inset]:shadow
               vega: md:peer-data-[variant=inset]:shadow-sm
               SidebarInset inset variant — from source; margins and rounded-xl already match. Requested: align to vega (floating ring-1 ring-sidebar-border shadow-sm; inset shadow-sm; content no-scrollbar).
               ↳ style-vega.css:1122
  ❌ Look       SidebarContent: overflow-auto (scrollbar visible)
               vega: cn-sidebar-content → no-scrollbar gap-2
               SidebarContent — vega hides the scrollbar of a long sidebar; RubyUI shows it. Requested: align to vega (floating ring-1 ring-sidebar-border shadow-sm; inset shadow-sm; content no-scrollbar).
               ↳ style-vega.css:1134
  ❌ Look       open-state hooks: SidebarMenuButton data-[state=open]:hover:*, SidebarMenuAction(show_on_hover) data-[state=open]:opacity-100
               vega: SidebarMenuButton data-open:hover:* (+ cn-sidebar-menu-button-aria aria-expanded:hover:*), SidebarMenuAction aria-expanded:opacity-100
               Open-state hooks — a RubyUI DropdownMenu trigger inside a menu item sets aria-expanded, not data-state=open, so the hover/visible-while-open styling never fires. From source. Requested: open-state hooks use aria-expanded / data-open like shadcn.
               ↳ style-vega.css:1166 + bases/base/ui/sidebar.tsx:578
  ❌ HTML       SidebarMenuItem → <ul class="group/menu-item relative">
               shadcn: SidebarMenuItem → <li data-slot="sidebar-menu-item">
               SidebarMenuItem — <ul> nested directly in <ul>: lists of one empty list each for assistive tech, and display block instead of list-item. Requested: SidebarMenuItem renders <li>.
               ↳ bases/base/ui/sidebar.tsx:476
  ❌ HTML       SidebarRail → <button data-sidebar="rail" data-tabindex="-1"> (no tabindex, no aria-label, no title)
               shadcn: <button data-slot="sidebar-rail" aria-label="Toggle Sidebar" title="Toggle Sidebar" tabindex="-1">
               SidebarRail — `data: {tabindex: "-1"}` renders data-tabindex, so the rail is a tab stop (Tab order: … Account → rail → trigger) with no name. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar".
               ↳ bases/base/ui/sidebar.tsx:291
  ❌ HTML       no data-slot on any sidebar part (only data-sidebar); SidebarTrigger carries data-slot="button"
               shadcn: every part has data-slot="sidebar-*" plus data-sidebar
               data-slot — shadcn's selectors and user CSS key off data-slot (e.g. has-data-[variant=inset], in-data-[side=left]); RubyUI only emits data-sidebar. Requested: add data-slot="sidebar-*" to every part (keep data-sidebar).
               ↳ bases/base/ui/sidebar.tsx:131
  ❌ HTML       SidebarMenuSubButton default as: :button
               shadcn: SidebarMenuSubButton defaultTagName "a"
               SidebarMenuSubButton — without as: :a a sub-item is a button; the fixture passes as: :a on the RubyUI side. Requested: SidebarMenuSubButton defaults to as: :a.
               ↳ bases/base/ui/sidebar.tsx:684
  ❌ HTML       Sidebar(**attrs) → class/attrs on the outer peer div; MobileSidebar Sheet always rendered too
               shadcn: className/props on sidebar-container (data-side set there); mobile Sheet only when isMobile
               Sidebar — user classes land one level higher than in shadcn (e.g. a custom width or border on Sidebar won't hit the fixed container). The always-present Sheet <template> is the server-rendered equivalent of the isMobile branch: note only. Requested: Sidebar class/attrs go to the sidebar container, with data-side on it.
               ↳ bases/base/ui/sidebar.tsx:230
  ❌ Props      SidebarMenuButton: no tooltip
               shadcn: tooltip?: string | TooltipContent props (shown on the right when collapsed, hidden when expanded or mobile)
               tooltip — collapsed to icons, RubyUI gives the icon buttons no visible label on hover/focus. Requested: add tooltip: to SidebarMenuButton (Tooltip, right side, only when collapsed).
               ↳ bases/base/ui/sidebar.tsx:519
  ❌ Props      SidebarMenuAction(show_on_hover: true) → ignored (@show_on_hover never assigned)
               shadcn: showOnHover → md:opacity-0 + group-hover/group-focus-within opacity-100
               show_on_hover — verified: SidebarMenuAction.new(show_on_hover: true).call has no md:opacity-0; the initializer drops the kwarg. Requested: assign @show_on_hover and add a test.
               ↳ bases/base/ui/sidebar.tsx:578
  ❌ Props      Sidebar(variant: :bogus) → NoMethodError (undefined method 'ArgumentError')
               shadcn: variant: "sidebar" | "floating" | "inset" (typed)
               variant validation — sidebar.rb:11 lacks the comma in `raise ArgumentError "…"`; side/collapsible validate fine. Requested: fix `raise ArgumentError, ...` in sidebar.rb:11 and add a test.
               ↳ bases/base/ui/sidebar.tsx:157
  ❌ Props      SidebarWrapper: no defaultOpen/open/onOpenChange; initial state via Sidebar(open:)
               shadcn: SidebarProvider defaultOpen, open, onOpenChange
               Provider state — Sidebar(open:) covers defaultOpen (rubyui_only); controlled open/onOpenChange are React-only. Candidate for a divergence. Suggested divergence (Props): initial state via Sidebar(open:), no controlled open. Requested: dispatch ruby-ui--sidebar:change {open} on toggle (onOpenChange equivalent).
               ↳ bases/base/ui/sidebar.tsx:56
  ❌ Props      SidebarGroupLabel: no as:
               shadcn: SidebarGroupLabel render (e.g. a CollapsibleTrigger)
               SidebarGroupLabel render — the other render props map to as: (MenuButton, MenuAction, GroupAction, MenuSubButton). Requested: add as: to SidebarGroupLabel.
               ↳ bases/base/ui/sidebar.tsx:404
  ✅ Props      side, variant, collapsible; active/variant/size/as on MenuButton; active/size/as on MenuSubButton; show_icon on MenuSkeleton; as on GroupAction/MenuAction
               rubyui_only: Sidebar open.
               ↳ bases/base/ui/sidebar.tsx
  ✅ Behavior   trigger and rail toggle expanded↔collapsed (data-collapsible=icon), focus stays on the trigger, cookie sidebar_state=false; path=/
               Desktop toggle at 1280×800; collapsed Tab order identical apart from the rail stop (HTML line).
               ↳ sandbox DOM (behavior.json)
  ⚠️ Behavior   Ctrl+B / ⌘+B do nothing
               shadcn: Ctrl+B and ⌘+B toggle the sidebar (window keydown)
               Keyboard shortcut — verified both keys on both sides. Requested: add Ctrl+B / Cmd+B toggle (keydown@window on SidebarWrapper).
               ↳ bases/base/ui/sidebar.tsx:96
  ⚠️ Behavior   375px: trigger opens the Sheet, focus stays on the trigger behind it, Esc does not close it
               shadcn: 375px: Sheet opens, focus moves to the first field inside, Esc closes and returns focus to the trigger
               Mobile — root cause is RubyUI's Sheet (template + insertAdjacentHTML, no dialog semantics or focus management), not the sidebar. Kept as is here: fix belongs to Sheet (dialog semantics, focus, Esc); Sidebar inherits it.
               ↳ sandbox DOM (behavior.json)
  ⚠️ ARIA       mobile sheet panel: <div data-mobile> with no role, no aria-labelledby/describedby
               shadcn: <div role="dialog" aria-labelledby=Sidebar title aria-describedby=description>
               Mobile — inherited from Sheet; the sr-only SheetTitle "Sidebar" exists but nothing points at it. Kept as is here: fix belongs to Sheet (dialog semantics, focus, Esc); Sidebar inherits it.
               ↳ sandbox DOM (behavior.json)
  ✅ Animation  gap: width 0.2s linear; container: left, right, width 0.2s linear
               Computed transitions on both sides.
               ↳ sandbox DOM (behavior.json)
  ⚠️ Native     mobile drawer: Sheet <template> cloned into body, no focus/Esc handling
               shadcn: Base UI Dialog (focus in, Esc, focus return)
               Native — desktop toggle (data-state + cookie) has no native equivalent. The mobile drawer is a <dialog> + showModal() candidate, but that belongs to the Sheet fix. Kept as is here: fix belongs to Sheet (dialog semantics, focus, Esc); Sidebar inherits it.
               ↳ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog
  ❌ Spec       <ul> SidebarMenu > <ul> SidebarMenuItem
               shadcn: <ul> > <li>
               HTML: ul content model is zero or more li and script-supporting elements; RubyUI nests ul directly in ul. Requested: SidebarMenuItem renders <li>.
               ↳ https://html.spec.whatwg.org/multipage/grouping-content.html#the-ul-element
  ❌ Spec       SidebarRail: focusable <button> with no accessible name
               shadcn: tabindex=-1, aria-label="Toggle Sidebar"
               WAI-ARIA 1.2 button: Accessible Name Required = True; the rail is a focusable <button> (in the Tab order) with no aria-label, title or text. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar".
               ↳ https://www.w3.org/TR/wai-aria-1.2/#button
  ⚠️ Spec       SidebarTrigger: no aria-expanded / aria-controls
               shadcn: SidebarTrigger: no aria-expanded / aria-controls either
               APG disclosure recommendation (not normative): the toggle button should expose aria-expanded and aria-controls. RubyUI copies shadcn's gap. Kept as is: same as shadcn (APG recommendation only).
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/#roles_states_properties
Audit checklist: this PR
shadcn audit  ruby-ui/ruby_ui
  base de613350  head e577287e  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
Sidebar  (ref: bases/base/ui/sidebar.tsx + style-vega.css @ 8a353667)
  ⏸ Look       properties: font-family, width
               SidebarWrapper, Sidebar, SidebarGap, SidebarContainer, SidebarInner, SidebarTrigger, SidebarRail, SidebarInset, SidebarInput, SidebarHeader, SidebarFooter, SidebarSeparator, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarGroupAction, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarMenuAction, SidebarMenuBadge, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton — explained by environment diff: font. Checked per element: every width/height matches except SidebarSeparator (239 vs 255px), the registered Look divergence.
  ⚠️ ARIA       snapshot: same lines, different order
               shadcn: snapshot: only here: - separator
               SidebarWrapper — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Only difference left: the separator line inside this subtree (role=none vs separator), registered in audit/divergences/sidebar.yml; collect matches that entry only on the SidebarSeparator part.
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: same lines, different order
               shadcn: snapshot: only here: - separator
               Sidebar — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Only difference left: the separator line inside this subtree (role=none vs separator), registered in audit/divergences/sidebar.yml; collect matches that entry only on the SidebarSeparator part.
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: same lines, different order
               shadcn: snapshot: only here: - separator
               SidebarContainer — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Only difference left: the separator line inside this subtree (role=none vs separator), registered in audit/divergences/sidebar.yml; collect matches that entry only on the SidebarSeparator part.
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: same lines, different order
               shadcn: snapshot: only here: - separator
               SidebarInner — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Only difference left: the separator line inside this subtree (role=none vs separator), registered in audit/divergences/sidebar.yml; collect matches that entry only on the SidebarSeparator part.
               ↳ accessibility tree (shadcn-audit collect)
  ✅ ARIA       snapshot: (none) — intentional
               SidebarSeparator — The line only splits the header input from the menu, with no named sections around it; RubyUI separators are decorative by default
               ↳ audit/divergences/sidebar.yml
  ✅ ARIA       SidebarGap, SidebarTrigger, SidebarRail, SidebarInset, SidebarInput, SidebarHeader, SidebarFooter, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarGroupAction, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarMenuAction, SidebarMenuBadge, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton: accessibility tree matches
               ↳ accessibility tree (shadcn-audit collect)
  ✅ Look       SidebarTrigger: Button size: :icon_sm → 32×32
               Fixed: Button size: :icon_sm → 32×32 (getComputedStyle, after collect).
               ↳ bases/base/ui/sidebar.tsx:266 + style-vega.css:193
  ✅ Look       SidebarSeparator: mx-2 w-auto → 239px (fits inside the 255px sidebar) — intentional
               shadcn's width is a CSS specificity side effect, not a design choice; RubyUI keeps what mx-2 w-auto intend
               ↳ audit/divergences/sidebar.yml
  ✅ Look       shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]
               Fixed: box-shadow computes to oklch(0.922 0 0) 0 0 0 1px in the preview.
               ↳ style-vega.css:1178
  ✅ Look       rounded-lg ring-1 ring-sidebar-border shadow-sm
               Fixed: inner has ring-1 (1px oklch ring), shadow-sm, border 0 (getComputedStyle with data-variant=floating).
               ↳ style-vega.css:1114
  ✅ Look       md:peer-data-[variant=inset]:shadow-sm
               Fixed: /docs/sidebar/inset computes shadow-sm.
               ↳ style-vega.css:1122
  ✅ Look       [scrollbar-width:none] [&::-webkit-scrollbar]:hidden
               Fixed: scrollbar-width: none (+ ::-webkit-scrollbar hidden) instead of shadcn's no-scrollbar utility, which RubyUI's CSS doesn't define.
               ↳ style-vega.css:1134
  ✅ Look       SidebarMenuButton data-open:hover:* + aria-expanded:hover:*, SidebarMenuAction aria-expanded:opacity-100
               Fixed in source: data-open:hover and aria-expanded:hover on the button, aria-expanded:opacity-100 on the action.
               ↳ style-vega.css:1166 + bases/base/ui/sidebar.tsx:578
  ✅ HTML       <li data-slot="sidebar-menu-item">
               Fixed: <li data-slot="sidebar-menu-item">; accessibility tree shows listitems (after collect).
               ↳ bases/base/ui/sidebar.tsx:476
  ✅ HTML       <button aria-label="Toggle Sidebar" title="Toggle Sidebar" tabindex="-1" data-slot="sidebar-rail">
               Fixed: tabindex=-1, aria-label and title; out of the Tab order (behavior.json).
               ↳ bases/base/ui/sidebar.tsx:291
  ✅ HTML       data-slot="sidebar-*" on every part
               Fixed: every part carries data-slot="sidebar-*" plus data-sidebar; the trigger replaces Button's data-slot.
               ↳ bases/base/ui/sidebar.tsx:131
  ✅ HTML       SidebarMenuSubButton default as: :a
               Fixed: default as: :a.
               ↳ bases/base/ui/sidebar.tsx:684
  ✅ HTML       class/attrs on sidebar-container (data-side set)
               Fixed: caller class/attrs land on sidebar-container, which carries data-side. The Sheet <template> stays as the server-side isMobile branch.
               ↳ bases/base/ui/sidebar.tsx:230
  ✅ Props      SidebarMenuButton(tooltip: "…")
               Fixed: tooltip: renders a right-side Tooltip, display:none while expanded and shown when collapsed (checked on /docs/sidebar/example).
               ↳ bases/base/ui/sidebar.tsx:519
  ✅ Props      show_on_hover: true → md:opacity-0 + hover/focus-within reveal
               Fixed and tested: md:opacity-0 + group-hover/focus-within opacity-100.
               ↳ bases/base/ui/sidebar.tsx:578
  ✅ Props      Sidebar(variant: :bogus) → ArgumentError
               Fixed and tested: raises ArgumentError.
               ↳ bases/base/ui/sidebar.tsx:157
  ✅ Props      SidebarWrapper: no defaultOpen/open/onOpenChange; initial state via Sidebar(open:) — intentional
               Server-rendered: the app reads the sidebar_state cookie and passes open:; controlled state is a React pattern, and the change event covers onOpenChange (verified: ruby-ui--sidebar:change fires with detail.open false/true).
               ↳ audit/divergences/sidebar.yml
  ✅ Props      SidebarGroupLabel(as:)
               Fixed and tested: as: (default :div).
               ↳ bases/base/ui/sidebar.tsx:404
  ✅ Props      side, variant, collapsible; active/variant/size/as on MenuButton; active/size/as on MenuSubButton; show_icon on MenuSkeleton; as on GroupAction/MenuAction; tooltip; show_on_hover; as on GroupLabel
               rubyui_only: Sidebar open.
               ↳ bases/base/ui/sidebar.tsx
  ✅ Behavior   trigger and rail toggle expanded↔collapsed (data-collapsible=icon), focus stays on the trigger, cookie sidebar_state=false; path=/
               Desktop toggle at 1280×800; collapsed Tab order identical apart from the rail stop (HTML line).
               ↳ sandbox DOM (behavior.json)
  ✅ Behavior   Ctrl+B and ⌘+B toggle the sidebar
               Fixed: keydown.ctrl+b@window / keydown.meta+b@window on SidebarWrapper toggle it (behavior.json).
               ↳ sandbox DOM (after/behavior.json)
  ⚠️ Behavior   375px: trigger opens the Sheet, focus stays on the trigger behind it, Esc does not close it
               shadcn: 375px: Sheet opens, focus moves to the first field inside, Esc closes and returns focus to the trigger
               Mobile — root cause is RubyUI's Sheet (template + insertAdjacentHTML, no dialog semantics or focus management), not the sidebar. Resolved after merging #642; see "Mobile, after merging #642".
               ↳ sandbox DOM (behavior.json)
  ⚠️ ARIA       mobile sheet panel: <div data-mobile> with no role, no aria-labelledby/describedby
               shadcn: <div role="dialog" aria-labelledby=Sidebar title aria-describedby=description>
               Mobile — inherited from Sheet; the sr-only SheetTitle "Sidebar" exists but nothing points at it. Resolved after merging #642; see "Mobile, after merging #642".
               ↳ sandbox DOM (behavior.json)
  ✅ Animation  gap: width 0.2s linear; container: left, right, width 0.2s linear
               Computed transitions on both sides.
               ↳ sandbox DOM (behavior.json)
  ⚠️ Native     mobile drawer: Sheet <template> cloned into body, no focus/Esc handling
               shadcn: Base UI Dialog (focus in, Esc, focus return)
               Native — desktop toggle (data-state + cookie) has no native equivalent. The mobile drawer is a <dialog> + showModal() candidate, but that belongs to the Sheet fix. Resolved after merging #642; see "Mobile, after merging #642".
               ↳ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog
  ✅ Spec       <ul> > <li>
               Fixed: ul > li.
               ↳ https://html.spec.whatwg.org/multipage/grouping-content.html#the-ul-element
  ✅ Spec       SidebarRail: tabindex=-1, aria-label
               Fixed: tabindex=-1 and aria-label "Toggle Sidebar".
               ↳ https://www.w3.org/TR/wai-aria-1.2/#button
  ⚠️ Spec       SidebarTrigger: no aria-expanded / aria-controls
               shadcn: SidebarTrigger: no aria-expanded / aria-controls either
               APG disclosure recommendation (not normative): the toggle button should expose aria-expanded and aria-controls. RubyUI copies shadcn's gap.
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/#roles_states_properties

Test steps

  1. cd gem && bundle exec rake (613 runs, standardrb clean).
  2. cd docs && bin/rails test (75 runs).
  3. Open /docs/sidebar/example: collapse with the trigger, Ctrl+B or ⌘+B; hover an icon to see its tooltip; Tab through the sidebar (the rail is skipped).
  4. Optional: /shadcn-audit sidebar.

🤖 Generated with Claude Code


Summary by cubic

Aligns the sidebar with shadcn (Base UI + vega). On desktop, tab order, trigger and rail toggles, the Ctrl/⌘+B shortcut, and styling now match shadcn, and the pixel diff on the audit fixture dropped from about 860 px to 15 px per state.

Fixes

  • SidebarMenuItem renders <li>, and every part carries data-slot="sidebar-*".
  • SidebarRail leaves the tab order (tabindex="-1") and gets an accessible name; the trigger hides its icon from assistive tech.
  • show_on_hover is honored, invalid variants raise ArgumentError, and the outline ring uses CSS variables (the old hsl() of an oklch token rendered nothing).
  • SidebarTrigger uses Button size: :icon_sm (32px).

New features and upgrade notes

  • SidebarMenuButton(tooltip:) shows a right-side tooltip while the desktop sidebar is collapsed; SidebarGroupLabel(as:) is new.
  • Ctrl/⌘+B toggles the sidebar; toggling dispatches ruby-ui--sidebar:change with detail.open and writes the sidebar_state cookie, but all three are no-ops without a collapsible sidebar. A non-collapsible sidebar reports data-state="expanded" so tooltips stay hidden.
  • Sidebar classes and attrs now land on the fixed container (data-slot="sidebar-container"), not the outer div.
  • SidebarMenuSubButton now defaults to <a> instead of <button>.
  • The generator now installs Button, Input, Separator, Sheet, Skeleton, and Tooltip when generating a sidebar (it installed none before).
  • Adds an audit component map, fixtures, and a weekly drift baseline. Below 768px the sidebar still opens a Sheet without dialog semantics, which follows from the Sheet component and will land with the Sheet fix.