Sidebar
Composable, collapsible sidebar — icon rail on desktop, off-canvas on mobile, with menu primitives.
Installation
bin/rails g wabi:add sidebarExample
Click the trigger to collapse the rail to icons (hover an icon for its label). You can also click the thin rail on the sidebar's right edge to toggle it. When collapsed, hovering a group with a submenu (like Projects) pops it out as a flyout. On narrow screens it becomes an off-canvas panel.
Shell variants
Pass variant: to SidebarProvider: :sidebar (default), :floating (the rail becomes a detached card), or :inset (the main content, wrapped in SidebarInset, floats as a rounded card over a sidebar-colored background).
render Components::UI::SidebarProvider.new(variant: :inset) do
render Components::UI::Sidebar.new do
# … header / content / footer …
end
render Components::UI::SidebarInset.new do
# … your page; include a SidebarTrigger somewhere …
end
end
Floating
Inset
Source
app/components/ui/sidebar_provider.rb
# frozen_string_literal: true
module Components
module UI
class SidebarProvider < Wabi::Base
variants do
base "group/sidebar flex min-h-svh w-full"
end
def initialize(variant: :sidebar, default_collapsed: false, persist_key: "wabi-sidebar", **attrs)
@variant = variant
@default_collapsed = default_collapsed
@persist_key = persist_key
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
**@attrs,
data: {
controller: "wabi--sidebar",
"wabi--sidebar-default-collapsed-value": @default_collapsed.to_s,
"wabi--sidebar-persist-key-value": @persist_key,
},
"data-state": @default_collapsed ? "collapsed" : "expanded",
"data-mobile": "closed",
"data-variant": @variant.to_s,
class: merge_class(tokens, (@variant == :inset ? "bg-sidebar" : nil), user_class)
) do
div(
"aria-hidden": "true",
data: {
"wabi--sidebar-target": "backdrop",
action: "click->wabi--sidebar#closeMobile",
},
class: "fixed inset-0 z-40 bg-black/50 hidden lg:hidden " \
"group-data-[mobile=open]/sidebar:block"
)
yield if block
end
end
end
end
end
app/components/ui/sidebar.rb
# frozen_string_literal: true
module Components
module UI
class Sidebar < Wabi::Base
BASE = "flex flex-col bg-sidebar text-sidebar-foreground overflow-hidden " \
"fixed inset-y-0 z-50 w-64 transition-transform duration-200 ease-in-out motion-reduce:transition-none " \
"group-data-[mobile=open]/sidebar:translate-x-0 " \
"lg:sticky lg:top-0 lg:z-auto lg:h-svh lg:translate-x-0 lg:transition-[width] lg:motion-reduce:transition-none " \
"lg:w-64 group-data-[state=collapsed]/sidebar:lg:w-[3.25rem] " \
"group-data-[variant=floating]/sidebar:m-2 " \
"group-data-[variant=floating]/sidebar:h-[calc(100svh-1rem)] " \
"group-data-[variant=floating]/sidebar:rounded-lg " \
"group-data-[variant=floating]/sidebar:border " \
"group-data-[variant=floating]/sidebar:border-sidebar-border " \
"group-data-[variant=floating]/sidebar:shadow-lg " \
"group-data-[variant=inset]/sidebar:border-0 " \
"group-data-[variant=inset]/sidebar:bg-transparent"
SIDE = {
left: "left-0 border-r border-sidebar-border -translate-x-full",
right: "right-0 border-l border-sidebar-border translate-x-full",
}.freeze
def initialize(side: :left, **attrs)
@side = side
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
# Default accessible name: the panel is a complementary landmark on
# desktop and becomes role="dialog" on mobile (set by the controller),
# where it needs a name. Callers can override via aria-label:.
aria_label = @attrs.delete(:"aria-label") || "Sidebar"
aside(
**@attrs,
"aria-label": aria_label,
data: { "wabi--sidebar-target": "panel" },
tabindex: -1,
class: merge_class(BASE, SIDE.fetch(@side, SIDE[:left]), user_class)
) do
yield if block
end
end
end
end
end
app/components/ui/sidebar_header.rb
# frozen_string_literal: true
module Components
module UI
class SidebarHeader < Wabi::Base
variants { base "flex flex-col gap-2 p-2" }
def initialize(**attrs) = @attrs = attrs
def view_template(&block)
user_class = @attrs.delete(:class)
div(**@attrs, class: merge_class(tokens, user_class)) { yield if block }
end
end
end
end
app/components/ui/sidebar_content.rb
# frozen_string_literal: true
module Components
module UI
class SidebarContent < Wabi::Base
variants { base "flex flex-1 min-h-0 flex-col gap-2 overflow-auto p-2" }
def initialize(**attrs) = @attrs = attrs
def view_template(&block)
user_class = @attrs.delete(:class)
div(**@attrs, class: merge_class(tokens, user_class)) { yield if block }
end
end
end
end
app/components/ui/sidebar_footer.rb
# frozen_string_literal: true
module Components
module UI
class SidebarFooter < Wabi::Base
variants { base "flex flex-col gap-2 p-2" }
def initialize(**attrs) = @attrs = attrs
def view_template(&block)
user_class = @attrs.delete(:class)
div(**@attrs, class: merge_class(tokens, user_class)) { yield if block }
end
end
end
end
app/components/ui/sidebar_group.rb
# frozen_string_literal: true
module Components
module UI
class SidebarGroup < Wabi::Base
variants { base "relative flex w-full min-w-0 flex-col p-2" }
SUMMARY = "flex h-8 shrink-0 cursor-pointer select-none items-center px-2 text-xs font-medium " \
"text-muted-foreground list-none [&::-webkit-details-marker]:hidden " \
"transition-[opacity,height] duration-200 motion-reduce:transition-none " \
"group-data-[state=collapsed]/sidebar:h-0 " \
"group-data-[state=collapsed]/sidebar:opacity-0 " \
"group-data-[state=collapsed]/sidebar:overflow-hidden"
def initialize(collapsible: false, label: nil, default_open: true, **attrs)
@collapsible = collapsible
@label = label
@default_open = default_open
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
unless @collapsible
# role="group" lets screen readers identify this as a named region when
# callers pair it with aria-labelledby pointing to their SidebarGroupLabel id.
return div(role: "group", **@attrs, class: merge_class(tokens, user_class)) { yield if block }
end
details(**@attrs, open: (@default_open ? true : nil),
class: merge_class(tokens, "group/collapsible-group wabi-collapsible", user_class)) do
summary(class: SUMMARY) do
span { @label }
raw(safe(chevron))
end
yield if block
end
end
private
def chevron
%(<svg class="ml-auto h-3.5 w-3.5 shrink-0 transition-transform duration-200 motion-reduce:transition-none group-[[open]]/collapsible-group:rotate-90" ) +
%(xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ) +
%(stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>)
end
end
end
end
app/components/ui/sidebar_group_label.rb
# frozen_string_literal: true
module Components
module UI
class SidebarGroupLabel < Wabi::Base
variants do
base "flex h-8 shrink-0 items-center px-2 text-xs font-medium text-muted-foreground " \
"transition-[opacity,height] duration-200 motion-reduce:transition-none " \
"group-data-[state=collapsed]/sidebar:h-0 " \
"group-data-[state=collapsed]/sidebar:opacity-0 " \
"group-data-[state=collapsed]/sidebar:overflow-hidden"
end
def initialize(**attrs) = @attrs = attrs
def view_template(&block)
user_class = @attrs.delete(:class)
div(**@attrs, class: merge_class(tokens, user_class)) { yield if block }
end
end
end
end
app/components/ui/sidebar_input.rb
# frozen_string_literal: true
module Components
module UI
class SidebarInput < Wabi::Base
variants do
base "h-8 w-full rounded-md border border-sidebar-border bg-background px-2 text-sm shadow-none " \
"outline-none placeholder:text-muted-foreground " \
"focus-visible:ring-2 focus-visible:ring-sidebar-ring " \
"group-data-[state=collapsed]/sidebar:hidden"
end
# aria_label: accessible name for the input (required by WCAG 1.3.1/2.4.6 when no visible
# <label> is present). Defaults to "Search" matching the default type: "search".
# Pass aria_label: nil if the input is already labelled by an associated <label> element.
def initialize(type: "search", aria_label: "Search", **attrs)
@type = type
@aria_label = aria_label
@attrs = attrs
end
def view_template
user_class = @attrs.delete(:class)
input(type: @type, "aria-label": @aria_label, **@attrs, class: merge_class(tokens, user_class))
end
end
end
end
app/components/ui/sidebar_inset.rb
# frozen_string_literal: true
module Components
module UI
class SidebarInset < Wabi::Base
variants do
base "flex grow flex-col min-w-0 " \
"group-data-[variant=inset]/sidebar:m-2 " \
"group-data-[variant=inset]/sidebar:rounded-xl " \
"group-data-[variant=inset]/sidebar:border " \
"group-data-[variant=inset]/sidebar:border-sidebar-border " \
"group-data-[variant=inset]/sidebar:bg-background " \
"group-data-[variant=inset]/sidebar:shadow-sm"
end
def initialize(**attrs) = @attrs = attrs
def view_template(&block)
user_class = @attrs.delete(:class)
main(**@attrs, class: merge_class(tokens, user_class)) { yield if block }
end
end
end
end
app/components/ui/sidebar_rail.rb
# frozen_string_literal: true
module Components
module UI
class SidebarRail < Wabi::Base
BASE = "absolute inset-y-0 z-20 hidden w-4 lg:flex items-center justify-center outline-none group/rail " \
"after:absolute after:inset-y-0 after:w-px after:bg-sidebar-border after:transition-colors " \
"hover:after:bg-sidebar-ring focus-visible:after:bg-sidebar-ring"
SIDE = {
left: "right-0 cursor-w-resize after:right-0",
right: "left-0 cursor-e-resize after:left-0",
}.freeze
def initialize(side: :left, **attrs)
@side = side
@attrs = attrs
end
def view_template
user_class = @attrs.delete(:class)
user_data = @attrs.delete(:data) || {}
button(
type: "button",
"aria-label": "Toggle sidebar",
tabindex: -1,
**@attrs,
data: { **user_data, action: "wabi--sidebar#toggle" },
class: merge_class(BASE, SIDE.fetch(@side, SIDE[:left]), user_class)
)
end
end
end
end
app/components/ui/sidebar_trigger.rb
# frozen_string_literal: true
module Components
module UI
class SidebarTrigger < Wabi::Base
variants do
base "inline-flex h-9 w-9 items-center justify-center rounded-md text-sidebar-foreground " \
"transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground " \
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
end
def initialize(**attrs) = @attrs = attrs
def view_template(&block)
user_class = @attrs.delete(:class)
button(
type: "button",
"aria-label": "Toggle sidebar",
data: { action: "wabi--sidebar#toggle", "wabi--sidebar-target": "trigger" },
class: merge_class(tokens, user_class)
) do
if block
yield
else
raw(safe(<<~SVG))
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>
SVG
end
end
end
end
end
end
app/components/ui/sidebar_menu.rb
# frozen_string_literal: true
module Components
module UI
class SidebarMenu < Wabi::Base
variants { base "flex w-full min-w-0 flex-col gap-1" }
def initialize(**attrs) = @attrs = attrs
def view_template(&block)
user_class = @attrs.delete(:class)
ul(**@attrs, class: merge_class(tokens, user_class)) { yield if block }
end
end
end
end
app/components/ui/sidebar_menu_item.rb
# frozen_string_literal: true
module Components
module UI
class SidebarMenuItem < Wabi::Base
variants { base "group/menu-item relative" }
def initialize(**attrs) = @attrs = attrs
def view_template(&block)
user_class = @attrs.delete(:class)
li(**@attrs, class: merge_class(tokens, user_class)) { yield if block }
end
end
end
end
app/components/ui/sidebar_menu_button.rb
# frozen_string_literal: true
module Components
module UI
class SidebarMenuButton < Wabi::Base
variants do
base "flex w-full items-center gap-2 overflow-hidden rounded-md px-2 py-1.5 text-left " \
"text-sm text-sidebar-foreground outline-none transition-colors " \
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground " \
"focus-visible:ring-2 focus-visible:ring-sidebar-ring " \
"disabled:pointer-events-none disabled:opacity-50 " \
"aria-[current=page]:bg-sidebar-accent aria-[current=page]:text-sidebar-accent-foreground " \
"aria-[current=page]:font-medium " \
"group-data-[state=collapsed]/sidebar:justify-center " \
"group-data-[state=collapsed]/sidebar:[&>span]:hidden"
end
def initialize(href: nil, active: false, tooltip: nil, **attrs)
@href = href
@active = active
@tooltip = tooltip
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
klass = merge_class(tokens, user_class)
return render_button(klass, &block) unless @tooltip
# Hand-rolled wabi--tooltip wrapper (Tooltip.new forces inline-block and
# ignores attrs). The menu button element itself is the trigger (no nested
# interactive elements). The bubble shows only when the sidebar is collapsed.
tip = @tooltip
div(
class: "w-full",
data: {
controller: "wabi--tooltip",
"wabi--tooltip-open-delay-value": "0",
"wabi--tooltip-close-delay-value": "0",
"wabi--tooltip-portal-value": "true",
}
) do
render_button(klass, trigger: true, &block)
# The tooltip is a collapsed-only label. Its content portals to <body>
# (escaping group/sidebar), so we gate on the <html data-wabi-sidebar>
# marker the controller mirrors — hidden whenever the sidebar is expanded.
render Components::UI::TooltipContent.new(
class: "[[data-wabi-sidebar=expanded]_&]:hidden"
) { tip }
end
end
private
def render_button(klass, trigger: false, &block)
user_data = @attrs.delete(:data) || {}
data = trigger ? { **user_data, "wabi--tooltip-target": "trigger" } : user_data
common = { **@attrs, "aria-current": (@active ? "page" : nil), data: data, class: klass }
if @href
a(href: @href, **common) { yield if block }
else
button(type: "button", **common) { yield if block }
end
end
end
end
end
app/components/ui/sidebar_menu_action.rb
# frozen_string_literal: true
module Components
module UI
class SidebarMenuAction < Wabi::Base
variants do
base "absolute right-1 top-1.5 flex h-6 w-6 items-center justify-center rounded-md " \
"text-sidebar-foreground outline-none transition-opacity opacity-0 " \
"group-hover/menu-item:opacity-100 focus-visible:opacity-100 " \
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground " \
"focus-visible:ring-2 focus-visible:ring-sidebar-ring " \
"group-data-[state=collapsed]/sidebar:hidden"
end
# aria_label: accessible name for this icon-only action button.
# Callers should always provide a meaningful label (e.g. aria_label: "More options").
def initialize(aria_label: nil, **attrs)
@aria_label = aria_label
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
button(type: "button", "aria-label": @aria_label, **@attrs, class: merge_class(tokens, user_class)) { yield if block }
end
end
end
end
app/components/ui/sidebar_menu_badge.rb
# frozen_string_literal: true
module Components
module UI
class SidebarMenuBadge < Wabi::Base
variants do
base "pointer-events-none absolute right-2 top-1/2 flex h-5 min-w-5 -translate-y-1/2 " \
"select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums " \
"text-sidebar-foreground " \
"group-data-[state=collapsed]/sidebar:hidden"
end
def initialize(**attrs) = @attrs = attrs
def view_template(&block)
user_class = @attrs.delete(:class)
span(**@attrs, class: merge_class(tokens, user_class)) { yield if block }
end
end
end
end
app/components/ui/sidebar_menu_skeleton.rb
# frozen_string_literal: true
module Components
module UI
class SidebarMenuSkeleton < Wabi::Base
variants { base "flex h-8 items-center gap-2 rounded-md px-2" }
def initialize(show_icon: true, **attrs)
@show_icon = show_icon
@attrs = attrs
end
def view_template
user_class = @attrs.delete(:class)
div(**@attrs, class: merge_class(tokens, user_class)) do
div(class: "size-4 shrink-0 animate-pulse motion-reduce:animate-none rounded-md bg-sidebar-accent") if @show_icon
div(class: "bar h-4 max-w-[70%] flex-1 animate-pulse motion-reduce:animate-none rounded-md bg-sidebar-accent " \
"group-data-[state=collapsed]/sidebar:hidden")
end
end
end
end
end
app/components/ui/sidebar_menu_collapsible.rb
# frozen_string_literal: true
module Components
module UI
class SidebarMenuCollapsible < Wabi::Base
SUMMARY = "flex w-full items-center gap-2 overflow-hidden rounded-md px-2 py-1.5 text-left " \
"text-sm text-sidebar-foreground outline-none transition-colors cursor-pointer select-none " \
"list-none [&::-webkit-details-marker]:hidden " \
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground " \
"focus-visible:ring-2 focus-visible:ring-sidebar-ring " \
"group-data-[state=collapsed]/sidebar:justify-center " \
"group-data-[state=collapsed]/sidebar:[&>span]:hidden " \
"group-data-[state=collapsed]/sidebar:[&>.chevron]:hidden"
def initialize(label:, icon: nil, default_open: false, **attrs)
@label = label
@icon = icon
@default_open = default_open
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
user_data = @attrs.delete(:data) || {}
details(**@attrs, open: (@default_open ? true : nil),
data: { **user_data, controller: "wabi--sidebar-flyout" },
class: merge_class("group/collapsible wabi-collapsible", user_class)) do
summary("aria-haspopup": "true", class: SUMMARY) do
raw(safe(@icon)) if @icon
span { @label }
raw(safe(chevron))
end
yield if block
end
end
private
def chevron
%(<svg class="chevron ml-auto h-4 w-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none group-[[open]]/collapsible:rotate-90" ) +
%(xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ) +
%(stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>)
end
end
end
end
app/components/ui/sidebar_menu_sub.rb
# frozen_string_literal: true
module Components
module UI
class SidebarMenuSub < Wabi::Base
variants do
base "ml-3.5 flex min-w-0 flex-col gap-1 border-l border-sidebar-border px-2.5 py-1 " \
"group-data-[state=collapsed]/sidebar:hidden " \
"data-[flyout=open]:!flex data-[flyout=open]:!fixed data-[flyout=open]:z-50 " \
"data-[flyout=open]:min-w-48 data-[flyout=open]:ml-0 data-[flyout=open]:border-l-0 " \
"data-[flyout=open]:rounded-md data-[flyout=open]:border data-[flyout=open]:border-sidebar-border " \
"data-[flyout=open]:bg-sidebar data-[flyout=open]:p-1 data-[flyout=open]:shadow-lg"
end
def initialize(**attrs) = @attrs = attrs
def view_template(&block)
user_class = @attrs.delete(:class)
ul(**@attrs, class: merge_class(tokens, user_class)) { yield if block }
end
end
end
end
app/components/ui/sidebar_menu_sub_item.rb
# frozen_string_literal: true
module Components
module UI
class SidebarMenuSubItem < Wabi::Base
variants do
base "relative"
end
def initialize(**attrs) = @attrs = attrs
def view_template(&block)
user_class = @attrs.delete(:class)
li(**@attrs, class: merge_class(tokens, user_class)) { yield if block }
end
end
end
end
app/components/ui/sidebar_menu_sub_button.rb
# frozen_string_literal: true
module Components
module UI
class SidebarMenuSubButton < Wabi::Base
variants do
base "flex h-7 w-full min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 text-sm " \
"text-sidebar-foreground outline-none transition-colors " \
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground " \
"focus-visible:ring-2 focus-visible:ring-sidebar-ring " \
"aria-[current=page]:bg-sidebar-accent aria-[current=page]:text-sidebar-accent-foreground " \
"aria-[current=page]:font-medium"
end
def initialize(href: nil, active: false, **attrs)
@href = href
@active = active
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
klass = merge_class(tokens, user_class)
if @href
a(href: @href, **@attrs, "aria-current": (@active ? "page" : nil), class: klass) { yield if block }
else
button(type: "button", **@attrs, "aria-current": (@active ? "page" : nil), class: klass) { yield if block }
end
end
end
end
end
app/javascript/controllers/wabi/sidebar_controller.js
import { Controller } from "@hotwired/stimulus"
let panelUid = 0
export default class extends Controller {
static targets = ["panel", "backdrop", "trigger"]
static values = {
defaultCollapsed: { type: Boolean, default: false },
persistKey: { type: String, default: "wabi-sidebar" },
}
connect() {
const stored = localStorage.getItem(this.persistKeyValue)
const collapsed = stored === null ? this.defaultCollapsedValue : stored === "true"
this.element.dataset.state = collapsed ? "collapsed" : "expanded"
this.#syncGlobalState()
// Give the panel a stable id so triggers can point aria-controls at it.
if (this.hasPanelTarget && !this.panelTarget.id) this.panelTarget.id = `wabi-sidebar-${++panelUid}`
// On mobile the panel starts off-canvas (data-mobile=closed); mark it inert so its
// contents are not keyboard-reachable until the user opens it.
if (!this.isDesktop() && this.hasPanelTarget && this.element.dataset.mobile !== "open") {
this.panelTarget.setAttribute("inert", "")
}
this.#syncTriggers()
this._onKeydown = (e) => {
if (e.key === "Escape" && this.element.dataset.mobile === "open") this.closeMobile()
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
if (this.#isEditable(e.target)) return // don't steal Cmd/Ctrl+B from text fields (bold)
e.preventDefault()
this.toggle()
}
}
document.addEventListener("keydown", this._onKeydown)
}
disconnect() {
document.removeEventListener("keydown", this._onKeydown)
}
isDesktop() {
return window.matchMedia("(min-width: 1024px)").matches
}
toggle() {
if (this.isDesktop()) {
const collapsed = this.element.dataset.state !== "collapsed"
this.element.dataset.state = collapsed ? "collapsed" : "expanded"
this.#syncGlobalState()
this.#syncTriggers()
localStorage.setItem(this.persistKeyValue, String(collapsed))
this.dispatch("change", { detail: { state: this.element.dataset.state, mobile: this.element.dataset.mobile } })
} else {
this.element.dataset.mobile === "open" ? this.closeMobile() : this.openMobile()
}
}
openMobile() {
this._triggerEl = document.activeElement // remember opener to restore focus on close
this.element.dataset.mobile = "open"
this.#setInert(true)
// Off-canvas panel is a modal on mobile: announce it as such to AT.
if (this.hasPanelTarget) {
this.panelTarget.setAttribute("role", "dialog")
this.panelTarget.setAttribute("aria-modal", "true")
this.panelTarget.focus() // hasPanelTarget guard: this.panelTarget throws when absent.
}
this.#syncTriggers()
this.dispatch("change", { detail: { state: this.element.dataset.state, mobile: "open" } })
}
closeMobile() {
this.element.dataset.mobile = "closed"
this.#setInert(false)
if (this.hasPanelTarget) {
this.panelTarget.removeAttribute("role")
this.panelTarget.removeAttribute("aria-modal")
}
this.#syncTriggers()
this._triggerEl?.focus() // return focus to the trigger (WCAG 2.4.3)
this._triggerEl = null
this.dispatch("change", { detail: { state: this.element.dataset.state, mobile: "closed" } })
}
// Mirror collapse state to <html> (like the theme controller mirrors data-mode)
// so collapsed-only menu-button tooltips can gate on it even after their content
// portals out to <body> (escaping the sidebar's own group/sidebar scope).
#syncGlobalState() {
document.documentElement.setAttribute("data-wabi-sidebar", this.element.dataset.state)
}
// Reflect the current open/collapsed state onto every trigger button so AT
// announces it. Desktop: expanded vs collapsed rail. Mobile: off-canvas open.
#syncTriggers() {
if (!this.hasTriggerTarget) return
const expanded = this.isDesktop()
? this.element.dataset.state === "expanded"
: this.element.dataset.mobile === "open"
for (const t of this.triggerTargets) {
t.setAttribute("aria-expanded", String(expanded))
if (this.hasPanelTarget && this.panelTarget.id) t.setAttribute("aria-controls", this.panelTarget.id)
}
}
#isEditable(el) {
if (!el || !el.tagName) return false
return el.isContentEditable ||
el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT"
}
#setInert(on) {
const skip = new Set([this.hasPanelTarget && this.panelTarget, this.hasBackdropTarget && this.backdropTarget].filter(Boolean))
for (const child of this.element.children) {
if (skip.has(child)) continue
if (on) child.setAttribute("inert", "")
else child.removeAttribute("inert")
}
// On mobile, also inert the panel when it is off-canvas so its contents are
// not keyboard-reachable while hidden (WCAG 2.1 — keyboard trap / focus management).
if (!this.isDesktop() && this.hasPanelTarget) {
if (on) this.panelTarget.removeAttribute("inert") // panel is open → remove inert
else this.panelTarget.setAttribute("inert", "") // panel is closed → add inert
}
}
}
Accessibility
- Menu items are real <a>/<button> elements; the active item carries aria-current="page".
- Collapsed (icon) mode shows each item's label via a tooltip, so the icon-only rail stays labelled.
- The trigger reflects its state with aria-expanded and points aria-controls at the panel.
- On mobile the panel is off-canvas: it becomes role="dialog" aria-modal="true", focus moves to it on open, the rest of the page is inert, and Escape (or a backdrop click) closes it.
- Cmd/Ctrl+B toggles the sidebar — but the shortcut is ignored while focus is in an input, textarea, select, or contenteditable.
- Collapse state persists across visits in localStorage.