ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

Sidebar

Composable, collapsible sidebar — icon rail on desktop, off-canvas on mobile, with menu primitives.

Installation

bin/rails g wabi:add sidebar

Example

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.

Main content area.

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

Main content (floating).

Inset

Main content (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
# 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.