diff --git a/.changeset/soft-lions-hover.md b/.changeset/soft-lions-hover.md new file mode 100644 index 000000000..ed16d7e6e --- /dev/null +++ b/.changeset/soft-lions-hover.md @@ -0,0 +1,10 @@ +--- +"@cloudflare/kumo": minor +--- + +Add opt-in hover flyouts for nested sidebar collapsible content via the new +`flyout` prop on `Sidebar.Provider`. When the rail is collapsed, +flyouts appear regardless of the section's open state. + +`Sidebar.MenuButton` now falls back to its own text for the collapsed-rail +tooltip when no explicit `tooltip` is set, so every icon-only item is labelled. diff --git a/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx b/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx index 4ceea3f59..553fdf7bc 100644 --- a/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx @@ -335,6 +335,148 @@ export function SidebarToggleDemo() { ); } +// --------------------------------------------------------------------------- +// Flyout — collapsed collapsibles reveal nested content in a popup +// --------------------------------------------------------------------------- + +/** Turns peeking on (collapsing the sidebar to try it) or off (expanding back). */ +function PeekToggle({ + peekable, + onPeekableChange, +}: { + peekable: boolean; + onPeekableChange: (peekable: boolean) => void; +}) { + const { setOpen } = useSidebar(); + return ( + + ); +} + +/** + * Opt-in hover flyouts via `flyout`: hovering a closed section shows + * its nested content in a popup instead of expanding inline. Collapse the rail + * to see closed and open sections alike in a flyout. Childless items (Analytics) + * have nothing to show, so they show a plain tooltip. Enable peeking to see + * flyouts wait for the peek to expand. + */ +export function SidebarFlyoutDemo() { + const [peekable, setPeekable] = useState(false); + return ( + + + + + + + + + Build + + + {/* Childless item: no popup, so its tooltip survives. With no + `tooltip` prop the label falls back to the button text. */} + + Analytics + + + + {/* Closed section: hovering shows its children in a flyout. + `tooltip` names the parent (the popup lists only its + children). */} + + + Compute + + + } + /> + + + + + + Workers & Pages + + + } + /> + + + + Overview + + + Workers + + + Pages + + + + + + + Durable Objects + + + + + + + + + Storage + + + } + /> + + + R2 + KV + D1 + + + + + + + + + + + + + +

Hover a collapsed icon to see its nested items in a flyout

+
+
+
+ ); +} + // --------------------------------------------------------------------------- // Loading — nav-item-shaped skeleton shown while nav resolves // --------------------------------------------------------------------------- diff --git a/packages/kumo-docs-astro/src/pages/components/sidebar.astro b/packages/kumo-docs-astro/src/pages/components/sidebar.astro index 4c7f40ca4..99bab9436 100644 --- a/packages/kumo-docs-astro/src/pages/components/sidebar.astro +++ b/packages/kumo-docs-astro/src/pages/components/sidebar.astro @@ -14,6 +14,7 @@ import { SidebarResizableDemo, SidebarRightDemo, SidebarPeekingDemo, + SidebarFlyoutDemo, SidebarAutoScrollDemo, SidebarTransitionCompleteDemo, SidebarSlidingViewsDemo, @@ -314,6 +315,54 @@ scrollToItem("zero-trust", { align: "center", behavior: "smooth" });`}> +
+ Flyout +

+ Set flyout on the Provider (default + false) to reveal a closed collapsible's nested + content in a flyout on hover, instead of expanding it inline. A collapsed rail can't render inline + content, so its sections show a flyout regardless of open state. Flyouts are mouse-only (hidden from the + accessibility tree); keyboard and assistive-tech users get inline expansion. +

+

+ The collapsed top-level popup shows the parent's name as a title (from the trigger's + tooltip, or its text when omitted), replacing + the trigger's own tooltip. Nested popups omit it — the parent row is already visible beside them. Items + with no nested content keep their tooltip instead. Every popup lines its first item up with the trigger, so + the pointer can move straight across onto it. Combined with + peekable, hovering the collapsed rail peeks + first; flyouts only appear once the sidebar has fully expanded. +

+ + + + + + {/* Childless: no popup, tooltip falls back to the button text */} + Analytics + + + + + Compute + + } + /> + + ... + + + + + + +`}> + + +
+
Sliding Views

diff --git a/packages/kumo/src/components/sidebar/sidebar.test.tsx b/packages/kumo/src/components/sidebar/sidebar.test.tsx index 61a3c878c..57ec28df9 100644 --- a/packages/kumo/src/components/sidebar/sidebar.test.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.test.tsx @@ -57,6 +57,10 @@ function fireTransitionEnd(element: HTMLElement, propertyName: string) { } /** Minimal sidebar wrapper for tests that need Provider context. */ +// Base UI keeps a closing flyout mounted (`data-closed`) until its exit +// animation settles, so only count the open ones. +const OPEN_FLYOUT = "[data-sidebar='collapsible-flyout']:not([data-closed])"; + function TestSidebar({ children, ...providerProps @@ -536,7 +540,7 @@ describe("Sidebar.Collapsible", () => { sidebarOpen?: boolean; }) { return ( - + @@ -556,7 +560,10 @@ describe("Sidebar.Collapsible", () => { /> - Workers + + + Workers + @@ -599,6 +606,541 @@ describe("Sidebar.Collapsible", () => { expect(trigger.getAttribute("aria-controls")).toBe(content.id); }); + it("should show nested content in a flyout on hover without toggling open", () => { + vi.useFakeTimers(); + render(); + + try { + const trigger = screen.getByRole("button", { name: /Compute/i }); + const collapsible = trigger.parentElement!; + const content = screen.getByTestId("collapsible-content"); + + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(content.getAttribute("aria-hidden")).toBe("true"); + + fireEvent.mouseEnter(collapsible); + + const flyout = document.querySelector(OPEN_FLYOUT); + expect(flyout).toBeTruthy(); + // Mouse-only affordance: hidden from the accessibility tree, but NOT + // inert (so it stays clickable). Keyboard users get inline expansion. + expect(flyout?.getAttribute("aria-hidden")).toBe("true"); + expect(flyout?.hasAttribute("inert")).toBe(false); + expect(flyout?.hasAttribute("role")).toBe(false); + expect(flyout?.textContent).toContain("Workers"); + + // The popup opens from its parent trigger, so the top-level sub-list sits + // flush: a wrapper collapses the indent and hides the tree connector line + // via direct-child (`>`) overrides. Icons still pass through. + const flyoutSub = flyout?.querySelector( + "[data-sidebar='menu-sub']", + ) as HTMLElement; + expect(flyoutSub).toBeTruthy(); + // The line is still in the DOM (hidden via CSS), not removed. + expect( + flyoutSub.querySelector(":scope > li[aria-hidden='true']"), + ).toBeTruthy(); + // Wrapper carries the top-level-only flatten overrides. + const flattenWrapper = flyoutSub.parentElement as HTMLElement; + expect(flattenWrapper.className).toContain( + "[&>[data-sidebar=menu-sub]]:pl-0", + ); + expect(flattenWrapper.className).toContain( + "[&>[data-sidebar=menu-sub]>li[aria-hidden=true]]:hidden", + ); + expect(flyout?.querySelector("[data-testid='sub-icon']")).toBeTruthy(); + expect( + (flyout as HTMLElement).style.getPropertyValue("--sidebar-active-bg"), + ).toBe("var(--color-kumo-tint)"); + expect(trigger.getAttribute("data-flyout-open")).toBe("true"); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(content.getAttribute("aria-hidden")).toBe("true"); + + fireEvent.click(trigger); + + expect(document.querySelector(OPEN_FLYOUT)).toBeNull(); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + expect(content.getAttribute("aria-hidden")).toBe("false"); + + fireEvent.click(trigger); + fireEvent.mouseEnter(collapsible); + + fireEvent.mouseLeave(collapsible); + fireEvent.mouseEnter(document.querySelector(OPEN_FLYOUT) as HTMLElement); + act(() => { + vi.advanceTimersByTime(120); + }); + + expect(document.querySelector(OPEN_FLYOUT)).toBeTruthy(); + + fireEvent.mouseLeave(document.querySelector(OPEN_FLYOUT) as HTMLElement); + act(() => { + vi.advanceTimersByTime(120); + }); + + expect(document.querySelector(OPEN_FLYOUT)).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + // Single collapsible driving all flyout tests: the provider toggle, initial + // rail/section open states, and the trigger's optional `tooltip`. + function FlyoutFixture({ + flyout, + peekable, + railOpen = true, + sectionOpen = false, + tooltip, + }: { + flyout?: boolean; + peekable?: boolean; + railOpen?: boolean; + sectionOpen?: boolean; + tooltip?: string; + }) { + return ( + + + + + + + Compute + + + } + /> + + + Workers + + + + + + + + ); + } + + const flyoutEl = () => document.querySelector(OPEN_FLYOUT); + + it("should wait for a peek to finish expanding before showing a flyout", () => { + vi.useFakeTimers(); + try { + render(); + const peekZone = document.querySelector("[data-sidebar='peek-zone']")!; + const collapsible = screen.getByRole("button", { + name: /Compute/i, + }).parentElement!; + + // The pointer enters the rail straight onto the section: peek starts, + // but the flyout holds off while the sidebar expands under it. + fireEvent.mouseEnter(peekZone); + fireEvent.mouseEnter(collapsible); + expect(flyoutEl()).toBeNull(); + + // Once the peek settles, the resting pointer gets its flyout, laid + // out as in an expanded sidebar (no title). + act(() => { + vi.advanceTimersByTime(400); + }); + expect(flyoutEl()).toBeTruthy(); + expect( + document.querySelector("[data-sidebar='collapsible-flyout-title']"), + ).toBeNull(); + + // Leaving keeps the peek briefly (to cross into the popup), then ends + // it and closes the flyout. + fireEvent.mouseLeave(collapsible); + fireEvent.mouseLeave(peekZone); + act(() => { + vi.advanceTimersByTime(400); + }); + expect(flyoutEl()).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("should not show a flyout when flyout is unset (opt-in)", () => { + render(); + fireEvent.mouseEnter( + screen.getByRole("button", { name: /Compute/i }).parentElement!, + ); + expect(flyoutEl()).toBeNull(); + }); + + it("should show a flyout when the Provider opts in", () => { + render(); + fireEvent.mouseEnter( + screen.getByRole("button", { name: /Compute/i }).parentElement!, + ); + expect(flyoutEl()).toBeTruthy(); + }); + + it("should not show a flyout for an open section when the rail is expanded", () => { + render(); + fireEvent.mouseEnter( + screen.getByRole("button", { name: /Compute/i }).parentElement!, + ); + // Expanded rail already shows the section inline → no popup. + expect(flyoutEl()).toBeNull(); + }); + + it("should show a flyout for an open section when the rail is collapsed", () => { + // A collapsed rail cannot render inline content, so even a section marked + // open must fall back to the hover popup. + render(); + fireEvent.mouseEnter( + screen.getByRole("button", { name: /Compute/i }).parentElement!, + ); + expect(flyoutEl()).toBeTruthy(); + }); + + // Positioning itself is Base UI's job; assert we anchor beside the trigger, + // away from the sidebar's own side, aligned to its start. + it.each([ + ["left", "right"], + ["right", "left"], + ] as const)( + "should anchor the flyout beside the trigger (sidebar %s → %s)", + (side, flyoutSide) => { + render( + + + + + + Compute} + /> + + + Workers + + + + + + + , + ); + fireEvent.mouseEnter( + screen.getByRole("button", { name: /Compute/i }).parentElement!, + ); + const flyout = flyoutEl() as HTMLElement; + expect(flyout.getAttribute("data-side")).toBe(flyoutSide); + expect(flyout.getAttribute("data-align")).toBe("start"); + }, + ); + + const titleEl = () => + document.querySelector( + "[data-sidebar='collapsible-flyout-title']", + ) as HTMLElement | null; + + it("should replace the tooltip with a popup title on collapsed flyout", () => { + // With a flyout available the tooltip is suppressed (showTooltip is + // gated on !hasFlyout) and the popup carries the parent's name instead. + // The tooltip popup is portaled + hover/delay driven and unreliable under + // happy-dom, so we assert the observable outcome: flyout + title present, + // no tooltip popup leaked into the DOM. + render(); + + fireEvent.mouseEnter( + screen.getByRole("button", { name: /Compute/i }).parentElement!, + ); + + expect(flyoutEl()).toBeTruthy(); + expect(titleEl()?.textContent).toBe("Compute"); + expect(document.querySelector(".kumo-tooltip-popup")).toBeNull(); + }); + + it("should title the collapsed popup from the trigger's tooltip", () => { + render(); + fireEvent.mouseEnter( + screen.getByRole("button", { name: /Compute/i }).parentElement!, + ); + expect(titleEl()?.textContent).toBe("Compute"); + }); + + it("should title the collapsed popup from the trigger's text when no tooltip", () => { + render(); + fireEvent.mouseEnter( + screen.getByRole("button", { name: /Compute/i }).parentElement!, + ); + // Text extraction skips the chevron element → just the label. + expect(titleEl()?.textContent).toBe("Compute"); + }); + + it("should not title the popup when the rail is expanded", () => { + render(); + fireEvent.mouseEnter( + screen.getByRole("button", { name: /Compute/i }).parentElement!, + ); + expect(flyoutEl()).toBeTruthy(); + expect(titleEl()).toBeNull(); + }); + + it("should title only the top-level popup, not nested ones", () => { + render(); + + // Open the top-level popup, then the nested one. + fireEvent.mouseEnter( + screen.getByRole("button", { name: /Compute/i }).parentElement!, + ); + const popup1 = flyoutEl() as HTMLElement; + fireEvent.mouseEnter( + popup1.querySelector("[data-sidebar='menu-sub-button']")!.parentElement!, + ); + + expect(document.querySelectorAll(OPEN_FLYOUT).length).toBe(2); + // Nested popup omits the title — its parent row shows in popup 1 to the + // left — so only the top-level "Compute" title exists. + const titles = document.querySelectorAll( + "[data-sidebar='collapsible-flyout-title']", + ); + expect(titles.length).toBe(1); + expect(titles[0].textContent).toBe("Compute"); + }); + + it("should keep flyout items clickable but out of the tab order", () => { + render(); + + const trigger = screen.getByRole("button", { name: /Compute/i }); + const collapsible = trigger.parentElement!; + + // Keyboard never opens the popup — the trigger does not advertise one. + expect(trigger.hasAttribute("aria-haspopup")).toBe(false); + + fireEvent.mouseEnter(collapsible); + const flyout = document.querySelector(OPEN_FLYOUT) as HTMLElement; + expect(flyout).toBeTruthy(); + + // Items inside are removed from the tab order (keyboard-invisible)... + const subButton = flyout.querySelector( + "[data-sidebar='menu-sub-button']", + ) as HTMLElement; + expect(subButton).toBeTruthy(); + expect(subButton.getAttribute("tabindex")).toBe("-1"); + + // ...but remain mouse-clickable. + const onClick = vi.fn(); + subButton.addEventListener("click", onClick); + fireEvent.click(subButton); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + function NestedCollapsibleTest({ + railOpen = true, + tooltip, + }: { + railOpen?: boolean; + tooltip?: string; + } = {}) { + return ( + + + + + + + Compute + + + } + /> + + + + + + Workers & Pages + + + } + /> + + + + Overview + + + + + + + + + + + + + ); + } + + it("should dismiss the whole popup stack when a leaf item is clicked", () => { + render(); + + const trigger = screen.getByRole("button", { name: /Compute/i }); + fireEvent.mouseEnter(trigger.parentElement!); + + const popup1 = document.querySelector(OPEN_FLYOUT) as HTMLElement; + expect(popup1).toBeTruthy(); + + // Hover the nested parent inside popup 1 to open popup 2. + const nestedParent = popup1.querySelector( + "[data-sidebar='menu-sub-button']", + ) as HTMLElement; + fireEvent.mouseEnter(nestedParent.parentElement!); + + const popups = () => document.querySelectorAll(OPEN_FLYOUT); + expect(popups().length).toBe(2); + + // Click a leaf (link) in the deepest popup: navigation is allowed and the + // entire popup stack is dismissed. (Popups are aria-hidden, so query the + // DOM directly rather than by accessible role.) + const popup2 = popups()[1] as HTMLElement; + const overview = popup2.querySelector('a[href="/overview"]') as HTMLElement; + expect(overview).toBeTruthy(); + const clickEvent = fireEvent.click(overview); + expect(clickEvent).toBe(true); // not preventDefault-ed → navigation proceeds + expect(popups().length).toBe(0); + }); + + it("should keep the popup stack open on a press inside a nested popup", async () => { + render(); + + fireEvent.mouseEnter( + screen.getByRole("button", { name: /Compute/i }).parentElement!, + ); + const popup1 = document.querySelector(OPEN_FLYOUT) as HTMLElement; + fireEvent.mouseEnter( + popup1.querySelector("[data-sidebar='menu-sub-button']")!.parentElement!, + ); + const popups = () => document.querySelectorAll(OPEN_FLYOUT); + expect(popups().length).toBe(2); + + // Popup 2 sits outside popup 1's DOM but inside its React tree, so it + // counts as inside the stack and nothing closes. + const popup2 = popups()[1] as HTMLElement; + fireEvent.pointerDown(popup2); + fireEvent.mouseDown(popup2); + await act(async () => {}); + expect(popups().length).toBe(2); + + // A real outside press closes the stack. + fireEvent.pointerDown(screen.getByTestId("main")); + fireEvent.mouseDown(screen.getByTestId("main")); + await act(async () => {}); + expect(popups().length).toBe(0); + }); + + it("should not toggle a parent's disclosure when clicked inside a popup", () => { + render(); + + const trigger = screen.getByRole("button", { name: /Compute/i }); + fireEvent.mouseEnter(trigger.parentElement!); + + const popup1 = document.querySelector(OPEN_FLYOUT) as HTMLElement; + const nestedParent = popup1.querySelector( + "[data-sidebar='menu-sub-button']", + ) as HTMLElement; + + // Chevron on a parent inside the popup is static (never rotated open). + const chevron = nestedParent.querySelector("svg") as SVGElement; + expect(chevron.getAttribute("class") ?? "").not.toContain("rotate-90"); + + // A hover-only parent (children, no href) has no click action → no pointer. + // `!` beats the unlayered cursor:pointer base rule in kumo.css. (Only the + // class can be checked here; happy-dom doesn't load Tailwind CSS.) + expect(nestedParent.className).toContain("cursor-default!"); + + // Clicking the parent keeps the popup open (children are hover-only) and + // does not flip the disclosure — so hovering it still opens its popup. + fireEvent.click(nestedParent); + expect(document.querySelectorAll(OPEN_FLYOUT).length).toBe(1); + expect(nestedParent.getAttribute("data-open")).toBeNull(); + + fireEvent.mouseEnter(nestedParent.parentElement!); + expect(document.querySelectorAll(OPEN_FLYOUT).length).toBe(2); + }); + + function HrefParentTest() { + return ( + + + + + + + Compute + + + } + /> + + + + + + Workers & Pages + + + } + /> + + + + Overview + + + + + + + + + + + + + ); + } + + it("should navigate and dismiss all popups when a parent with href is clicked", () => { + render(); + + const trigger = screen.getByRole("button", { name: /Compute/i }); + fireEvent.mouseEnter(trigger.parentElement!); + + const popup1 = document.querySelector(OPEN_FLYOUT) as HTMLElement; + const nestedParent = popup1.querySelector( + 'a[href="/workers"]', + ) as HTMLElement; + expect(nestedParent).toBeTruthy(); + + // A parent with an href is a real link → keeps the pointer cursor. + expect(nestedParent.className).not.toContain("cursor-default"); + + // Clicking navigates (not preventDefault-ed) and dismisses the popup stack. + const clickEvent = fireEvent.click(nestedParent); + expect(clickEvent).toBe(true); + expect(document.querySelectorAll(OPEN_FLYOUT).length).toBe(0); + }); + it("should have role=region on content", () => { render(); const content = screen.getByTestId("collapsible-content"); @@ -807,6 +1349,23 @@ describe("Sidebar peeking", () => { expect(screen.getByTestId("state-reader").dataset.peeking).toBe("false"); }); + it("should peek once peekable turns on while already collapsed", () => { + const ui = (peekable: boolean) => ( + + + + + + ); + const { rerender } = render(ui(false)); + // Nothing else changes, so the context must still pick up the new prop. + rerender(ui(true)); + + fireEvent.mouseEnter(document.querySelector("[data-sidebar='peek-zone']")!); + + expect(screen.getByTestId("state-reader").dataset.state).toBe("peeking"); + }); + it("should peek on mouseEnter when collapsed and peekable", () => { render( @@ -1047,6 +1606,37 @@ describe("Sidebar.MenuButton", () => { expect(link!.getAttribute("data-active")).toBe("true"); expect(link!.getAttribute("data-kumo-part")).toBe("menu-button-link"); }); + + // The collapsed rail labels every item via a tooltip. The popup render is + // portal + hover-delay driven (unreliable under happy-dom), so we assert the + // wrapping: a labelled button becomes a tooltip trigger, a bare one does not. + it("should tooltip a collapsed item from its own text when no tooltip is set", () => { + render( + + + + Analytics + + + , + ); + const button = screen.getByRole("button", { name: "Analytics" }); + expect(button.hasAttribute("data-base-ui-tooltip-trigger")).toBe(true); + }); + + it("should not tooltip a collapsed item with no label", () => { + render( + + + + + + + , + ); + const button = screen.getByRole("button", { name: "icon only" }); + expect(button.hasAttribute("data-base-ui-tooltip-trigger")).toBe(false); + }); }); describe("Sidebar.MenuSub", () => { diff --git a/packages/kumo/src/components/sidebar/sidebar.tsx b/packages/kumo/src/components/sidebar/sidebar.tsx index 93e301877..2013d7ac6 100644 --- a/packages/kumo/src/components/sidebar/sidebar.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.tsx @@ -1,3 +1,5 @@ +import { PreviewCard as PreviewCardBase } from "@base-ui/react/preview-card"; +import { ScrollArea as ScrollAreaBase } from "@base-ui/react/scroll-area"; import React, { type ComponentPropsWithoutRef, type CSSProperties, @@ -7,20 +9,20 @@ import React, { useCallback, useContext, useEffect, - useMemo, useId, + useMemo, useRef, useState, useSyncExternalStore, } from "react"; -import { ScrollArea as ScrollAreaBase } from "@base-ui/react/scroll-area"; import { CaretRightIcon, XIcon } from "@phosphor-icons/react"; import { cn } from "../../utils/cn"; import { useLinkComponent } from "../../utils/link-provider"; +import { usePortalContainer } from "../../utils/portal-provider"; +import { Button } from "../button"; import { SkeletonLine } from "../loader/skeleton-line"; import { Tooltip, TooltipProvider } from "../tooltip"; -import { Button } from "../button"; // ============================================================================ // Variants (required by Kumo convention) @@ -97,7 +99,11 @@ const SIDEBAR_WIDTH_ICON = "57px"; const SIDEBAR_EASING = "cubic-bezier(0.77, 0, 0.175, 1)"; const SIDEBAR_ANIMATION_DURATION_MS = 250; const TRANSITION_FALLBACK_GRACE_MS = 50; +/** Grace before a peek collapses on mouse leave, with hover flyouts on. */ +const PEEK_LEAVE_DELAY_MS = 120; const MOBILE_BREAKPOINT = 768; +/** Label typography shared by group labels and flyout popup titles. */ +const SIDEBAR_LABEL_TEXT = "truncate text-sm font-medium text-kumo-subtle"; const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; @@ -209,7 +215,10 @@ export interface SidebarContextValue { setIsResizing: (resizing: boolean) => void; setWidth: (width: number) => void; isPeeking: boolean; + /** `true` once a peek has finished expanding. Hover flyouts wait for it. */ + isPeekSettled: boolean; peekable: boolean; + flyout: boolean; startPeek: () => void; stopPeek: () => void; contained: boolean; @@ -322,6 +331,17 @@ export interface SidebarProviderProps { * @default false */ peekable?: boolean; + /** + * When true, hovering a closed collapsible section shows its nested content in + * a floating popup instead of expanding inline. + * + * When the sidebar rail is collapsed, flyouts appear regardless of the + * section's open state (inline content cannot render in a collapsed rail). + * Combined with `peekable`, the peek handles the collapsed rail: flyouts + * wait until it has fully expanded. + * @default false + */ + flyout?: boolean; /** * Duration of sidebar expand/collapse animation in milliseconds. * @default 250 @@ -372,6 +392,7 @@ function SidebarProvider({ onWidthChange, contained = false, peekable = false, + flyout = false, animationDuration = SIDEBAR_ANIMATION_DURATION_MS, mobileBreakpoint, children, @@ -383,6 +404,7 @@ function SidebarProvider({ const [width, setWidthState] = useState(defaultWidth); const [isResizing, setIsResizing] = useState(false); const [isPeeking, setIsPeeking] = useState(false); + const [isPeekSettled, setIsPeekSettled] = useState(false); // When controlled (`openProp` provided), use it directly for mobile too. // When uncontrolled, use internal `_openMobile` state. @@ -437,6 +459,7 @@ function SidebarProvider({ const stopPeek = useCallback(() => { setIsPeeking(false); + setIsPeekSettled(false); }, []); const state: SidebarState = isPeeking @@ -538,8 +561,6 @@ function SidebarProvider({ [scrollToItem], ); - // eslint-disable-next-line react-hooks/exhaustive-deps -- all values are - // either stable (props, setters) or derived from state that triggers re-render const contextValue = useMemo( () => ({ state, @@ -560,7 +581,9 @@ function SidebarProvider({ setIsResizing, setWidth, isPeeking, + isPeekSettled, peekable, + flyout, startPeek, stopPeek, contained, @@ -569,7 +592,42 @@ function SidebarProvider({ scrollToItem, scrollItemIntoView, }), - [state, open, openMobile, isMobile, width, isResizing, isPeeking], + [ + state, + open, + setOpen, + openMobile, + setOpenMobile, + isMobile, + toggleSidebar, + variant, + side, + collapsible, + width, + resizable, + minWidth, + maxWidth, + isResizing, + setWidth, + isPeeking, + isPeekSettled, + peekable, + flyout, + startPeek, + stopPeek, + contained, + animationDuration, + registerItem, + scrollToItem, + scrollItemIntoView, + ], + ); + + const completePeek = useOpenChangeComplete( + isPeeking, + animationDuration, + setIsPeekSettled, + !isMobile, ); const completeDesktopOpenChange = useOpenChangeComplete( @@ -591,15 +649,22 @@ function SidebarProvider({ const handleOpenTransitionEnd = useCallback( (event: React.TransitionEvent) => { const target = event.target as HTMLElement; + if (target.closest("[data-sidebar-wrapper]") !== event.currentTarget) { + return; + } if ( - target.closest("[data-sidebar-wrapper]") === event.currentTarget && target.dataset.sidebar === "sidebar" && event.propertyName === transitionProperty ) { completeOpenChange(); + } else if ( + target.dataset.sidebar === "content-container" && + event.propertyName === "width" + ) { + completePeek(); } }, - [completeOpenChange, transitionProperty], + [completeOpenChange, completePeek, transitionProperty], ); return ( @@ -696,6 +761,7 @@ const SidebarRoot = forwardRef( startPeek, stopPeek, contained, + flyout, } = useSidebar(); // --- Mobile a11y hooks (must be before early returns) --- @@ -786,15 +852,36 @@ const SidebarRoot = forwardRef( [stopPeek], ); + const stopPeekTimeoutRef = useRef | null>( + null, + ); + const clearStopPeekTimeout = useCallback(() => { + if (stopPeekTimeoutRef.current) { + clearTimeout(stopPeekTimeoutRef.current); + stopPeekTimeoutRef.current = null; + } + }, []); + + useEffect(() => clearStopPeekTimeout, [clearStopPeekTimeout]); + const handlePeekMouseEnter = useCallback(() => { isPointerInPeekZoneRef.current = true; + clearStopPeekTimeout(); startPeek(); - }, [startPeek]); + }, [clearStopPeekTimeout, startPeek]); const handlePeekMouseLeave = useCallback(() => { isPointerInPeekZoneRef.current = false; - stopPeek(); - }, [stopPeek]); + clearStopPeekTimeout(); + // Hover flyouts portal outside the sidebar, past a small gap. Give the + // pointer time to cross it: entering a flyout re-enters the peek zone + // (portals bubble through the React tree) and cancels the stop. + if (flyout) { + stopPeekTimeoutRef.current = setTimeout(stopPeek, PEEK_LEAVE_DELAY_MS); + } else { + stopPeek(); + } + }, [clearStopPeekTimeout, flyout, stopPeek]); if (collapsible === "none") { return ( @@ -1274,7 +1361,8 @@ const SidebarGroupLabel = forwardRef<

( const { state, peekable, registerItem } = useSidebar(); const LinkComponent = useLinkComponent(); const isInsideMenuItem = useContext(MenuItemContext); + const { hasFlyout } = useContext(SidebarCollapseContext); const registerAutoWrap = useCallback( (node: HTMLLIElement | null) => { @@ -1548,6 +1642,7 @@ const SidebarMenuButton = forwardRef( "text-kumo-default", "transition-[color,box-shadow,outline] duration-(--sidebar-animation-duration)", !active && "hover:bg-(--sidebar-active-bg)", + !active && "data-[flyout-open]:bg-(--sidebar-active-bg)", // Active state active && "bg-(--sidebar-active-bg)", // When a child sub-button is active, don't show active styling on the parent trigger @@ -1602,16 +1697,18 @@ const SidebarMenuButton = forwardRef( ); } - // Always wrap in Tooltip when tooltip text is provided so the DOM - // structure stays stable across expand/collapse — preventing React from - // remounting the button (which would kill CSS transitions). - // The tooltip popup only shows when collapsed and peeking is disabled — - // when peekable, hovering reveals the full sidebar so tooltips are redundant. - const showTooltip = state === "collapsed" && !peekable; - if (tooltip) { + // Label every childless item on the collapsed rail: explicit `tooltip` + // wins, else the button's own text. Wrapping is unconditional (when there's + // any label) so the DOM stays stable across expand/collapse — otherwise + // React remounts the button and kills its CSS transitions. + // Suppressed when peekable (hover reveals the full sidebar) or when a hover + // flyout owns the label (its popup carries the title and sits in front). + const showTooltip = state === "collapsed" && !peekable && !hasFlyout; + const tooltipText = tooltip ?? getElementText(children); + if (tooltipText) { button = ( void; completeOpenChange: () => void; + closeFlyout: () => void; } const isCollapsibleContentShown = ( @@ -2214,12 +2316,107 @@ const isCollapsibleContentShown = ( const SidebarCollapseContext = createContext({ contentId: "", isOpen: true, + isFlyoutOpen: false, isCollapsible: false, autoScrollOnOpen: false, + hasFlyout: false, toggle: () => {}, completeOpenChange: () => {}, + closeFlyout: () => {}, }); +interface SidebarFlyoutContextValue { + /** True when descendants are rendered inside a hover-flyout popup. */ + insideFlyout: boolean; + /** Close this popup and every ancestor popup in the chain. */ + dismissAll: () => void; +} + +const SidebarFlyoutContext = createContext({ + insideFlyout: false, + dismissAll: () => {}, +}); + +/** Gap between the trigger and the flyout popup in px. */ +const FLYOUT_GAP = 7; +/** Minimum viewport inset for the flyout popup in px. */ +const FLYOUT_VIEWPORT_INSET = 8; +const FLYOUT_TRIGGER_SELECTOR = + "[data-sidebar='menu-button'], [data-sidebar='menu-sub-button']"; +/** + * Flyout popup inner padding in px (must match the `p-1.5` class below). + * When there's no title, offsetting the popup top by this amount aligns its + * first item with the row that opened it, so nested popups don't drift. + */ +const FLYOUT_PADDING = 6; +/** Flyout title row height in px (must match its `min-h-8.5` class). */ +const FLYOUT_TITLE_HEIGHT = 34; +/** + * How far to pull a titled popup up so its first item, not the title, aligns + * with the trigger: the pointer moves straight across onto it. The title's + * `-mt-1.5` cancels the top padding, so the first item sits below the title + * plus its `mb-1.5` (= FLYOUT_PADDING). + */ +const FLYOUT_TITLE_OFFSET = FLYOUT_TITLE_HEIGHT + FLYOUT_PADDING; + +function getCollapsibleFlyoutContent(children: ReactNode) { + let flyoutContent: ReactNode = null; + + React.Children.forEach(children, (child) => { + if ( + React.isValidElement(child) && + (child.type as { displayName?: string })?.displayName === + "Sidebar.CollapsibleContent" + ) { + flyoutContent = (child.props as { children?: ReactNode }).children; + } + }); + + return flyoutContent; +} + +/** + * The button's own text label: only direct string/number children. Skips the + * icon element and nested elements (e.g. a MenuBadge) so a popup title reads + * "Workers", not "Workers3". + */ +function getElementText(children: ReactNode): string { + let text = ""; + React.Children.forEach(children, (child) => { + if (typeof child === "string" || typeof child === "number") { + text += child; + } + }); + return text.trim(); +} + +/** + * Label for a collapsible's flyout popup: the trigger's `tooltip` when set, + * otherwise its plain-text children. Returns null when neither is available. + */ +function getCollapsibleTriggerLabel(children: ReactNode): string | null { + let label: string | null = null; + + React.Children.forEach(children, (child) => { + if ( + React.isValidElement(child) && + (child.type as { displayName?: string })?.displayName === + "Sidebar.CollapsibleTrigger" + ) { + const render = (child.props as { render?: React.ReactElement }).render; + if (React.isValidElement(render)) { + const props = render.props as { + tooltip?: string; + children?: ReactNode; + }; + label = props.tooltip?.trim() || getElementText(props.children) || null; + } + } + }); + + return label; +} + export interface SidebarCollapsibleProps extends ComponentPropsWithoutRef<"div"> { /** Initial open state (uncontrolled). @default false */ defaultOpen?: boolean; @@ -2268,6 +2465,8 @@ const SidebarCollapsible = forwardRef( autoScrollOnOpen = false, className, children, + onMouseEnter, + onMouseLeave, ...props }, ref, @@ -2275,10 +2474,69 @@ const SidebarCollapsible = forwardRef( const sidebar = useSidebar(); const { animationDuration } = sidebar; const [internalOpen, setInternalOpen] = useState(defaultOpen); + const [flyoutOpen, setFlyoutOpen] = useState(false); + const [flyoutNode, setFlyoutNode] = useState(null); const isOpen = openProp ?? internalOpen; const isContentShown = isCollapsibleContentShown(isOpen, sidebar); const contentId = useId(); const keyboardExpandedRef = useRef(false); + const portalContainer = usePortalContainer(); + const containerRef = useRef(null); + const closeFlyoutTimeoutRef = useRef | null>( + null, + ); + const flyoutContent = getCollapsibleFlyoutContent(children); + const hasFlyoutContent = flyoutContent !== null; + const flyoutLabel = getCollapsibleTriggerLabel(children); + const { + isMobile, + side, + state, + open: sidebarOpen, + peekable, + isPeeking, + isPeekSettled, + flyout: flyoutEnabled, + } = useSidebar(); + // With `peekable`, hovering the collapsed rail peeks instead: flyouts wait + // until the peek has fully expanded, then behave as in an expanded sidebar. + const isBlockedByPeek = + peekable && !sidebarOpen && !(isPeeking && isPeekSettled); + const isHoveredRef = useRef(false); + + const clearFlyoutCloseTimeout = useCallback(() => { + if (closeFlyoutTimeoutRef.current) { + clearTimeout(closeFlyoutTimeoutRef.current); + closeFlyoutTimeoutRef.current = null; + } + }, []); + + const closeFlyout = useCallback(() => { + clearFlyoutCloseTimeout(); + closeFlyoutTimeoutRef.current = setTimeout(() => { + setFlyoutOpen(false); + }, 120); + }, [clearFlyoutCloseTimeout]); + + const closeFlyoutImmediately = useCallback(() => { + clearFlyoutCloseTimeout(); + setFlyoutOpen(false); + }, [clearFlyoutCloseTimeout]); + + // Chain up ancestor popups so a click anywhere in the stack dismisses all. + const parentFlyout = useContext(SidebarFlyoutContext); + // The title only renders for a collapsed, top-level popup with a label. + const showFlyoutTitle = + state === "collapsed" && !parentFlyout.insideFlyout && !!flyoutLabel; + const dismissAll = useCallback(() => { + closeFlyoutImmediately(); + parentFlyout.dismissAll(); + }, [closeFlyoutImmediately, parentFlyout]); + + const flyoutProviderValue = useMemo( + () => ({ insideFlyout: true, dismissAll }), + [dismissAll], + ); const toggle = useCallback(() => { const next = !isOpen; @@ -2294,18 +2552,126 @@ const SidebarCollapsible = forwardRef( onOpenChangeComplete, ); + const openFlyout = useCallback(() => { + if ( + !flyoutEnabled || + isMobile || + isBlockedByPeek || + !hasFlyoutContent || + !containerRef.current + ) { + return; + } + // A collapsed rail cannot render inline content (see + // SidebarCollapsibleContent), so show the flyout regardless of open state. + // Otherwise only closed sections get one — open ones show inline. + if (state !== "collapsed" && isOpen) { + return; + } + clearFlyoutCloseTimeout(); + setFlyoutOpen(true); + }, [ + clearFlyoutCloseTimeout, + hasFlyoutContent, + isBlockedByPeek, + isMobile, + isOpen, + flyoutEnabled, + state, + ]); + + const getFlyoutAnchor = useCallback( + () => + containerRef.current?.querySelector(FLYOUT_TRIGGER_SELECTOR) ?? + containerRef.current, + [], + ); + + // Opening stays ours (hover, peek, keyboard rules); Base UI only reports + // dismissals (Escape, outside press). + const handleFlyoutOpenChange = useCallback( + (open: boolean) => { + if (!open) closeFlyoutImmediately(); + }, + [closeFlyoutImmediately], + ); + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + onMouseEnter?.(e); + isHoveredRef.current = true; + openFlyout(); + }, + [onMouseEnter, openFlyout], + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + onMouseLeave?.(e); + isHoveredRef.current = false; + closeFlyout(); + }, + [closeFlyout, onMouseLeave], + ); + + // Close when a peek ends; open for the resting pointer once it settles + // (no fresh mouseenter fires when the sidebar expands under it). + const wasBlockedByPeekRef = useRef(isBlockedByPeek); + useEffect(() => { + const wasBlocked = wasBlockedByPeekRef.current; + wasBlockedByPeekRef.current = isBlockedByPeek; + if (isBlockedByPeek) { + closeFlyoutImmediately(); + } else if (wasBlocked && isHoveredRef.current) { + openFlyout(); + } + }, [isBlockedByPeek, closeFlyoutImmediately, openFlyout]); + const contextValue = useMemo( () => ({ contentId, isOpen, + isFlyoutOpen: flyoutOpen, isCollapsible: true, autoScrollOnOpen, + hasFlyout: flyoutEnabled && hasFlyoutContent, toggle, completeOpenChange, + closeFlyout: closeFlyoutImmediately, }), - [contentId, isOpen, autoScrollOnOpen, toggle, completeOpenChange], + [ + contentId, + isOpen, + flyoutOpen, + autoScrollOnOpen, + flyoutEnabled, + hasFlyoutContent, + toggle, + completeOpenChange, + closeFlyoutImmediately, + ], + ); + + useEffect( + () => () => { + clearFlyoutCloseTimeout(); + }, + [clearFlyoutCloseTimeout], ); + // The flyout is a mouse-only affordance: it stays clickable, but its + // contents are removed from the tab order so keyboard users transparently + // get the inline expansion instead (handleFocusIn). Paired with + // aria-hidden on the popup, this avoids focusable content in a hidden + // subtree. + useEffect(() => { + if (!flyoutNode) return; + const focusables = flyoutNode.querySelectorAll( + "a[href], button, input, select, textarea, [tabindex]", + ); + for (const el of focusables) el.setAttribute("tabindex", "-1"); + }, [flyoutNode, flyoutContent]); + const handleFocusIn = useCallback( (e: React.FocusEvent) => { // Auto-expand on keyboard focus (focus-visible) when collapsed @@ -2338,14 +2704,103 @@ const SidebarCollapsible = forwardRef( return (
{ + containerRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }} data-open={isOpen || undefined} className={cn("min-w-0", className)} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} onFocus={handleFocusIn} onBlur={handleFocusOut} {...props} > {children} + {hasFlyoutContent && ( + + + + + + + + + + )}
); @@ -2376,18 +2831,49 @@ export interface SidebarCollapsibleTriggerProps { * ``` */ function SidebarCollapsibleTrigger({ render }: SidebarCollapsibleTriggerProps) { - const { contentId, isOpen, toggle } = useContext(SidebarCollapseContext); + const { contentId, isOpen, isFlyoutOpen, toggle, closeFlyout } = useContext( + SidebarCollapseContext, + ); + const { insideFlyout } = useContext(SidebarFlyoutContext); + + const hasHref = Boolean((render.props as { href?: string }).href); return React.cloneElement(render, { "aria-expanded": isOpen, "aria-controls": contentId, "data-open": isOpen || undefined, + "data-flyout-open": isFlyoutOpen || undefined, + // Inside a popup, a parent with no href only reveals children on hover — + // it has no click action, so drop the pointer cursor. `!` is required: the + // base clickable-affordance rule in kumo.css is unlayered and would + // otherwise win over the (layered) utility. + ...(insideFlyout && !hasHref + ? { + className: cn( + (render.props as { className?: string }).className, + "cursor-default!", + ), + } + : {}), onClick: (e: React.MouseEvent) => { // Call any existing onClick on the render element const existingOnClick = ( render.props as { onClick?: (e: React.MouseEvent) => void } ).onClick; existingOnClick?.(e); + + // Inside a popup, children are revealed by hover only — never toggle the + // disclosure (that would set isOpen and disable this item's own hover + // popup). + if (insideFlyout) { + // A parent with no href has no click action: keep the flyout open by + // stopping the bubble to the container's dismiss-all. A parent with an + // href navigates, so let it bubble and dismiss the whole popup stack. + if (!hasHref) e.stopPropagation(); + return; + } + + closeFlyout(); toggle(); }, } as Record); @@ -2751,30 +3237,30 @@ export const Sidebar = Object.assign(SidebarRoot, { }); export { - SidebarProvider, - SidebarRoot, - SidebarHeader, + SidebarClose, + SidebarCollapsible, + SidebarCollapsibleContent, + SidebarCollapsibleTrigger, SidebarContent, SidebarFooter, - SidebarLoading, SidebarGroup, SidebarGroupLabel, + SidebarHeader, + SidebarLoading, SidebarMenu, - SidebarMenuItem, - SidebarMenuButton, SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuChevron, + SidebarMenuItem, SidebarMenuSub, - SidebarMenuSubItem, SidebarMenuSubButton, - SidebarSeparator, - SidebarTrigger, - SidebarClose, + SidebarMenuSubItem, + SidebarProvider, SidebarRail, SidebarResizeHandle, - SidebarMenuChevron, - SidebarCollapsible, - SidebarCollapsibleTrigger, - SidebarCollapsibleContent, - SidebarSlidingViews, + SidebarRoot, + SidebarSeparator, SidebarSlidingView, + SidebarSlidingViews, + SidebarTrigger, };