diff --git a/.changeset/sidebar-footer-refactor.md b/.changeset/sidebar-footer-refactor.md new file mode 100644 index 0000000000..b1be5140e5 --- /dev/null +++ b/.changeset/sidebar-footer-refactor.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": minor +--- + +Sidebar: refactor footer to match Stratus NavFooter — animated panel icon, tooltip with keyboard shortcut hint, peek prevention on footer hover, sticky positioning, and improved aria attributes. diff --git a/.changeset/sidebar-group-label-first-child.md b/.changeset/sidebar-group-label-first-child.md new file mode 100644 index 0000000000..a1a478ec10 --- /dev/null +++ b/.changeset/sidebar-group-label-first-child.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/kumo": patch +--- + +Sidebar: hide GroupLabel spacer/border when first-child and collapsed + +The `SidebarGroupLabel` now takes up no vertical space and hides its collapsed-state border line when it's inside the first `Sidebar.Group`. Previously, the first group label would still render a horizontal divider and margin even though there was nothing above it to separate from. diff --git a/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx b/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx index 2f267480d3..c25a6b7cb9 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 { cn, Sidebar, useSidebar, DropdownMenu, type SidebarState } from "@cloudflare/kumo"; import { HouseIcon, GlobeIcon, @@ -8,12 +8,16 @@ import { DatabaseIcon, CodeIcon, LockIcon, - CloudIcon, + CubeIcon, BellIcon, CaretUpDownIcon, CheckIcon, - RocketIcon, - FlaskIcon, + StackIcon, + StackSimpleIcon, + UserIcon, + ArrowsLeftRightIcon, + ArrowLeftIcon, + MagnifyingGlassIcon, } from "@phosphor-icons/react"; import { useState } from "react"; @@ -31,7 +35,7 @@ function DemoContainer({ children }: { children: React.ReactNode }) { function DemoMain({ children }: { children?: React.ReactNode }) { return ( -
+
{children ?? "Main content area"}
); @@ -39,19 +43,19 @@ function DemoMain({ children }: { children?: React.ReactNode }) { function BrandLogo() { return ( -
-
+
+ - Acme Inc + Company
); } const accounts = [ - { id: "1", name: "Acme Inc", icon: CloudIcon }, - { id: "2", name: "Personal", icon: RocketIcon }, - { id: "3", name: "Staging", icon: FlaskIcon }, + { id: "1", name: "Company", icon: CubeIcon }, + { id: "2", name: "Personal", icon: StackIcon }, + { id: "3", name: "Staging", icon: StackSimpleIcon }, ]; function AccountSwitcher() { @@ -63,27 +67,27 @@ function AccountSwitcher() { render={ } /> - + {accounts.map((account) => ( setActive(account)} > - + {account.name} {account.id === active.id && ( @@ -103,7 +107,7 @@ function AccountSwitcher() { export function SidebarBasicDemo() { return ( - + @@ -136,9 +140,31 @@ export function SidebarBasicDemo() { /> - - Workers & Pages - + + + + Workers & Pages + + + } + /> + + + + Overview + + + Workers + + + Pages + + + + + Durable Objects @@ -160,71 +186,7 @@ export function SidebarBasicDemo() { } // --------------------------------------------------------------------------- -// 2. Collapsible Groups — group-level collapse via label click -// --------------------------------------------------------------------------- - -/** Sidebar with collapsible groups that animate open/closed via the group label. */ -export function SidebarCollapsibleGroupDemo() { - return ( - - - - - {/* GroupContent is required for collapsible groups (provides grid-rows animation) */} - - Overview - - - - Home - - - Analytics - - - Domains - - - - - - - Build - - - - Compute - - - Storage - - - - - - - Protect & Connect - - - - Security - - - Zero Trust - - - - - - - - - - ); -} - -// --------------------------------------------------------------------------- -// 3. Toggle — expand/collapse with trigger + tooltips +// 2. Toggle — expand/collapse with trigger + tooltips // --------------------------------------------------------------------------- function ToggleButton() { @@ -233,7 +195,7 @@ function ToggleButton() { @@ -244,7 +206,7 @@ function ToggleButton() { export function SidebarToggleDemo() { return ( - + @@ -273,7 +235,7 @@ export function SidebarToggleDemo() { -

+

Click the button or the sidebar trigger to toggle

@@ -283,24 +245,26 @@ export function SidebarToggleDemo() { } // --------------------------------------------------------------------------- -// 4. Full — kitchen sink: header, account switcher, search, badges, footer +// 4. Resizable — drag handle with auto-collapse // --------------------------------------------------------------------------- -/** Sidebar with account switcher, search input, badges, and full navigation. */ -export function SidebarFullDemo() { +/** Resizable sidebar with drag handle. Drag the right edge to resize. */ +export function SidebarResizableDemo() { return ( - + - + - -
- -
- Overview @@ -308,91 +272,129 @@ export function SidebarFullDemo() { Home - Analytics & Logs + Analytics - - Domains + + Storage +
+ + + + +
+ +

Drag the sidebar edge to resize

+
+
+
+ ); +} - +// --------------------------------------------------------------------------- +// 6. Right Side — right-aligned, content only +// --------------------------------------------------------------------------- +/** Right-side sidebar variant. */ +export function SidebarRightDemo() { + return ( + + + + + - Build + Details - - - - Compute - - - } - /> - - - - Workers & Pages - - - Durable Objects - - - Containers - Beta - - - - - - - Storage + + Properties + + + Metrics + Alerts + + + + + ); +} +// --------------------------------------------------------------------------- +// 7. Peeking — hover to temporarily expand collapsed sidebar +// --------------------------------------------------------------------------- + +function PeekStateIndicator() { + const { state } = useSidebar(); + const labels: Record = { + expanded: "Expanded", + collapsed: "Collapsed", + peeking: "Peeking", + }; + return ( +
+ + State: {labels[state]} + +

Collapse, then hover the sidebar to peek

+
+ ); +} + +/** Peekable sidebar that temporarily expands on hover when collapsed. */ +export function SidebarPeekingDemo() { + return ( + + + + + + + - Protect & Connect - - Security + + Home - - Zero Trust - Beta + + Analytics + + + Compute + + + Storage - - - Manage account - + - + + + ); } // --------------------------------------------------------------------------- -// 5. Resizable — drag handle with auto-collapse +// 8. Keyboard Shortcut — toggle sidebar with Cmd+B / Ctrl+B // --------------------------------------------------------------------------- -/** Resizable sidebar with drag handle. Drag the right edge to resize. */ -export function SidebarResizableDemo() { +/** Sidebar with keyboard shortcut toggle (Cmd+B / Ctrl+B). */ +export function SidebarKeyboardShortcutDemo() { return ( - @@ -401,15 +403,14 @@ export function SidebarResizableDemo() { - Overview - + Home - + Analytics - + Storage @@ -418,10 +419,14 @@ export function SidebarResizableDemo() { - -

Drag the sidebar edge to resize

+
+ + B + +

Press Cmd+B (Mac) or Ctrl+B to toggle

+
@@ -429,32 +434,283 @@ export function SidebarResizableDemo() { } // --------------------------------------------------------------------------- -// 6. Right Side — right-aligned, content only +// 9. Sliding Views — animated horizontal transitions between surfaces // --------------------------------------------------------------------------- -/** Right-side sidebar variant. */ -export function SidebarRightDemo() { +/** Sidebar with animated sliding views between Account and Zone navigation. */ +export function SidebarSlidingViewsDemo() { + const [surface, setSurface] = useState<"account" | "zone">("account"); + return ( - - + - - - Details - - - Properties - - - Metrics - - Alerts - - - + + + + + + + + + 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 +

+
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// 9. Full — kitchen sink showcasing every subcomponent +// --------------------------------------------------------------------------- + +/** Kitchen sink sidebar showcasing every subcomponent: header with account switcher, search input, groups with labels, collapsible sections with nested expandable, badges, sliding views via Domains, and a footer trigger. */ +export function SidebarFullDemo() { + const [surface, setSurface] = useState<"account" | "domain">("account"); + + return ( + + + + + + +
+ +
+ + + + + + + + Home + + + Analytics & Logs + + setSurface("domain")} + > + Domains + + + + + + Build + + + + + Compute + + + } + /> + + + + + + Workers & Pages + + + } + /> + + + + Overview + + + Workers + + + Pages + + + + + + + Durable Objects + + + Containers + Beta + + + + + + + Storage + + + + + + Protect & Connect + + + Security + + + Zero Trust + Beta + + + + + + + + + + + setSurface("account")} + > + Back + + + + + example.com + + + Overview + + + Security + + + SSL/TLS + + + Analytics + + + Caching + + + + + + + + + +
+
); } + diff --git a/packages/kumo-docs-astro/src/pages/components/sidebar.astro b/packages/kumo-docs-astro/src/pages/components/sidebar.astro index 9891463d62..a61a767e44 100644 --- a/packages/kumo-docs-astro/src/pages/components/sidebar.astro +++ b/packages/kumo-docs-astro/src/pages/components/sidebar.astro @@ -7,11 +7,13 @@ import CodeBlock from "../../components/docs/CodeBlock.astro"; import PropsTable from "../../components/docs/PropsTable.astro"; import { SidebarBasicDemo, - SidebarCollapsibleGroupDemo, SidebarToggleDemo, SidebarFullDemo, SidebarResizableDemo, SidebarRightDemo, + SidebarPeekingDemo, + SidebarKeyboardShortcutDemo, + SidebarSlidingViewsDemo, } from "../../components/demos/SidebarDemo"; --- @@ -48,7 +50,7 @@ import { Usage -

+

At minimum you need Provider, Sidebar, Content (scrollable area), @@ -71,7 +73,7 @@ function AppLayout({ children }) { Navigation Home - {/* MenuItem only needed to wrap Collapsible or MenuAction */} + {/* MenuItem only needed to wrap Collapsible */} Settings -

{children}
+
{children}
); }`} lang="tsx" /> @@ -107,7 +109,7 @@ function AppLayout({ children }) {
Basic -

+

The minimum viable sidebar: just groups, menu buttons, and collapsible sub-menus. No header or footer. MenuButton and MenuSubButton auto-wrap in <li> — no MenuItem / MenuSubItem needed.

@@ -128,26 +130,9 @@ function AppLayout({ children }) {
-
- Collapsible Groups -

- Add collapsible to a Group and wrap the Menu in GroupContent to enable animated expand/collapse via the group label. -

- - Overview - - - Home - - -`}> - - -
-
Toggle & Collapsed State -

+

Use Sidebar.Trigger in the footer or useSidebar().toggleSidebar programmatically. Pass tooltip to show labels on hover when collapsed.

@@ -164,37 +149,9 @@ const { toggleSidebar } = useSidebar();`}>
-
- Full Example -

- Kitchen sink: header with account switcher, search input, badges, collapsible sub-menus, and a footer action. -

- - - - - - - - - - Zero Trust - Beta - - - - - - Manage account - -`}> - - -
-
Resizable -

+

Drag the edge to resize. Dragging below minWidth collapses; dragging outward from collapsed expands.

@@ -209,7 +166,7 @@ const { toggleSidebar } = useSidebar();`}>
Right Side -

+

Use side="right" for a sidebar on the right edge. Place <main> before <Sidebar> in the DOM.

@@ -229,6 +186,134 @@ 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... + +`}> + + +
+ +
+ Full Example +

+ Kitchen sink showcasing every subcomponent: header with account switcher, groups with labels, collapsible sections with nested expandable, badges, sliding views, and a footer trigger. +

+ + + + + + + + Home + + + + Build + + + + + Compute + + } + /> + + + + + + Workers & Pages + + } + /> + + + Overview + + + + + + Containers Beta + + + + + + + + + Protect + + Security + + + + + + +`}> + + +
+
@@ -238,7 +323,7 @@ const { toggleSidebar } = useSidebar();`}>

Sidebar

-

+

The main sidebar container. Renders as <aside> on desktop, Dialog sheet on mobile.

@@ -246,7 +331,7 @@ const { toggleSidebar } = useSidebar();`}>

Sidebar.Provider

-

+

Context provider managing expand/collapse state and mobile detection.

@@ -254,23 +339,23 @@ const { toggleSidebar } = useSidebar();`}>

Sidebar.Content

-

+

Scrollable middle section (flex-1 overflow-y-auto). Use Header / Footer to pin content above or below this scroll area.

Sidebar.MenuButton

-

+

Primary interactive element. Supports icons, active state, links, and auto-tooltip when collapsed. - Auto-wraps in <li> — no MenuItem wrapper needed unless you have siblings like MenuAction. + Auto-wraps in <li> — no MenuItem wrapper needed unless wrapping a Collapsible.

Sidebar.MenuSubButton

-

+

Button inside a sub-menu for nested navigation. Auto-wraps in <li> — no MenuSubItem wrapper needed.

@@ -279,18 +364,11 @@ const { toggleSidebar } = useSidebar();`}>

Sidebar.GroupContent

-

+

Animation wrapper — only needed for collapsible groups (provides the grid-rows height animation). For non-collapsible groups, place Menu directly inside Group.

-
-

Sidebar.Input

-

- Search trigger button styled as an input. Typically opens a command palette. -

- -
diff --git a/packages/kumo/src/components/sidebar/index.ts b/packages/kumo/src/components/sidebar/index.ts index d6f88b624a..3136307a06 100644 --- a/packages/kumo/src/components/sidebar/index.ts +++ b/packages/kumo/src/components/sidebar/index.ts @@ -7,17 +7,14 @@ export { SidebarFooter, SidebarGroup, SidebarGroupLabel, - SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton, - SidebarMenuAction, SidebarMenuBadge, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton, SidebarSeparator, - SidebarInput, SidebarTrigger, SidebarRail, SidebarResizeHandle, @@ -25,10 +22,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, @@ -39,5 +39,6 @@ export { type SidebarMenuButtonProps, 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..1b607eab6c 100644 --- a/packages/kumo/src/components/sidebar/sidebar.test.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.test.tsx @@ -1,4 +1,17 @@ -import { describe, it, expect } from "vitest"; +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +// 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: _animate, initial: _initial, transition: _transition, ...rest } = props; + return React.createElement("div", { ...rest, ref }); + }), + }, + useReducedMotion: () => false, +})); import { Sidebar, SidebarProvider, @@ -7,17 +20,14 @@ import { SidebarFooter, SidebarGroup, SidebarGroupLabel, - SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton, - SidebarMenuAction, SidebarMenuBadge, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton, SidebarSeparator, - SidebarInput, SidebarTrigger, SidebarRail, SidebarMenuChevron, @@ -30,6 +40,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(); @@ -39,17 +69,14 @@ describe("Sidebar", () => { expect(Sidebar.Footer).toBe(SidebarFooter); expect(Sidebar.Group).toBe(SidebarGroup); expect(Sidebar.GroupLabel).toBe(SidebarGroupLabel); - expect(Sidebar.GroupContent).toBe(SidebarGroupContent); expect(Sidebar.Menu).toBe(SidebarMenu); expect(Sidebar.MenuItem).toBe(SidebarMenuItem); expect(Sidebar.MenuButton).toBe(SidebarMenuButton); - expect(Sidebar.MenuAction).toBe(SidebarMenuAction); expect(Sidebar.MenuBadge).toBe(SidebarMenuBadge); expect(Sidebar.MenuSub).toBe(SidebarMenuSub); expect(Sidebar.MenuSubItem).toBe(SidebarMenuSubItem); expect(Sidebar.MenuSubButton).toBe(SidebarMenuSubButton); expect(Sidebar.Separator).toBe(SidebarSeparator); - expect(Sidebar.Input).toBe(SidebarInput); expect(Sidebar.Trigger).toBe(SidebarTrigger); expect(Sidebar.Rail).toBe(SidebarRail); expect(Sidebar.MenuChevron).toBe(SidebarMenuChevron); @@ -84,8 +111,8 @@ describe("Sidebar", () => { it("should export styling metadata", () => { expect(KUMO_SIDEBAR_STYLING).toBeDefined(); - expect(KUMO_SIDEBAR_STYLING.width.expanded).toBe("16rem"); - expect(KUMO_SIDEBAR_STYLING.width.icon).toBe("3rem"); + expect(KUMO_SIDEBAR_STYLING.width.expanded).toBe("16.25rem"); + expect(KUMO_SIDEBAR_STYLING.width.icon).toBe("57px"); }); it("should set displayName on all forwardRef components", () => { @@ -94,17 +121,14 @@ describe("Sidebar", () => { expect(SidebarFooter.displayName).toBe("Sidebar.Footer"); expect(SidebarGroup.displayName).toBe("Sidebar.Group"); expect(SidebarGroupLabel.displayName).toBe("Sidebar.GroupLabel"); - expect(SidebarGroupContent.displayName).toBe("Sidebar.GroupContent"); expect(SidebarMenu.displayName).toBe("Sidebar.Menu"); expect(SidebarMenuItem.displayName).toBe("Sidebar.MenuItem"); expect(SidebarMenuButton.displayName).toBe("Sidebar.MenuButton"); - expect(SidebarMenuAction.displayName).toBe("Sidebar.MenuAction"); expect(SidebarMenuBadge.displayName).toBe("Sidebar.MenuBadge"); expect(SidebarMenuSub.displayName).toBe("Sidebar.MenuSub"); expect(SidebarMenuSubItem.displayName).toBe("Sidebar.MenuSubItem"); expect(SidebarMenuSubButton.displayName).toBe("Sidebar.MenuSubButton"); expect(SidebarSeparator.displayName).toBe("Sidebar.Separator"); - expect(SidebarInput.displayName).toBe("Sidebar.Input"); expect(SidebarTrigger.displayName).toBe("Sidebar.Trigger"); expect(SidebarRail.displayName).toBe("Sidebar.Rail"); }); @@ -113,3 +137,769 @@ 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.hasAttribute("inert")).toBe(true); + }); + + 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.Collapsible keyboard expand/collapse +// --------------------------------------------------------------------------- + +describe("Sidebar.Collapsible keyboard expand/collapse", () => { + beforeEach(() => mockDesktopViewport()); + + const CollapsibleFixture = ({ defaultOpen = false }: { defaultOpen?: boolean }) => ( + + + + + Compute} + /> + + + Workers + + + + + + + ); + + it("expands on keyboard focus (focus-visible)", () => { + renderDesktopSidebar(); + + const trigger = screen.getByRole("button", { name: /Compute/i }); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + + // Simulate keyboard focus: mock matches(':focus-visible') → true + vi.spyOn(trigger, "matches").mockImplementation( + (selector: string) => selector === ":focus-visible", + ); + + // Focus the trigger inside the collapsible — bubbles to collapsible's onFocus + fireEvent.focus(trigger); + + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + }); + + it("does not expand on mouse focus (non focus-visible)", () => { + renderDesktopSidebar(); + + const trigger = screen.getByRole("button", { name: /Compute/i }); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + + // Mouse focus: matches(':focus-visible') → false + vi.spyOn(trigger, "matches").mockReturnValue(false); + fireEvent.focus(trigger); + + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + }); + + it("collapses on blur when keyboard-expanded and focus leaves", () => { + renderDesktopSidebar(); + + const trigger = screen.getByRole("button", { name: /Compute/i }); + const collapsible = screen.getByTestId("collapsible"); + + // Keyboard-expand it first + vi.spyOn(trigger, "matches").mockImplementation( + (selector: string) => selector === ":focus-visible", + ); + fireEvent.focus(trigger); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + + // Blur with relatedTarget outside the collapsible + fireEvent.blur(collapsible, { relatedTarget: document.body }); + + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + }); + + it("does not collapse on blur when focus moves to a child", () => { + renderDesktopSidebar(); + + const trigger = screen.getByRole("button", { name: /Compute/i }); + const collapsible = screen.getByTestId("collapsible"); + + // Keyboard-expand it first + vi.spyOn(trigger, "matches").mockImplementation( + (selector: string) => selector === ":focus-visible", + ); + fireEvent.focus(trigger); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + + // Blur with relatedTarget inside the collapsible (focus moving to child) + const subButton = screen.getByText("Workers"); + fireEvent.blur(collapsible, { relatedTarget: subButton }); + + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + }); + + it("does not auto-collapse after manual click toggle", () => { + renderDesktopSidebar(); + + const trigger = screen.getByRole("button", { name: /Compute/i }); + const collapsible = screen.getByTestId("collapsible"); + + // Click to open (manual toggle) + fireEvent.click(trigger); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + + // Blur — should NOT collapse because it was click-expanded + fireEvent.blur(collapsible, { relatedTarget: document.body }); + + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + }); + + it("does not auto-collapse when a child has data-active", () => { + renderDesktopSidebar( + + + + + Compute} + /> + + + Workers + + + + + + , + ); + + const trigger = screen.getByRole("button", { name: /Compute/i }); + const collapsible = screen.getByTestId("collapsible"); + + // Keyboard-expand + vi.spyOn(trigger, "matches").mockImplementation( + (selector: string) => selector === ":focus-visible", + ); + fireEvent.focus(trigger); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + + // Blur — should NOT collapse because a child has data-active + fireEvent.blur(collapsible, { relatedTarget: document.body }); + + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + }); +}); + +// --------------------------------------------------------------------------- +// 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; + const peekZone = document.querySelector("[data-sidebar='peek-zone']") as HTMLElement; + expect(sidebar.getAttribute("data-state")).toBe("collapsed"); + + fireEvent.mouseEnter(peekZone); + + expect(sidebar.getAttribute("data-state")).toBe("peeking"); + }); + + it("sidebar exits peeking state on mouseleave", () => { + renderCollapsedSidebar( + + + Home + + , + { peekable: true }, + ); + + const peekZone = document.querySelector("[data-sidebar='peek-zone']") as HTMLElement; + + fireEvent.mouseEnter(peekZone); + expect(document.querySelector("[data-sidebar='sidebar']")?.getAttribute("data-state")).toBe("peeking"); + + fireEvent.mouseLeave(peekZone); + expect(document.querySelector("[data-sidebar='sidebar']")?.getAttribute("data-state")).toBe("collapsed"); + }); + + it("does not enter peeking state when peekable=false", () => { + renderCollapsedSidebar( + + + Home + + , + { peekable: false }, + ); + + const peekZone = document.querySelector("[data-sidebar='peek-zone']") as HTMLElement; + + fireEvent.mouseEnter(peekZone); + expect(document.querySelector("[data-sidebar='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; + const peekZone = document.querySelector("[data-sidebar='peek-zone']") as HTMLElement; + expect(sidebar.getAttribute("data-state")).toBe("expanded"); + + fireEvent.mouseEnter(peekZone); + // Should remain expanded, not switch to peeking + expect(sidebar.getAttribute("data-state")).toBe("expanded"); + }); +}); + +// --------------------------------------------------------------------------- +// Footer peek prevention +// --------------------------------------------------------------------------- + +describe("Sidebar.Footer peek prevention", () => { + beforeEach(() => mockDesktopViewport()); + + // NOTE: Footer tests render directly (not via renderCollapsedSidebar) + // because the helper wraps children in a fragment, and Children.toArray + // does not flatten fragments — so the Footer would not be separated from + // content and would end up inside the peek-zone. + + it("footer is rendered outside the peek-zone so hovering it cannot trigger peek", () => { + render( + + + + + Home + + + + + + + , + ); + + const peekZone = document.querySelector("[data-sidebar='peek-zone']") as HTMLElement; + const footer = screen.getByTestId("footer"); + + // The architectural guarantee: footer is a sibling of the peek-zone, + // not a descendant. In a real browser, mouseenter on the footer cannot + // reach the peek-zone's onMouseEnter handler (mouseenter doesn't bubble). + // happy-dom's event model cannot accurately test this, so we verify the + // DOM structure directly. + expect(peekZone.contains(footer)).toBe(false); + }); + + it("moving mouse from content to footer cancels active peek", () => { + render( + + + + + Home + + + + + + + , + ); + + const sidebar = document.querySelector("[data-sidebar='sidebar']") as HTMLElement; + const peekZone = document.querySelector("[data-sidebar='peek-zone']") as HTMLElement; + + // Start peek via peek-zone (content area) + fireEvent.mouseEnter(peekZone); + expect(sidebar.getAttribute("data-state")).toBe("peeking"); + + // Move to footer — mouse leaves peek-zone, cancelling the peek + fireEvent.mouseLeave(peekZone); + 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?.hasAttribute("inert")).toBe(true); + }); + + 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..d0388b5a7b 100644 --- a/packages/kumo/src/components/sidebar/sidebar.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.tsx @@ -2,23 +2,22 @@ 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 { CaretRightIcon } from "@phosphor-icons/react"; import { cn } from "../../utils/cn"; import { useLinkComponent } from "../../utils/link-provider"; import { Tooltip, TooltipProvider } from "../tooltip"; @@ -77,8 +76,8 @@ export const KUMO_SIDEBAR_DEFAULT_VARIANTS = { export const KUMO_SIDEBAR_STYLING = { width: { - expanded: "16rem", - icon: "3rem", + expanded: "16.25rem", + icon: "57px", }, mobile: { breakpoint: 768, @@ -93,9 +92,11 @@ export type SidebarCollapsible = "icon" | "offcanvas" | "none"; // Constants // ============================================================================ -const SIDEBAR_WIDTH = "16rem"; -const SIDEBAR_WIDTH_ICON = "3rem"; -const MOBILE_BREAKPOINT = 768; +const SIDEBAR_WIDTH = KUMO_SIDEBAR_STYLING.width.expanded; +const SIDEBAR_WIDTH_ICON = KUMO_SIDEBAR_STYLING.width.icon; +const MOBILE_BREAKPOINT = KUMO_SIDEBAR_STYLING.mobile.breakpoint; +const SIDEBAR_ANIMATION_DURATION_MS = 250; +const SIDEBAR_EASING = "cubic-bezier(0.77,0,0.175,1)"; // ============================================================================ // Mobile detection hook @@ -119,8 +120,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 +140,18 @@ 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; + /** When true, collapsed sidebar uses absolute instead of fixed positioning. */ + contained: boolean; } const SidebarContext = createContext(null); @@ -185,6 +200,31 @@ 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; + /** + * When true, the collapsed sidebar uses `absolute` instead of `fixed` positioning. + * Use this when the sidebar is rendered inside a bounded container (e.g., demos) + * rather than at the viewport level. + * @default false + */ + contained?: boolean; /** Content — typically `` + main content. */ children: ReactNode; /** Additional CSS classes for the wrapper div. */ @@ -221,6 +261,10 @@ function SidebarProvider({ minWidth = MIN_WIDTH_PX, maxWidth = MAX_WIDTH_PX, onWidthChange, + peekable = false, + keyboardShortcut, + animationDuration = SIDEBAR_ANIMATION_DURATION_MS, + contained = false, children, className, style, @@ -229,6 +273,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 +303,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 +365,13 @@ function SidebarProvider({ isResizing, setIsResizing, setWidth, + isPeeking, + peekable, + startPeek, + stopPeek, + animationDuration, + keyboardShortcut, + contained, }), [ state, @@ -300,6 +391,13 @@ function SidebarProvider({ isResizing, setIsResizing, setWidth, + isPeeking, + peekable, + startPeek, + stopPeek, + animationDuration, + keyboardShortcut, + contained, ], ); @@ -313,6 +411,9 @@ function SidebarProvider({ { "--sidebar-width": sidebarWidthValue, "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, + "--sidebar-animation-duration": `${animationDuration}ms`, + "--sidebar-easing": SIDEBAR_EASING, + "--sidebar-bg": "var(--color-kumo-base)", ...style, } as CSSProperties } @@ -370,8 +471,69 @@ const SidebarRoot = forwardRef( isResizing, resizable, width, + open, + peekable, + startPeek, + stopPeek, + contained, + isPeeking, } = useSidebar(); + // Peek handlers — only active when collapsed and peekable + const canPeek = peekable && !open && !isMobile && collapsible !== "none"; + const isMouseOverRef = useRef(false); + + const handleMouseEnter = useCallback(() => { + isMouseOverRef.current = true; + if (canPeek) startPeek(); + }, [canPeek, startPeek]); + + const handleMouseLeave = useCallback(() => { + isMouseOverRef.current = false; + if (canPeek) stopPeek(); + }, [canPeek, stopPeek]); + + const handleFocusIn = useCallback( + (e: React.FocusEvent) => { + if ( + canPeek && + (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 content container + if (isMouseOverRef.current) return; + if (canPeek && !e.currentTarget.contains(e.relatedTarget as Node)) { + stopPeek(); + } + }, + [canPeek, stopPeek], + ); + + // Separate footer children from content children so the footer + // renders outside the peek-handling content container (matching + // Stratus architecture where NavFooter is a sibling of the + // peek zone, not nested inside it). + const childArray = Children.toArray(children); + const footerChildren: React.ReactNode[] = []; + const contentChildren: React.ReactNode[] = []; + for (const child of childArray) { + if ( + isValidElement(child) && + (child.type as { displayName?: string }).displayName === "Sidebar.Footer" + ) { + footerChildren.push(child); + } else { + contentChildren.push(child); + } + } + if (collapsible === "none") { return (