From 8f543ea9634af09b7b8c710074eff75f526e3c28 Mon Sep 17 00:00:00 2001 From: Kyle Foster Date: Thu, 26 Mar 2026 17:56:01 -0400 Subject: [PATCH 1/6] feat(sidebar): refactor sidebar to match Stratus feature set --- .../src/components/demos/SidebarDemo.tsx | 213 ++++- .../src/pages/components/sidebar.astro | 61 ++ packages/kumo/src/components/sidebar/index.ts | 5 + .../src/components/sidebar/sidebar.test.tsx | 765 ++++++++++++++++- .../kumo/src/components/sidebar/sidebar.tsx | 812 +++++++++++++++--- packages/kumo/src/index.ts | 5 + 6 files changed, 1714 insertions(+), 147 deletions(-) diff --git a/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx b/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx index 2f267480d3..160a19cccb 100644 --- a/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx @@ -1,4 +1,4 @@ -import { Sidebar, useSidebar, DropdownMenu } from "@cloudflare/kumo"; +import { Sidebar, useSidebar, DropdownMenu, type SidebarState } from "@cloudflare/kumo"; import { HouseIcon, GlobeIcon, @@ -14,6 +14,8 @@ import { CheckIcon, RocketIcon, FlaskIcon, + UserIcon, + ArrowsLeftRightIcon, } from "@phosphor-icons/react"; import { useState } from "react"; @@ -39,7 +41,7 @@ function DemoMain({ children }: { children?: React.ReactNode }) { function BrandLogo() { return ( -
+
Acme Inc @@ -63,7 +65,7 @@ function AccountSwitcher() { render={ + + + + + + + Account + + + Home + + + Members + + + Analytics + + + Settings + + + + + + + + + + Zone + + + Overview + + + Security + + + SSL/TLS + + + Caching + + + + + + + + +
+

+ Active: {surface === "account" ? "Account" : "Zone"} surface +

+

+ Click the header button to slide between views +

+
+
+ + + ); +} diff --git a/packages/kumo-docs-astro/src/pages/components/sidebar.astro b/packages/kumo-docs-astro/src/pages/components/sidebar.astro index 9891463d62..e144fc7c8c 100644 --- a/packages/kumo-docs-astro/src/pages/components/sidebar.astro +++ b/packages/kumo-docs-astro/src/pages/components/sidebar.astro @@ -12,6 +12,9 @@ import { SidebarFullDemo, SidebarResizableDemo, SidebarRightDemo, + SidebarPeekingDemo, + SidebarKeyboardShortcutDemo, + SidebarSlidingViewsDemo, } from "../../components/demos/SidebarDemo"; --- @@ -229,6 +232,64 @@ const { toggleSidebar } = useSidebar();`}>
+ +
+ Peeking +

+ Set peekable on the Provider. When the sidebar is collapsed, + hovering or focusing it temporarily expands it. Moving away collapses it back. + The data-state attribute will be "peeking" during the peek. +

+ + + ... + + + + + + +// Read peeking state: +const { state, isPeeking } = useSidebar(); +// state: "expanded" | "collapsed" | "peeking"`}> + + +
+ +
+ Keyboard Shortcut +

+ Pass keyboardShortcut to the Provider to toggle the sidebar + with a keyboard shortcut. Use "mod+b" for Cmd+B on Mac / Ctrl+B elsewhere. +

+ + ... +`}> + + +
+ +
+ Sliding Views +

+ Use Sidebar.SlidingViews and Sidebar.SlidingView + for animated horizontal transitions between navigation surfaces (e.g., account ↔ zone). + Inactive views are automatically marked with aria-hidden and inert. + Animation respects prefers-reduced-motion. +

+ + + ...account nav... + + + ...zone nav... + +`}> + + +
diff --git a/packages/kumo/src/components/sidebar/index.ts b/packages/kumo/src/components/sidebar/index.ts index d6f88b624a..1185e3c49b 100644 --- a/packages/kumo/src/components/sidebar/index.ts +++ b/packages/kumo/src/components/sidebar/index.ts @@ -25,10 +25,13 @@ export { SidebarCollapsible, SidebarCollapsibleTrigger, SidebarCollapsibleContent, + SidebarSlidingViews, + SidebarSlidingView, useSidebar, KUMO_SIDEBAR_VARIANTS, KUMO_SIDEBAR_DEFAULT_VARIANTS, KUMO_SIDEBAR_STYLING, + type SidebarState, type SidebarSide, type SidebarVariant, type SidebarCollapsible as SidebarCollapsibleType, @@ -40,4 +43,6 @@ export { type SidebarMenuSubButtonProps, type SidebarGroupProps, type SidebarInputProps, + type SidebarSlidingViewProps, + type SidebarSlidingViewsProps, } from "./sidebar"; diff --git a/packages/kumo/src/components/sidebar/sidebar.test.tsx b/packages/kumo/src/components/sidebar/sidebar.test.tsx index 0860f134c7..3feafe7de1 100644 --- a/packages/kumo/src/components/sidebar/sidebar.test.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.test.tsx @@ -1,4 +1,19 @@ -import { describe, it, expect } from "vitest"; +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// Mock motion/react so motion.div renders as a plain div in happy-dom +vi.mock("motion/react", () => ({ + motion: { + div: React.forwardRef((props: Record, ref: React.Ref) => { + // Strip motion-specific props, pass the rest through + const { animate, initial, transition, ...rest } = props; + return React.createElement("div", { ...rest, ref }); + }), + }, + useReducedMotion: () => false, +})); import { Sidebar, SidebarProvider, @@ -30,6 +45,26 @@ import { KUMO_SIDEBAR_STYLING, } from "./sidebar"; +// Force desktop mode: happy-dom needs matchMedia mock and wide viewport +function mockDesktopViewport() { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 1024, + }); + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + // Any max-width query should report non-matching (we're "wide" / desktop) + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); +} + describe("Sidebar", () => { it("should export compound component with all sub-components", () => { expect(Sidebar).toBeDefined(); @@ -113,3 +148,731 @@ describe("Sidebar", () => { expect(() => useSidebar()).toThrow(); }); }); + +// ============================================================================= +// Behavioral tests +// ============================================================================= + +// Helper: render a sidebar in desktop mode with standard wrapper +function renderDesktopSidebar(ui: React.ReactNode) { + mockDesktopViewport(); + return render( + + {ui} + , + ); +} + +// Helper: render a collapsed sidebar in desktop mode +function renderCollapsedSidebar( + ui: React.ReactNode, + providerProps?: Record, +) { + mockDesktopViewport(); + return render( + + {ui} + , + ); +} + +// --------------------------------------------------------------------------- +// Sidebar.Collapsible (item-level) +// --------------------------------------------------------------------------- + +describe("Sidebar.Collapsible", () => { + beforeEach(() => mockDesktopViewport()); + + it("renders content when defaultOpen is true", () => { + renderDesktopSidebar( + + + + + Compute} + /> + + + Workers + + + + + + , + ); + + // Content is in the DOM (grid-rows animation keeps it mounted) + expect(screen.getByText("Workers")).toBeTruthy(); + }); + + it("renders content in DOM even when defaultOpen is false (grid-rows keeps it mounted)", () => { + renderDesktopSidebar( + + + + + Compute} + /> + + + Workers + + + + + + , + ); + + // Content is still in the DOM (visually collapsed via grid-rows-[0fr]) + expect(screen.getByText("Workers")).toBeTruthy(); + }); + + it("trigger has aria-expanded reflecting open state", () => { + renderDesktopSidebar( + + + + + Compute} + /> + + + Workers + + + + + + , + ); + + const trigger = screen.getByRole("button", { name: /Compute/i }); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + }); + + it("trigger aria-expanded is false when collapsed", () => { + renderDesktopSidebar( + + + + + Compute} + /> + + + Workers + + + + + + , + ); + + const trigger = screen.getByRole("button", { name: /Compute/i }); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + }); + + it("collapsible content wrapper has data-sidebar attribute", () => { + renderDesktopSidebar( + + + + + Compute} + /> + + + Workers + + + + + + , + ); + + const content = screen.getByTestId("collapse-content"); + expect(content.getAttribute("data-sidebar")).toBe("collapsible-content"); + }); + + // --- Semantic hiding on collapsed content --- + + it("collapsed content has aria-hidden=true when closed", () => { + renderDesktopSidebar( + + + + + Compute} + /> + + + Workers + + + + + + , + ); + + const content = screen.getByTestId("cc"); + expect(content.getAttribute("aria-hidden")).toBe("true"); + }); + + it("collapsed content has inert attribute when closed", () => { + renderDesktopSidebar( + + + + + Compute} + /> + + + Workers + + + + + + , + ); + + const content = screen.getByTestId("cc"); + expect(content.getAttribute("inert")).toBeDefined(); + }); + + it("open content does not have aria-hidden", () => { + renderDesktopSidebar( + + + + + Compute} + /> + + + Workers + + + + + + , + ); + + const content = screen.getByTestId("cc"); + expect(content.getAttribute("aria-hidden")).toBe("false"); + }); + + it("open content does not have inert", () => { + renderDesktopSidebar( + + + + + Compute} + /> + + + Workers + + + + + + , + ); + + const content = screen.getByTestId("cc"); + expect(content.hasAttribute("inert")).toBe(false); + }); + + it("trigger has aria-controls pointing to content id", () => { + renderDesktopSidebar( + + + + + Compute} + /> + + + Workers + + + + + + , + ); + + const trigger = screen.getByRole("button", { name: /Compute/i }); + const content = screen.getByTestId("cc"); + expect(trigger.getAttribute("aria-controls")).toBe(content.id); + expect(content.id).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// Sidebar.Group (collapsible) +// --------------------------------------------------------------------------- + +describe("Sidebar.Group collapsible", () => { + beforeEach(() => mockDesktopViewport()); + + it("renders group content when collapsible and defaultOpen", () => { + renderDesktopSidebar( + + + Build + + + Compute + + + + , + ); + + expect(screen.getByText("Compute")).toBeTruthy(); + }); + + it("group content stays in DOM when defaultOpen is false (grid-rows keeps it mounted)", () => { + renderDesktopSidebar( + + + Build + + + Compute + + + + , + ); + + // Content is in the DOM (collapsed visually via grid-rows-[0fr]) + expect(screen.getByText("Compute")).toBeTruthy(); + }); + + it("group label trigger has aria-expanded=true when open", () => { + renderDesktopSidebar( + + + Build + + + Compute + + + + , + ); + + const trigger = screen.getByRole("button", { name: /Build/i }); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + }); + + it("group label trigger has aria-expanded=false when collapsed", () => { + renderDesktopSidebar( + + + Build + + + Compute + + + + , + ); + + const trigger = screen.getByRole("button", { name: /Build/i }); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + }); + + // --- Semantic hiding on collapsed group content --- + + it("collapsed group content has aria-hidden=true when closed", () => { + renderDesktopSidebar( + + + Build + + + Compute + + + + , + ); + + const content = screen.getByTestId("gc"); + expect(content.getAttribute("aria-hidden")).toBe("true"); + }); + + it("collapsed group content has inert attribute when closed", () => { + renderDesktopSidebar( + + + Build + + + Compute + + + + , + ); + + const content = screen.getByTestId("gc"); + expect(content.getAttribute("inert")).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Peeking behavior +// --------------------------------------------------------------------------- + +describe("Sidebar peeking", () => { + beforeEach(() => mockDesktopViewport()); + + it("sidebar is in collapsed state when defaultOpen=false", () => { + renderCollapsedSidebar( + + + Home + + , + ); + + const sidebar = document.querySelector("[data-sidebar='sidebar']"); + expect(sidebar?.getAttribute("data-state")).toBe("collapsed"); + }); + + it("sidebar enters peeking state on mouseenter when peekable and collapsed", () => { + renderCollapsedSidebar( + + + Home + + , + { peekable: true }, + ); + + const sidebar = document.querySelector("[data-sidebar='sidebar']") as HTMLElement; + expect(sidebar.getAttribute("data-state")).toBe("collapsed"); + + fireEvent.mouseEnter(sidebar); + + expect(sidebar.getAttribute("data-state")).toBe("peeking"); + }); + + it("sidebar exits peeking state on mouseleave", () => { + renderCollapsedSidebar( + + + Home + + , + { peekable: true }, + ); + + const sidebar = document.querySelector("[data-sidebar='sidebar']") as HTMLElement; + + fireEvent.mouseEnter(sidebar); + expect(sidebar.getAttribute("data-state")).toBe("peeking"); + + fireEvent.mouseLeave(sidebar); + expect(sidebar.getAttribute("data-state")).toBe("collapsed"); + }); + + it("does not enter peeking state when peekable=false", () => { + renderCollapsedSidebar( + + + Home + + , + { peekable: false }, + ); + + const sidebar = document.querySelector("[data-sidebar='sidebar']") as HTMLElement; + + fireEvent.mouseEnter(sidebar); + expect(sidebar.getAttribute("data-state")).toBe("collapsed"); + }); + + it("does not peek when sidebar is expanded", () => { + mockDesktopViewport(); + render( + + + + + Home + + + + , + ); + + const sidebar = document.querySelector("[data-sidebar='sidebar']") as HTMLElement; + expect(sidebar.getAttribute("data-state")).toBe("expanded"); + + fireEvent.mouseEnter(sidebar); + // Should remain expanded, not switch to peeking + expect(sidebar.getAttribute("data-state")).toBe("expanded"); + }); +}); + +// --------------------------------------------------------------------------- +// Footer peek prevention +// --------------------------------------------------------------------------- + +describe("Sidebar.Footer peek prevention", () => { + beforeEach(() => mockDesktopViewport()); + + it("hovering the footer does not leave sidebar in peeking state", () => { + renderCollapsedSidebar( + <> + + + Home + + + + + + , + { peekable: true }, + ); + + const sidebar = document.querySelector("[data-sidebar='sidebar']") as HTMLElement; + const footer = screen.getByTestId("footer"); + + // In a real browser, entering the sidebar via the footer fires mouseenter + // on both the aside and the footer. The footer's handler calls stopPeek(), + // and React 18 batches both updates. Net effect: no peek. + fireEvent.mouseEnter(sidebar); + fireEvent.mouseEnter(footer); + + // Should be collapsed — footer cancels any peek + expect(sidebar.getAttribute("data-state")).toBe("collapsed"); + }); + + it("moving mouse from content to footer cancels active peek", () => { + renderCollapsedSidebar( + <> + + + Home + + + + + + , + { peekable: true }, + ); + + const sidebar = document.querySelector("[data-sidebar='sidebar']") as HTMLElement; + const footer = screen.getByTestId("footer"); + + // Start peek via sidebar content area + fireEvent.mouseEnter(sidebar); + expect(sidebar.getAttribute("data-state")).toBe("peeking"); + + // Move to footer — should cancel the peek + fireEvent.mouseEnter(footer); + expect(sidebar.getAttribute("data-state")).toBe("collapsed"); + }); +}); + +// --------------------------------------------------------------------------- +// Sidebar.Trigger +// --------------------------------------------------------------------------- + +describe("Sidebar.Trigger", () => { + beforeEach(() => mockDesktopViewport()); + + it("has aria-expanded=true when sidebar is open", () => { + render( + + + + + + + , + ); + + const trigger = screen.getByTestId("trigger"); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + }); + + it("has aria-expanded=false when sidebar is collapsed", () => { + renderCollapsedSidebar( + + + , + ); + + const trigger = screen.getByTestId("trigger"); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + }); + + it("has dynamic aria-label based on open state", () => { + renderCollapsedSidebar( + + + , + ); + + const trigger = screen.getByTestId("trigger"); + expect(trigger.getAttribute("aria-label")).toBe("Expand sidebar"); + }); + + it("renders animated panel icon by default", () => { + renderDesktopSidebar( + + + , + ); + + const trigger = screen.getByTestId("trigger"); + const svg = trigger.querySelector("svg"); + expect(svg).toBeTruthy(); + // Should have the animated divider path + expect(svg?.querySelectorAll("path").length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// Keyboard shortcut +// --------------------------------------------------------------------------- + +describe("Sidebar keyboard shortcut", () => { + beforeEach(() => mockDesktopViewport()); + + it("mod+b toggles sidebar when keyboardShortcut is configured", () => { + render( + + + + + Home + + + + , + ); + + const sidebar = document.querySelector("[data-sidebar='sidebar']") as HTMLElement; + expect(sidebar.getAttribute("data-state")).toBe("expanded"); + + // Simulate Ctrl+B (non-Mac in happy-dom) + fireEvent.keyDown(document, { key: "b", ctrlKey: true }); + expect(sidebar.getAttribute("data-state")).toBe("collapsed"); + + // Toggle back + fireEvent.keyDown(document, { key: "b", ctrlKey: true }); + expect(sidebar.getAttribute("data-state")).toBe("expanded"); + }); +}); + +// --------------------------------------------------------------------------- +// Sidebar.SlidingViews accessibility +// --------------------------------------------------------------------------- + +describe("Sidebar.SlidingViews", () => { + beforeEach(() => mockDesktopViewport()); + + it("active view does not have aria-hidden", () => { + renderDesktopSidebar( + + +
Account Nav
+
+ +
Zone Nav
+
+
, + ); + + const accountView = screen.getByText("Account Nav").closest("[data-sidebar='sliding-view']"); + expect(accountView?.getAttribute("aria-hidden")).toBe("false"); + }); + + it("inactive view has aria-hidden=true", () => { + renderDesktopSidebar( + + +
Account Nav
+
+ +
Zone Nav
+
+
, + ); + + const zoneView = screen.getByText("Zone Nav").closest("[data-sidebar='sliding-view']"); + expect(zoneView?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("inactive view has inert attribute", () => { + renderDesktopSidebar( + + +
Account Nav
+
+ +
Zone Nav
+
+
, + ); + + const zoneView = screen.getByText("Zone Nav").closest("[data-sidebar='sliding-view']"); + // React renders inert="" as a string attribute; check it exists in the DOM + expect(zoneView?.getAttribute("inert")).toBeDefined(); + }); + + it("active view does not have inert attribute", () => { + renderDesktopSidebar( + + +
Account Nav
+
+ +
Zone Nav
+
+
, + ); + + const accountView = screen.getByText("Account Nav").closest("[data-sidebar='sliding-view']"); + expect(accountView?.hasAttribute("inert")).toBe(false); + }); +}); diff --git a/packages/kumo/src/components/sidebar/sidebar.tsx b/packages/kumo/src/components/sidebar/sidebar.tsx index 0bbe03de06..3fe72604e9 100644 --- a/packages/kumo/src/components/sidebar/sidebar.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.tsx @@ -2,22 +2,24 @@ import React, { type ComponentPropsWithoutRef, type CSSProperties, type ReactNode, + Children, createContext, forwardRef, + isValidElement, useCallback, useContext, useEffect, + useId, useMemo, useRef, useState, } from "react"; -import { Collapsible as CollapsibleBase } from "@base-ui/react/collapsible"; import { Dialog as DialogBase } from "@base-ui/react/dialog"; +import { motion, useReducedMotion } from "motion/react"; import { CaretRightIcon, MagnifyingGlassIcon, - SidebarSimpleIcon, } from "@phosphor-icons/react"; import { cn } from "../../utils/cn"; import { useLinkComponent } from "../../utils/link-provider"; @@ -96,6 +98,8 @@ export type SidebarCollapsible = "icon" | "offcanvas" | "none"; const SIDEBAR_WIDTH = "16rem"; const SIDEBAR_WIDTH_ICON = "3rem"; const MOBILE_BREAKPOINT = 768; +const SIDEBAR_ANIMATION_DURATION_MS = 200; +const SIDEBAR_EASING = "cubic-bezier(0.77,0,0.175,1)"; // ============================================================================ // Mobile detection hook @@ -119,8 +123,10 @@ function useIsMobile() { // Context // ============================================================================ +export type SidebarState = "expanded" | "collapsed" | "peeking"; + export interface SidebarContextValue { - state: "expanded" | "collapsed"; + state: SidebarState; open: boolean; setOpen: (open: boolean) => void; openMobile: boolean; @@ -137,6 +143,16 @@ export interface SidebarContextValue { isResizing: boolean; setIsResizing: (resizing: boolean) => void; setWidth: (width: number) => void; + isPeeking: boolean; + peekable: boolean; + /** @internal Start peeking (called by SidebarRoot on mouse/focus enter). */ + startPeek: () => void; + /** @internal Stop peeking (called by SidebarRoot on mouse/focus leave). */ + stopPeek: () => void; + /** Animation duration in milliseconds for structural transitions. */ + animationDuration: number; + /** Keyboard shortcut string (e.g. "mod+b") from Provider, used by Trigger tooltip. */ + keyboardShortcut?: string; } const SidebarContext = createContext(null); @@ -185,6 +201,24 @@ export interface SidebarProviderProps { maxWidth?: number; /** Callback when width changes during resize. */ onWidthChange?: (width: number) => void; + /** + * When true, hovering or focusing the collapsed sidebar temporarily expands + * it ("peeking"). Moving the cursor/focus away collapses it back. + * @default false + */ + peekable?: boolean; + /** + * Keyboard shortcut string that toggles the sidebar (e.g. `"mod+b"`). + * `mod` maps to `Meta` on macOS and `Control` elsewhere. + * @default undefined + */ + keyboardShortcut?: string; + /** + * Duration in milliseconds for structural sidebar animations (expand/collapse, + * sliding views, grid-row transitions). Matches Stratus `SIDEBAR_NAV_ANIMATION_DURATION`. + * @default 200 + */ + animationDuration?: number; /** Content — typically `` + main content. */ children: ReactNode; /** Additional CSS classes for the wrapper div. */ @@ -221,6 +255,9 @@ function SidebarProvider({ minWidth = MIN_WIDTH_PX, maxWidth = MAX_WIDTH_PX, onWidthChange, + peekable = false, + keyboardShortcut, + animationDuration = SIDEBAR_ANIMATION_DURATION_MS, children, className, style, @@ -229,6 +266,7 @@ function SidebarProvider({ const [openMobile, setOpenMobile] = useState(false); const [width, setWidthState] = useState(defaultWidth); const [isResizing, setIsResizing] = useState(false); + const [isPeeking, setIsPeeking] = useState(false); const setWidth = useCallback( (newWidth: number) => { @@ -258,7 +296,46 @@ function SidebarProvider({ } }, [isMobile, setOpen]); - const state = open ? "expanded" : "collapsed"; + // --- Peeking --- + const startPeek = useCallback(() => setIsPeeking(true), []); + const stopPeek = useCallback(() => setIsPeeking(false), []); + + // Cancel peeking when sidebar is explicitly opened + useEffect(() => { + if (open) setIsPeeking(false); + }, [open]); + + // --- Keyboard shortcut --- + useEffect(() => { + if (!keyboardShortcut) return; + + const parts = keyboardShortcut.toLowerCase().split("+"); + const key = parts[parts.length - 1]; + const needsMod = parts.includes("mod"); + const needsShift = parts.includes("shift"); + const needsAlt = parts.includes("alt"); + + const handler = (e: KeyboardEvent) => { + if (e.key.toLowerCase() !== key) return; + const isMac = + typeof navigator !== "undefined" && + /Mac|iPhone|iPad/.test(navigator.userAgent); + const modPressed = isMac ? e.metaKey : e.ctrlKey; + if (needsMod && !modPressed) return; + if (needsShift && !e.shiftKey) return; + if (needsAlt && !e.altKey) return; + + e.preventDefault(); + toggleSidebar(); + }; + + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [keyboardShortcut, toggleSidebar]); + + const baseState: "expanded" | "collapsed" = open ? "expanded" : "collapsed"; + const state: SidebarState = + !open && peekable && isPeeking ? "peeking" : baseState; const sidebarWidthValue = resizable ? `${width}px` : SIDEBAR_WIDTH; @@ -281,6 +358,12 @@ function SidebarProvider({ isResizing, setIsResizing, setWidth, + isPeeking, + peekable, + startPeek, + stopPeek, + animationDuration, + keyboardShortcut, }), [ state, @@ -300,6 +383,12 @@ function SidebarProvider({ isResizing, setIsResizing, setWidth, + isPeeking, + peekable, + startPeek, + stopPeek, + animationDuration, + keyboardShortcut, ], ); @@ -313,6 +402,8 @@ function SidebarProvider({ { "--sidebar-width": sidebarWidthValue, "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, + "--sidebar-animation-duration": `${animationDuration}ms`, + "--sidebar-easing": SIDEBAR_EASING, ...style, } as CSSProperties } @@ -370,8 +461,58 @@ const SidebarRoot = forwardRef( isResizing, resizable, width, + open, + peekable, + startPeek, + stopPeek, } = useSidebar(); + // Peek handlers — only active when collapsed and peekable + const canPeek = peekable && !open && !isMobile && collapsible !== "none"; + const isMouseOverRef = useRef(false); + + const isInFooter = (el: EventTarget | null) => + el instanceof HTMLElement && + !!el.closest('[data-sidebar="footer"]'); + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + isMouseOverRef.current = true; + // Don't start peek when mouse enters through the footer zone + if (canPeek && !isInFooter(e.target)) startPeek(); + }, + [canPeek, startPeek], + ); + + const handleMouseLeave = useCallback(() => { + isMouseOverRef.current = false; + if (canPeek) stopPeek(); + }, [canPeek, stopPeek]); + + const handleFocusIn = useCallback( + (e: React.FocusEvent) => { + if ( + canPeek && + !isInFooter(e.target) && + (e.target as HTMLElement).matches(":focus-visible") + ) { + startPeek(); + } + }, + [canPeek, startPeek], + ); + + const handleFocusOut = useCallback( + (e: React.FocusEvent) => { + // Don't close peek if mouse is still over the sidebar + if (isMouseOverRef.current) return; + if (canPeek && !e.currentTarget.contains(e.relatedTarget as Node)) { + stopPeek(); + } + }, + [canPeek, stopPeek], + ); + if (collapsible === "none") { return (