From 6799dffc31af0abd42b4a32365858f0e361c0b26 Mon Sep 17 00:00:00 2001 From: Sophia Alfred Date: Fri, 31 Jul 2026 10:55:26 +0100 Subject: [PATCH 1/4] feat(sidebar): add hover preview for nested content --- .changeset/soft-lions-hover.md | 5 + .../src/components/sidebar/sidebar.test.tsx | 72 +++++++++ .../kumo/src/components/sidebar/sidebar.tsx | 144 +++++++++++++++++- 3 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 .changeset/soft-lions-hover.md diff --git a/.changeset/soft-lions-hover.md b/.changeset/soft-lions-hover.md new file mode 100644 index 000000000..9a4031093 --- /dev/null +++ b/.changeset/soft-lions-hover.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": patch +--- + +Add hover previews for nested sidebar collapsible content. diff --git a/packages/kumo/src/components/sidebar/sidebar.test.tsx b/packages/kumo/src/components/sidebar/sidebar.test.tsx index 61a3c878c..e9a6878e7 100644 --- a/packages/kumo/src/components/sidebar/sidebar.test.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.test.tsx @@ -599,6 +599,78 @@ describe("Sidebar.Collapsible", () => { expect(trigger.getAttribute("aria-controls")).toBe(content.id); }); + it("should preview nested content 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 preview = document.querySelector( + "[data-sidebar='collapsible-preview']", + ); + expect(preview).toBeTruthy(); + expect(preview?.getAttribute("aria-hidden")).toBe("true"); + expect(preview?.hasAttribute("inert")).toBe(false); + expect(preview?.textContent).toContain("Workers"); + expect( + (preview as HTMLElement).style.getPropertyValue( + "--sidebar-active-bg", + ), + ).toBe("var(--color-kumo-tint)"); + expect(trigger.getAttribute("data-preview-open")).toBe("true"); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(content.getAttribute("aria-hidden")).toBe("true"); + + fireEvent.click(trigger); + + expect( + document.querySelector("[data-sidebar='collapsible-preview']"), + ).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( + "[data-sidebar='collapsible-preview']", + ) as HTMLElement, + ); + act(() => { + vi.advanceTimersByTime(120); + }); + + expect( + document.querySelector("[data-sidebar='collapsible-preview']"), + ).toBeTruthy(); + + fireEvent.mouseLeave( + document.querySelector( + "[data-sidebar='collapsible-preview']", + ) as HTMLElement, + ); + act(() => { + vi.advanceTimersByTime(120); + }); + + expect( + document.querySelector("[data-sidebar='collapsible-preview']"), + ).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + it("should have role=region on content", () => { render(); const content = screen.getByTestId("collapsible-content"); diff --git a/packages/kumo/src/components/sidebar/sidebar.tsx b/packages/kumo/src/components/sidebar/sidebar.tsx index 93e301877..aed75dbad 100644 --- a/packages/kumo/src/components/sidebar/sidebar.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.tsx @@ -13,6 +13,7 @@ import React, { useState, useSyncExternalStore, } from "react"; +import { createPortal } from "react-dom"; import { ScrollArea as ScrollAreaBase } from "@base-ui/react/scroll-area"; import { CaretRightIcon, XIcon } from "@phosphor-icons/react"; @@ -1548,6 +1549,7 @@ const SidebarMenuButton = forwardRef( "text-kumo-default", "transition-[color,box-shadow,outline] duration-(--sidebar-animation-duration)", !active && "hover:bg-(--sidebar-active-bg)", + !active && "data-[preview-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 @@ -1783,6 +1785,7 @@ const SidebarMenuSubButton = forwardRef< "before:absolute before:inset-x-0 before:-inset-y-px", "text-kumo-default transition-[color] duration-150", !active && "hover:bg-(--sidebar-active-bg)", + !active && "data-[preview-open]:bg-(--sidebar-active-bg)", active && "bg-(--sidebar-active-bg)", "focus:outline-none focus-visible:bg-(--sidebar-active-bg) focus-visible:text-kumo-strong", className, @@ -2200,10 +2203,12 @@ SidebarResizeHandle.displayName = "Sidebar.ResizeHandle"; interface SidebarCollapseContextValue { contentId: string; isOpen: boolean; + isPreviewOpen: boolean; isCollapsible: boolean; autoScrollOnOpen: boolean; toggle: () => void; completeOpenChange: () => void; + closePreview: () => void; } const isCollapsibleContentShown = ( @@ -2214,12 +2219,30 @@ const isCollapsibleContentShown = ( const SidebarCollapseContext = createContext({ contentId: "", isOpen: true, + isPreviewOpen: false, isCollapsible: false, autoScrollOnOpen: false, toggle: () => {}, completeOpenChange: () => {}, + closePreview: () => {}, }); +function getCollapsiblePreviewContent(children: ReactNode) { + let previewContent: ReactNode = null; + + React.Children.forEach(children, (child) => { + if ( + React.isValidElement(child) && + (child.type as { displayName?: string })?.displayName === + "Sidebar.CollapsibleContent" + ) { + previewContent = (child.props as { children?: ReactNode }).children; + } + }); + + return previewContent; +} + export interface SidebarCollapsibleProps extends ComponentPropsWithoutRef<"div"> { /** Initial open state (uncontrolled). @default false */ defaultOpen?: boolean; @@ -2268,6 +2291,8 @@ const SidebarCollapsible = forwardRef( autoScrollOnOpen = false, className, children, + onMouseEnter, + onMouseLeave, ...props }, ref, @@ -2275,10 +2300,37 @@ const SidebarCollapsible = forwardRef( const sidebar = useSidebar(); const { animationDuration } = sidebar; const [internalOpen, setInternalOpen] = useState(defaultOpen); + const [previewOpen, setPreviewOpen] = useState(false); + const [previewPosition, setPreviewPosition] = useState({ top: 0, left: 0 }); const isOpen = openProp ?? internalOpen; const isContentShown = isCollapsibleContentShown(isOpen, sidebar); const contentId = useId(); const keyboardExpandedRef = useRef(false); + const closePreviewTimeoutRef = useRef | null>( + null, + ); + const previewContent = getCollapsiblePreviewContent(children); + const hasPreviewContent = previewContent !== null; + const { isMobile, side } = useSidebar(); + + const clearPreviewCloseTimeout = useCallback(() => { + if (closePreviewTimeoutRef.current) { + clearTimeout(closePreviewTimeoutRef.current); + closePreviewTimeoutRef.current = null; + } + }, []); + + const closePreview = useCallback(() => { + clearPreviewCloseTimeout(); + closePreviewTimeoutRef.current = setTimeout(() => { + setPreviewOpen(false); + }, 120); + }, [clearPreviewCloseTimeout]); + + const closePreviewImmediately = useCallback(() => { + clearPreviewCloseTimeout(); + setPreviewOpen(false); + }, [clearPreviewCloseTimeout]); const toggle = useCallback(() => { const next = !isOpen; @@ -2298,12 +2350,64 @@ const SidebarCollapsible = forwardRef( () => ({ contentId, isOpen, + isPreviewOpen: previewOpen, isCollapsible: true, autoScrollOnOpen, toggle, completeOpenChange, + closePreview: closePreviewImmediately, }), - [contentId, isOpen, autoScrollOnOpen, toggle, completeOpenChange], + [ + contentId, + isOpen, + previewOpen, + autoScrollOnOpen, + toggle, + completeOpenChange, + closePreviewImmediately, + ], + ); + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + onMouseEnter?.(e); + if (isOpen || isMobile || !hasPreviewContent) return; + clearPreviewCloseTimeout(); + + const trigger = e.currentTarget.querySelector( + "[data-sidebar='menu-button'], [data-sidebar='menu-sub-button']", + ); + const rect = (trigger ?? e.currentTarget).getBoundingClientRect(); + + setPreviewPosition({ + top: Math.max(8, rect.top - 4), + left: side === "left" ? rect.right + 8 : rect.left - 232, + }); + setPreviewOpen(true); + }, + [ + clearPreviewCloseTimeout, + hasPreviewContent, + isMobile, + isOpen, + onMouseEnter, + side, + ], + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + onMouseLeave?.(e); + closePreview(); + }, + [closePreview, onMouseLeave], + ); + + useEffect( + () => () => { + clearPreviewCloseTimeout(); + }, + [clearPreviewCloseTimeout], ); const handleFocusIn = useCallback( @@ -2341,11 +2445,44 @@ const SidebarCollapsible = forwardRef( ref={ref} data-open={isOpen || undefined} className={cn("min-w-0", className)} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} onFocus={handleFocusIn} onBlur={handleFocusOut} {...props} > {children} + {previewOpen && + previewContent && + typeof document !== "undefined" && + createPortal( + , + document.body, + )} ); @@ -2376,18 +2513,21 @@ export interface SidebarCollapsibleTriggerProps { * ``` */ function SidebarCollapsibleTrigger({ render }: SidebarCollapsibleTriggerProps) { - const { contentId, isOpen, toggle } = useContext(SidebarCollapseContext); + const { contentId, isOpen, isPreviewOpen, toggle, closePreview } = + useContext(SidebarCollapseContext); return React.cloneElement(render, { "aria-expanded": isOpen, "aria-controls": contentId, "data-open": isOpen || undefined, + "data-preview-open": isPreviewOpen || undefined, 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); + closePreview(); toggle(); }, } as Record); From 0e5e33434f84eb7813a9d9004a2a9381da0cb584 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Mon, 3 Aug 2026 11:00:39 +0100 Subject: [PATCH 2/4] feat(sidebar): title hover previews and auto-label collapsed items Gate previews behind `previewOnHover` on Sidebar.Provider, and preview collapsed-rail sections regardless of open state. Title the top-level popup from the trigger and suppress its tooltip so the two never overlap. Fall back to a menu button's own text for its collapsed-rail tooltip. Add a docs demo and section. --- .changeset/soft-lions-hover.md | 9 +- .../src/components/demos/SidebarDemo.tsx | 116 ++++ .../src/pages/components/sidebar.astro | 46 ++ .../src/components/sidebar/sidebar.test.tsx | 503 +++++++++++++++++- .../kumo/src/components/sidebar/sidebar.tsx | 423 ++++++++++++--- 5 files changed, 1004 insertions(+), 93 deletions(-) diff --git a/.changeset/soft-lions-hover.md b/.changeset/soft-lions-hover.md index 9a4031093..d663cc91f 100644 --- a/.changeset/soft-lions-hover.md +++ b/.changeset/soft-lions-hover.md @@ -1,5 +1,10 @@ --- -"@cloudflare/kumo": patch +"@cloudflare/kumo": minor --- -Add hover previews for nested sidebar collapsible content. +Add opt-in hover previews for nested sidebar collapsible content via the new +`previewOnHover` prop on `Sidebar.Provider`. When the rail is collapsed, +previews 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..b93ca5d75 100644 --- a/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx @@ -335,6 +335,122 @@ export function SidebarToggleDemo() { ); } +// --------------------------------------------------------------------------- +// Hover Preview — collapsed collapsibles reveal nested content in a popup +// --------------------------------------------------------------------------- + +/** + * Opt-in hover previews via `previewOnHover`: 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 preview. Childless items (Analytics) + * have nothing to preview, so they show a plain tooltip. + */ +export function SidebarHoverPreviewDemo() { + 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 previews its children in a popup. + `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 preview its nested items

+
+
+
+ ); +} + // --------------------------------------------------------------------------- // 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..79882f39d 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, + SidebarHoverPreviewDemo, SidebarAutoScrollDemo, SidebarTransitionCompleteDemo, SidebarSlidingViewsDemo, @@ -314,6 +315,51 @@ scrollToItem("zero-trust", { align: "center", behavior: "smooth" });`}> +
+ Hover Preview +

+ Set previewOnHover on the Provider (default + false) to reveal a closed collapsible's nested + content in a floating popup on hover, instead of expanding it inline. A collapsed rail can't render inline + content, so its sections preview regardless of open state. Previews 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. +

+ + + + + + {/* 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 e9a6878e7..33be7b451 100644 --- a/packages/kumo/src/components/sidebar/sidebar.test.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.test.tsx @@ -536,7 +536,7 @@ describe("Sidebar.Collapsible", () => { sidebarOpen?: boolean; }) { return ( - + @@ -556,7 +556,10 @@ describe("Sidebar.Collapsible", () => { /> - Workers + + + Workers + @@ -617,13 +620,35 @@ describe("Sidebar.Collapsible", () => { "[data-sidebar='collapsible-preview']", ); expect(preview).toBeTruthy(); + // Mouse-only affordance: hidden from the accessibility tree, but NOT + // inert (so it stays clickable). Keyboard users get inline expansion. expect(preview?.getAttribute("aria-hidden")).toBe("true"); expect(preview?.hasAttribute("inert")).toBe(false); + expect(preview?.hasAttribute("role")).toBe(false); expect(preview?.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 previewSub = preview?.querySelector( + "[data-sidebar='menu-sub']", + ) as HTMLElement; + expect(previewSub).toBeTruthy(); + // The line is still in the DOM (hidden via CSS), not removed. + expect( + previewSub.querySelector(":scope > li[aria-hidden='true']"), + ).toBeTruthy(); + // Wrapper carries the top-level-only flatten overrides. + const flattenWrapper = previewSub.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(preview?.querySelector("[data-testid='sub-icon']")).toBeTruthy(); expect( - (preview as HTMLElement).style.getPropertyValue( - "--sidebar-active-bg", - ), + (preview as HTMLElement).style.getPropertyValue("--sidebar-active-bg"), ).toBe("var(--color-kumo-tint)"); expect(trigger.getAttribute("data-preview-open")).toBe("true"); expect(trigger.getAttribute("aria-expanded")).toBe("false"); @@ -671,6 +696,443 @@ describe("Sidebar.Collapsible", () => { } }); + // Single collapsible driving all preview tests: the provider toggle, initial + // rail/section open states, and the trigger's optional `tooltip`. + function PreviewFixture({ + previewOnHover, + railOpen = true, + sectionOpen = false, + tooltip, + }: { + previewOnHover?: boolean; + railOpen?: boolean; + sectionOpen?: boolean; + tooltip?: string; + }) { + return ( + + + + + + + Compute + + + } + /> + + + Workers + + + + + + + + ); + } + + const previewEl = () => + document.querySelector("[data-sidebar='collapsible-preview']"); + + it("should not preview when previewOnHover is unset (opt-in)", () => { + render(); + fireEvent.mouseEnter( + screen.getByRole("button", { name: /Compute/i }).parentElement!, + ); + expect(previewEl()).toBeNull(); + }); + + it("should preview when the Provider opts in", () => { + render(); + fireEvent.mouseEnter( + screen.getByRole("button", { name: /Compute/i }).parentElement!, + ); + expect(previewEl()).toBeTruthy(); + }); + + it("should not preview 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(previewEl()).toBeNull(); + }); + + it("should preview 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(previewEl()).toBeTruthy(); + }); + + it("should anchor the popup top to the trigger, offset by its padding", () => { + // Align the popup's first row with the row that opened it: top is the + // trigger's top minus the popup padding (PREVIEW_PADDING = 6, matching + // `p-1.5`). A fixed rect keeps this above the viewport-inset clamp. + const rectSpy = vi + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockReturnValue({ + top: 120, + left: 40, + right: 88, + bottom: 154, + width: 48, + height: 34, + x: 40, + y: 120, + toJSON: () => ({}), + } as DOMRect); + + try { + render(); + fireEvent.mouseEnter( + screen.getByRole("button", { name: /Compute/i }).parentElement!, + ); + const preview = previewEl() as HTMLElement; + expect(preview).toBeTruthy(); + expect(preview.style.top).toBe("114px"); // 120 - 6 + } finally { + rectSpy.mockRestore(); + } + }); + + it("should align the collapsed popup's title row with the trigger", () => { + // The title mirrors the parent, so it overlays the trigger row: no offset, + // the popup's top edge meets the trigger's top (both are min-h-8.5). + const rectSpy = vi + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockReturnValue({ + top: 120, + left: 40, + right: 88, + bottom: 154, + width: 48, + height: 34, + x: 40, + y: 120, + toJSON: () => ({}), + } as DOMRect); + + try { + render( + , + ); + fireEvent.mouseEnter( + screen.getByRole("button", { name: /Compute/i }).parentElement!, + ); + const preview = previewEl() as HTMLElement; + expect(preview).toBeTruthy(); + expect(preview.style.top).toBe("120px"); // 120 - 0 + } finally { + rectSpy.mockRestore(); + } + }); + + const titleEl = () => + document.querySelector( + "[data-sidebar='collapsible-preview-title']", + ) as HTMLElement | null; + + it("should replace the tooltip with a popup title on collapsed preview", () => { + // With a preview available the tooltip is suppressed (showTooltip is + // gated on !hasPreview) 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: preview + title present, + // no tooltip popup leaked into the DOM. + render( + , + ); + + fireEvent.mouseEnter( + screen.getByRole("button", { name: /Compute/i }).parentElement!, + ); + + expect(previewEl()).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(previewEl()).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 = previewEl() as HTMLElement; + fireEvent.mouseEnter( + popup1.querySelector("[data-sidebar='menu-sub-button']")!.parentElement!, + ); + + expect( + document.querySelectorAll("[data-sidebar='collapsible-preview']").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-preview-title']", + ); + expect(titles.length).toBe(1); + expect(titles[0].textContent).toBe("Compute"); + }); + + it("should keep preview 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 preview = document.querySelector( + "[data-sidebar='collapsible-preview']", + ) as HTMLElement; + expect(preview).toBeTruthy(); + + // Items inside are removed from the tab order (keyboard-invisible)... + const subButton = preview.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( + "[data-sidebar='collapsible-preview']", + ) 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("[data-sidebar='collapsible-preview']"); + 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 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( + "[data-sidebar='collapsible-preview']", + ) 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("[data-sidebar='collapsible-preview']").length, + ).toBe(1); + expect(nestedParent.getAttribute("data-open")).toBeNull(); + + fireEvent.mouseEnter(nestedParent.parentElement!); + expect( + document.querySelectorAll("[data-sidebar='collapsible-preview']").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( + "[data-sidebar='collapsible-preview']", + ) 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("[data-sidebar='collapsible-preview']").length, + ).toBe(0); + }); + it("should have role=region on content", () => { render(); const content = screen.getByTestId("collapsible-content"); @@ -1119,6 +1581,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 aed75dbad..d3729daed 100644 --- a/packages/kumo/src/components/sidebar/sidebar.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.tsx @@ -1,3 +1,4 @@ +import { ScrollArea as ScrollAreaBase } from "@base-ui/react/scroll-area"; import React, { type ComponentPropsWithoutRef, type CSSProperties, @@ -7,21 +8,20 @@ import React, { useCallback, useContext, useEffect, - useMemo, useId, + useMemo, useRef, useState, useSyncExternalStore, } from "react"; import { createPortal } from "react-dom"; -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 { Button } from "../button"; import { SkeletonLine } from "../loader/skeleton-line"; import { Tooltip, TooltipProvider } from "../tooltip"; -import { Button } from "../button"; // ============================================================================ // Variants (required by Kumo convention) @@ -99,6 +99,8 @@ const SIDEBAR_EASING = "cubic-bezier(0.77, 0, 0.175, 1)"; const SIDEBAR_ANIMATION_DURATION_MS = 250; const TRANSITION_FALLBACK_GRACE_MS = 50; const MOBILE_BREAKPOINT = 768; +/** Label typography shared by group labels and preview 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"])'; @@ -211,6 +213,7 @@ export interface SidebarContextValue { setWidth: (width: number) => void; isPeeking: boolean; peekable: boolean; + previewOnHover: boolean; startPeek: () => void; stopPeek: () => void; contained: boolean; @@ -323,6 +326,15 @@ 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, previews appear regardless of the + * section's open state (inline content cannot render in a collapsed rail). + * @default false + */ + previewOnHover?: boolean; /** * Duration of sidebar expand/collapse animation in milliseconds. * @default 250 @@ -373,6 +385,7 @@ function SidebarProvider({ onWidthChange, contained = false, peekable = false, + previewOnHover = false, animationDuration = SIDEBAR_ANIMATION_DURATION_MS, mobileBreakpoint, children, @@ -562,6 +575,7 @@ function SidebarProvider({ setWidth, isPeeking, peekable, + previewOnHover, startPeek, stopPeek, contained, @@ -1275,7 +1289,8 @@ const SidebarGroupLabel = forwardRef<

( const { state, peekable, registerItem } = useSidebar(); const LinkComponent = useLinkComponent(); const isInsideMenuItem = useContext(MenuItemContext); + const { hasPreview } = useContext(SidebarCollapseContext); const registerAutoWrap = useCallback( (node: HTMLLIElement | null) => { @@ -1604,16 +1625,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 + // preview owns the label (its popup carries the title and sits in front). + const showTooltip = state === "collapsed" && !peekable && !hasPreview; + const tooltipText = tooltip ?? getElementText(children); + if (tooltipText) { button = ( void; completeOpenChange: () => void; closePreview: () => void; @@ -2222,11 +2247,44 @@ const SidebarCollapseContext = createContext({ isPreviewOpen: false, isCollapsible: false, autoScrollOnOpen: false, + hasPreview: false, toggle: () => {}, completeOpenChange: () => {}, closePreview: () => {}, }); +interface SidebarPreviewContextValue { + /** True when descendants are rendered inside a hover-preview popup. */ + insidePreview: boolean; + /** Close this popup and every ancestor popup in the chain. */ + dismissAll: () => void; +} + +const SidebarPreviewContext = createContext({ + insidePreview: false, + dismissAll: () => {}, +}); + +/** Preview popup width in px (matches the `w-56` class below). */ +const PREVIEW_WIDTH = 224; +/** Gap between the trigger and the preview popup in px. */ +const PREVIEW_GAP = 7; +/** Minimum viewport inset for the preview popup in px. */ +const PREVIEW_VIEWPORT_INSET = 8; +/** + * Preview 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 PREVIEW_PADDING = 6; +/** + * Extra vertical offset for the collapsed-rail title popup, in px, applied on + * top of the base padding. `0` cancels the padding so the popup's top edge + * meets the trigger's — the title row (min-h-8.5) overlays the parent row it + * opened from. Increase to drop the popup down. Tune here to adjust alignment. + */ +const PREVIEW_TITLE_OFFSET = 0; + function getCollapsiblePreviewContent(children: ReactNode) { let previewContent: ReactNode = null; @@ -2243,6 +2301,48 @@ function getCollapsiblePreviewContent(children: ReactNode) { return previewContent; } +/** + * 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 preview 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; @@ -2306,12 +2406,20 @@ const SidebarCollapsible = forwardRef( const isContentShown = isCollapsibleContentShown(isOpen, sidebar); const contentId = useId(); const keyboardExpandedRef = useRef(false); + const previewRef = useRef(null); + const containerRef = useRef(null); const closePreviewTimeoutRef = useRef | null>( null, ); const previewContent = getCollapsiblePreviewContent(children); const hasPreviewContent = previewContent !== null; - const { isMobile, side } = useSidebar(); + const previewLabel = getCollapsibleTriggerLabel(children); + const { + isMobile, + side, + state, + previewOnHover: previewEnabled, + } = useSidebar(); const clearPreviewCloseTimeout = useCallback(() => { if (closePreviewTimeoutRef.current) { @@ -2332,6 +2440,21 @@ const SidebarCollapsible = forwardRef( setPreviewOpen(false); }, [clearPreviewCloseTimeout]); + // Chain up ancestor popups so a click anywhere in the stack dismisses all. + const parentPreview = useContext(SidebarPreviewContext); + // The title only renders for a collapsed, top-level popup with a label. + const showPreviewTitle = + state === "collapsed" && !parentPreview.insidePreview && !!previewLabel; + const dismissAll = useCallback(() => { + closePreviewImmediately(); + parentPreview.dismissAll(); + }, [closePreviewImmediately, parentPreview]); + + const previewProviderValue = useMemo( + () => ({ insidePreview: true, dismissAll }), + [dismissAll], + ); + const toggle = useCallback(() => { const next = !isOpen; setInternalOpen(next); @@ -2346,6 +2469,91 @@ const SidebarCollapsible = forwardRef( onOpenChangeComplete, ); + const openPreview = useCallback(() => { + if ( + !previewEnabled || + isMobile || + !hasPreviewContent || + !containerRef.current + ) { + return; + } + // A collapsed rail cannot render inline content (see + // SidebarCollapsibleContent), so preview regardless of open state. + // Otherwise only preview closed sections — open ones show inline. + if (state !== "collapsed" && isOpen) { + return; + } + clearPreviewCloseTimeout(); + + const trigger = containerRef.current.querySelector( + "[data-sidebar='menu-button'], [data-sidebar='menu-sub-button']", + ); + const anchor = trigger ?? containerRef.current; + const rect = anchor.getBoundingClientRect(); + + // When the trigger lives inside another preview popup, measure the + // horizontal gap from that popup's outer edge (not the button's, which + // is inset by the popup's padding/indent). Keeps every popup-to-popup + // gap visually equal. + const parentPopup = anchor.closest( + "[data-sidebar='collapsible-preview']", + ); + const horizontalRect = parentPopup?.getBoundingClientRect() ?? rect; + + const rawLeft = + side === "left" + ? horizontalRect.right + PREVIEW_GAP + : horizontalRect.left - PREVIEW_WIDTH - PREVIEW_GAP; + const maxLeft = + window.innerWidth - PREVIEW_WIDTH - PREVIEW_VIEWPORT_INSET; + const maxTop = window.innerHeight - PREVIEW_VIEWPORT_INSET; + + // A title mirrors the parent row, so align the two: the popup's top edge + // meets the trigger's (both are min-h-8.5). Without a title, pull up by + // the popup's padding so the first item aligns instead. + const topOffset = showPreviewTitle + ? PREVIEW_TITLE_OFFSET + : PREVIEW_PADDING; + + setPreviewPosition({ + top: Math.min( + Math.max(PREVIEW_VIEWPORT_INSET, rect.top - topOffset), + Math.max(PREVIEW_VIEWPORT_INSET, maxTop), + ), + left: Math.min( + Math.max(PREVIEW_VIEWPORT_INSET, rawLeft), + Math.max(PREVIEW_VIEWPORT_INSET, maxLeft), + ), + }); + setPreviewOpen(true); + }, [ + clearPreviewCloseTimeout, + hasPreviewContent, + isMobile, + isOpen, + previewEnabled, + showPreviewTitle, + side, + state, + ]); + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + onMouseEnter?.(e); + openPreview(); + }, + [onMouseEnter, openPreview], + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + onMouseLeave?.(e); + closePreview(); + }, + [closePreview, onMouseLeave], + ); + const contextValue = useMemo( () => ({ contentId, @@ -2353,6 +2561,7 @@ const SidebarCollapsible = forwardRef( isPreviewOpen: previewOpen, isCollapsible: true, autoScrollOnOpen, + hasPreview: previewEnabled && hasPreviewContent, toggle, completeOpenChange, closePreview: closePreviewImmediately, @@ -2362,47 +2571,14 @@ const SidebarCollapsible = forwardRef( isOpen, previewOpen, autoScrollOnOpen, + previewEnabled, + hasPreviewContent, toggle, completeOpenChange, closePreviewImmediately, ], ); - const handleMouseEnter = useCallback( - (e: React.MouseEvent) => { - onMouseEnter?.(e); - if (isOpen || isMobile || !hasPreviewContent) return; - clearPreviewCloseTimeout(); - - const trigger = e.currentTarget.querySelector( - "[data-sidebar='menu-button'], [data-sidebar='menu-sub-button']", - ); - const rect = (trigger ?? e.currentTarget).getBoundingClientRect(); - - setPreviewPosition({ - top: Math.max(8, rect.top - 4), - left: side === "left" ? rect.right + 8 : rect.left - 232, - }); - setPreviewOpen(true); - }, - [ - clearPreviewCloseTimeout, - hasPreviewContent, - isMobile, - isOpen, - onMouseEnter, - side, - ], - ); - - const handleMouseLeave = useCallback( - (e: React.MouseEvent) => { - onMouseLeave?.(e); - closePreview(); - }, - [closePreview, onMouseLeave], - ); - useEffect( () => () => { clearPreviewCloseTimeout(); @@ -2410,6 +2586,19 @@ const SidebarCollapsible = forwardRef( [clearPreviewCloseTimeout], ); + // The preview 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 (!previewOpen || !previewRef.current) return; + const focusables = previewRef.current.querySelectorAll( + "a[href], button, input, select, textarea, [tabindex]", + ); + for (const el of focusables) el.setAttribute("tabindex", "-1"); + }, [previewOpen, previewContent]); + const handleFocusIn = useCallback( (e: React.FocusEvent) => { // Auto-expand on keyboard focus (focus-visible) when collapsed @@ -2442,7 +2631,11 @@ 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} @@ -2456,31 +2649,61 @@ const SidebarCollapsible = forwardRef( previewContent && typeof document !== "undefined" && createPortal( - , + , document.body, )}
@@ -2513,20 +2736,48 @@ export interface SidebarCollapsibleTriggerProps { * ``` */ function SidebarCollapsibleTrigger({ render }: SidebarCollapsibleTriggerProps) { - const { contentId, isOpen, isPreviewOpen, toggle, closePreview } = - useContext(SidebarCollapseContext); + const { contentId, isOpen, isPreviewOpen, toggle, closePreview } = useContext( + SidebarCollapseContext, + ); + const { insidePreview } = useContext(SidebarPreviewContext); + + const hasHref = Boolean((render.props as { href?: string }).href); return React.cloneElement(render, { "aria-expanded": isOpen, "aria-controls": contentId, "data-open": isOpen || undefined, "data-preview-open": isPreviewOpen || 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. + ...(insidePreview && !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 (insidePreview) { + // 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; + } + closePreview(); toggle(); }, @@ -2891,30 +3142,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, }; From e658d9e4875c1df28357876d40c42eaa3e976b97 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Tue, 27 Oct 2026 17:03:09 +0000 Subject: [PATCH 3/4] fix(sidebar): align titled preview's first item with its trigger --- .../src/pages/components/sidebar.astro | 3 ++- .../src/components/sidebar/sidebar.test.tsx | 8 ++++---- .../kumo/src/components/sidebar/sidebar.tsx | 17 +++++++++-------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/kumo-docs-astro/src/pages/components/sidebar.astro b/packages/kumo-docs-astro/src/pages/components/sidebar.astro index 79882f39d..c9f6ba8e3 100644 --- a/packages/kumo-docs-astro/src/pages/components/sidebar.astro +++ b/packages/kumo-docs-astro/src/pages/components/sidebar.astro @@ -328,7 +328,8 @@ scrollToItem("zero-trust", { align: "center", behavior: "smooth" });`}> 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. + 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.

diff --git a/packages/kumo/src/components/sidebar/sidebar.test.tsx b/packages/kumo/src/components/sidebar/sidebar.test.tsx index 33be7b451..2b7b68fa8 100644 --- a/packages/kumo/src/components/sidebar/sidebar.test.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.test.tsx @@ -805,9 +805,9 @@ describe("Sidebar.Collapsible", () => { } }); - it("should align the collapsed popup's title row with the trigger", () => { - // The title mirrors the parent, so it overlays the trigger row: no offset, - // the popup's top edge meets the trigger's top (both are min-h-8.5). + it("should align the collapsed popup's first item with the trigger", () => { + // The title sits above the trigger row: pull up by its height (34) plus + // its bottom margin (6) so the first item lines up with the trigger. const rectSpy = vi .spyOn(HTMLElement.prototype, "getBoundingClientRect") .mockReturnValue({ @@ -831,7 +831,7 @@ describe("Sidebar.Collapsible", () => { ); const preview = previewEl() as HTMLElement; expect(preview).toBeTruthy(); - expect(preview.style.top).toBe("120px"); // 120 - 0 + expect(preview.style.top).toBe("80px"); // 120 - 34 - 6 } finally { rectSpy.mockRestore(); } diff --git a/packages/kumo/src/components/sidebar/sidebar.tsx b/packages/kumo/src/components/sidebar/sidebar.tsx index d3729daed..cb8c058d7 100644 --- a/packages/kumo/src/components/sidebar/sidebar.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.tsx @@ -2277,13 +2277,15 @@ const PREVIEW_VIEWPORT_INSET = 8; * first item with the row that opened it, so nested popups don't drift. */ const PREVIEW_PADDING = 6; +/** Preview title row height in px (must match its `min-h-8.5` class). */ +const PREVIEW_TITLE_HEIGHT = 34; /** - * Extra vertical offset for the collapsed-rail title popup, in px, applied on - * top of the base padding. `0` cancels the padding so the popup's top edge - * meets the trigger's — the title row (min-h-8.5) overlays the parent row it - * opened from. Increase to drop the popup down. Tune here to adjust alignment. + * 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` (= PREVIEW_PADDING). */ -const PREVIEW_TITLE_OFFSET = 0; +const PREVIEW_TITLE_OFFSET = PREVIEW_TITLE_HEIGHT + PREVIEW_PADDING; function getCollapsiblePreviewContent(children: ReactNode) { let previewContent: ReactNode = null; @@ -2509,9 +2511,8 @@ const SidebarCollapsible = forwardRef( window.innerWidth - PREVIEW_WIDTH - PREVIEW_VIEWPORT_INSET; const maxTop = window.innerHeight - PREVIEW_VIEWPORT_INSET; - // A title mirrors the parent row, so align the two: the popup's top edge - // meets the trigger's (both are min-h-8.5). Without a title, pull up by - // the popup's padding so the first item aligns instead. + // Align the first item with the trigger, skipping the title (if any) and + // the popup's padding. const topOffset = showPreviewTitle ? PREVIEW_TITLE_OFFSET : PREVIEW_PADDING; From 44976deed7764b1efd67c851e69b19b1303404c1 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Fri, 30 Oct 2026 15:10:49 +0000 Subject: [PATCH 4/4] refactor(sidebar): rename hover preview to flyout and position it with Base UI - Rename the `previewOnHover` Provider prop to `flyout`, along with its data attributes, internals, tests and docs. - Position flyouts with Base UI's PreviewCard (anchor, side, offsets, collision padding) instead of hand-rolled measuring and clamping. - With `peekable`, wait for the peek to finish expanding before showing a flyout, and keep the peek open while the pointer crosses into it. - Rebuild the Provider context when its props change, so toggling `peekable` or `flyout` at runtime takes effect. - Add a peeking toggle to the flyout demo. --- .changeset/soft-lions-hover.md | 6 +- .../src/components/demos/SidebarDemo.tsx | 44 +- .../src/pages/components/sidebar.astro | 18 +- .../src/components/sidebar/sidebar.test.tsx | 345 ++++++------ .../kumo/src/components/sidebar/sidebar.tsx | 508 +++++++++++------- 5 files changed, 534 insertions(+), 387 deletions(-) diff --git a/.changeset/soft-lions-hover.md b/.changeset/soft-lions-hover.md index d663cc91f..ed16d7e6e 100644 --- a/.changeset/soft-lions-hover.md +++ b/.changeset/soft-lions-hover.md @@ -2,9 +2,9 @@ "@cloudflare/kumo": minor --- -Add opt-in hover previews for nested sidebar collapsible content via the new -`previewOnHover` prop on `Sidebar.Provider`. When the rail is collapsed, -previews appear regardless of the section's open state. +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 b93ca5d75..553fdf7bc 100644 --- a/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx @@ -336,22 +336,48 @@ export function SidebarToggleDemo() { } // --------------------------------------------------------------------------- -// Hover Preview — collapsed collapsibles reveal nested content in a popup +// 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 previews via `previewOnHover`: hovering a closed section shows + * 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 preview. Childless items (Analytics) - * have nothing to preview, so they show a plain tooltip. + * 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 SidebarHoverPreviewDemo() { +export function SidebarFlyoutDemo() { + const [peekable, setPeekable] = useState(false); return ( @@ -370,7 +396,7 @@ export function SidebarHoverPreviewDemo() { - {/* Closed section: hovering previews its children in a popup. + {/* Closed section: hovering shows its children in a flyout. `tooltip` names the parent (the popup lists only its children). */} @@ -443,8 +469,8 @@ export function SidebarHoverPreviewDemo() { - -

Hover a collapsed icon to preview its nested items

+ +

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

diff --git a/packages/kumo-docs-astro/src/pages/components/sidebar.astro b/packages/kumo-docs-astro/src/pages/components/sidebar.astro index c9f6ba8e3..99bab9436 100644 --- a/packages/kumo-docs-astro/src/pages/components/sidebar.astro +++ b/packages/kumo-docs-astro/src/pages/components/sidebar.astro @@ -14,7 +14,7 @@ import { SidebarResizableDemo, SidebarRightDemo, SidebarPeekingDemo, - SidebarHoverPreviewDemo, + SidebarFlyoutDemo, SidebarAutoScrollDemo, SidebarTransitionCompleteDemo, SidebarSlidingViewsDemo, @@ -316,12 +316,12 @@ scrollToItem("zero-trust", { align: "center", behavior: "smooth" });`}>
- Hover Preview + Flyout

- Set previewOnHover on the Provider (default + Set flyout on the Provider (default false) to reveal a closed collapsible's nested - content in a floating popup on hover, instead of expanding it inline. A collapsed rail can't render inline - content, so its sections preview regardless of open state. Previews are mouse-only (hidden from the + 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.

@@ -329,9 +329,11 @@ scrollToItem("zero-trust", { align: "center", behavior: "smooth" });`}> 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. + 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.

- + @@ -357,7 +359,7 @@ scrollToItem("zero-trust", { align: "center", behavior: "smooth" });`}> `}> - +
diff --git a/packages/kumo/src/components/sidebar/sidebar.test.tsx b/packages/kumo/src/components/sidebar/sidebar.test.tsx index 2b7b68fa8..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 ( - + @@ -602,7 +606,7 @@ describe("Sidebar.Collapsible", () => { expect(trigger.getAttribute("aria-controls")).toBe(content.id); }); - it("should preview nested content on hover without toggling open", () => { + it("should show nested content in a flyout on hover without toggling open", () => { vi.useFakeTimers(); render(); @@ -616,49 +620,45 @@ describe("Sidebar.Collapsible", () => { fireEvent.mouseEnter(collapsible); - const preview = document.querySelector( - "[data-sidebar='collapsible-preview']", - ); - expect(preview).toBeTruthy(); + 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(preview?.getAttribute("aria-hidden")).toBe("true"); - expect(preview?.hasAttribute("inert")).toBe(false); - expect(preview?.hasAttribute("role")).toBe(false); - expect(preview?.textContent).toContain("Workers"); + 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 previewSub = preview?.querySelector( + const flyoutSub = flyout?.querySelector( "[data-sidebar='menu-sub']", ) as HTMLElement; - expect(previewSub).toBeTruthy(); + expect(flyoutSub).toBeTruthy(); // The line is still in the DOM (hidden via CSS), not removed. expect( - previewSub.querySelector(":scope > li[aria-hidden='true']"), + flyoutSub.querySelector(":scope > li[aria-hidden='true']"), ).toBeTruthy(); // Wrapper carries the top-level-only flatten overrides. - const flattenWrapper = previewSub.parentElement as HTMLElement; + 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(preview?.querySelector("[data-testid='sub-icon']")).toBeTruthy(); + expect(flyout?.querySelector("[data-testid='sub-icon']")).toBeTruthy(); expect( - (preview as HTMLElement).style.getPropertyValue("--sidebar-active-bg"), + (flyout as HTMLElement).style.getPropertyValue("--sidebar-active-bg"), ).toBe("var(--color-kumo-tint)"); - expect(trigger.getAttribute("data-preview-open")).toBe("true"); + 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("[data-sidebar='collapsible-preview']"), - ).toBeNull(); + expect(document.querySelector(OPEN_FLYOUT)).toBeNull(); expect(trigger.getAttribute("aria-expanded")).toBe("true"); expect(content.getAttribute("aria-hidden")).toBe("false"); @@ -666,51 +666,41 @@ describe("Sidebar.Collapsible", () => { fireEvent.mouseEnter(collapsible); fireEvent.mouseLeave(collapsible); - fireEvent.mouseEnter( - document.querySelector( - "[data-sidebar='collapsible-preview']", - ) as HTMLElement, - ); + fireEvent.mouseEnter(document.querySelector(OPEN_FLYOUT) as HTMLElement); act(() => { vi.advanceTimersByTime(120); }); - expect( - document.querySelector("[data-sidebar='collapsible-preview']"), - ).toBeTruthy(); + expect(document.querySelector(OPEN_FLYOUT)).toBeTruthy(); - fireEvent.mouseLeave( - document.querySelector( - "[data-sidebar='collapsible-preview']", - ) as HTMLElement, - ); + fireEvent.mouseLeave(document.querySelector(OPEN_FLYOUT) as HTMLElement); act(() => { vi.advanceTimersByTime(120); }); - expect( - document.querySelector("[data-sidebar='collapsible-preview']"), - ).toBeNull(); + expect(document.querySelector(OPEN_FLYOUT)).toBeNull(); } finally { vi.useRealTimers(); } }); - // Single collapsible driving all preview tests: the provider toggle, initial + // Single collapsible driving all flyout tests: the provider toggle, initial // rail/section open states, and the trigger's optional `tooltip`. - function PreviewFixture({ - previewOnHover, + function FlyoutFixture({ + flyout, + peekable, railOpen = true, sectionOpen = false, tooltip, }: { - previewOnHover?: boolean; + flyout?: boolean; + peekable?: boolean; railOpen?: boolean; sectionOpen?: boolean; tooltip?: string; }) { return ( - + @@ -736,135 +726,142 @@ describe("Sidebar.Collapsible", () => { ); } - const previewEl = () => - document.querySelector("[data-sidebar='collapsible-preview']"); + const flyoutEl = () => document.querySelector(OPEN_FLYOUT); - it("should not preview when previewOnHover is unset (opt-in)", () => { - render(); + 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(previewEl()).toBeNull(); + expect(flyoutEl()).toBeNull(); }); - it("should preview when the Provider opts in", () => { - render(); + it("should show a flyout when the Provider opts in", () => { + render(); fireEvent.mouseEnter( screen.getByRole("button", { name: /Compute/i }).parentElement!, ); - expect(previewEl()).toBeTruthy(); + expect(flyoutEl()).toBeTruthy(); }); - it("should not preview an open section when the rail is expanded", () => { - render(); + 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(previewEl()).toBeNull(); + expect(flyoutEl()).toBeNull(); }); - it("should preview an open section when the rail is collapsed", () => { + 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(); + render(); fireEvent.mouseEnter( screen.getByRole("button", { name: /Compute/i }).parentElement!, ); - expect(previewEl()).toBeTruthy(); - }); - - it("should anchor the popup top to the trigger, offset by its padding", () => { - // Align the popup's first row with the row that opened it: top is the - // trigger's top minus the popup padding (PREVIEW_PADDING = 6, matching - // `p-1.5`). A fixed rect keeps this above the viewport-inset clamp. - const rectSpy = vi - .spyOn(HTMLElement.prototype, "getBoundingClientRect") - .mockReturnValue({ - top: 120, - left: 40, - right: 88, - bottom: 154, - width: 48, - height: 34, - x: 40, - y: 120, - toJSON: () => ({}), - } as DOMRect); - - try { - render(); - fireEvent.mouseEnter( - screen.getByRole("button", { name: /Compute/i }).parentElement!, - ); - const preview = previewEl() as HTMLElement; - expect(preview).toBeTruthy(); - expect(preview.style.top).toBe("114px"); // 120 - 6 - } finally { - rectSpy.mockRestore(); - } + expect(flyoutEl()).toBeTruthy(); }); - it("should align the collapsed popup's first item with the trigger", () => { - // The title sits above the trigger row: pull up by its height (34) plus - // its bottom margin (6) so the first item lines up with the trigger. - const rectSpy = vi - .spyOn(HTMLElement.prototype, "getBoundingClientRect") - .mockReturnValue({ - top: 120, - left: 40, - right: 88, - bottom: 154, - width: 48, - height: 34, - x: 40, - y: 120, - toJSON: () => ({}), - } as DOMRect); - - try { + // 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 preview = previewEl() as HTMLElement; - expect(preview).toBeTruthy(); - expect(preview.style.top).toBe("80px"); // 120 - 34 - 6 - } finally { - rectSpy.mockRestore(); - } - }); + 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-preview-title']", + "[data-sidebar='collapsible-flyout-title']", ) as HTMLElement | null; - it("should replace the tooltip with a popup title on collapsed preview", () => { - // With a preview available the tooltip is suppressed (showTooltip is - // gated on !hasPreview) and the popup carries the parent's name instead. + 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: preview + title present, + // happy-dom, so we assert the observable outcome: flyout + title present, // no tooltip popup leaked into the DOM. - render( - , - ); + render(); fireEvent.mouseEnter( screen.getByRole("button", { name: /Compute/i }).parentElement!, ); - expect(previewEl()).toBeTruthy(); + 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( - , - ); + render(); fireEvent.mouseEnter( screen.getByRole("button", { name: /Compute/i }).parentElement!, ); @@ -872,7 +869,7 @@ describe("Sidebar.Collapsible", () => { }); it("should title the collapsed popup from the trigger's text when no tooltip", () => { - render(); + render(); fireEvent.mouseEnter( screen.getByRole("button", { name: /Compute/i }).parentElement!, ); @@ -881,11 +878,11 @@ describe("Sidebar.Collapsible", () => { }); it("should not title the popup when the rail is expanded", () => { - render(); + render(); fireEvent.mouseEnter( screen.getByRole("button", { name: /Compute/i }).parentElement!, ); - expect(previewEl()).toBeTruthy(); + expect(flyoutEl()).toBeTruthy(); expect(titleEl()).toBeNull(); }); @@ -896,24 +893,22 @@ describe("Sidebar.Collapsible", () => { fireEvent.mouseEnter( screen.getByRole("button", { name: /Compute/i }).parentElement!, ); - const popup1 = previewEl() as HTMLElement; + const popup1 = flyoutEl() as HTMLElement; fireEvent.mouseEnter( popup1.querySelector("[data-sidebar='menu-sub-button']")!.parentElement!, ); - expect( - document.querySelectorAll("[data-sidebar='collapsible-preview']").length, - ).toBe(2); + 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-preview-title']", + "[data-sidebar='collapsible-flyout-title']", ); expect(titles.length).toBe(1); expect(titles[0].textContent).toBe("Compute"); }); - it("should keep preview items clickable but out of the tab order", () => { + it("should keep flyout items clickable but out of the tab order", () => { render(); const trigger = screen.getByRole("button", { name: /Compute/i }); @@ -923,13 +918,11 @@ describe("Sidebar.Collapsible", () => { expect(trigger.hasAttribute("aria-haspopup")).toBe(false); fireEvent.mouseEnter(collapsible); - const preview = document.querySelector( - "[data-sidebar='collapsible-preview']", - ) as HTMLElement; - expect(preview).toBeTruthy(); + const flyout = document.querySelector(OPEN_FLYOUT) as HTMLElement; + expect(flyout).toBeTruthy(); // Items inside are removed from the tab order (keyboard-invisible)... - const subButton = preview.querySelector( + const subButton = flyout.querySelector( "[data-sidebar='menu-sub-button']", ) as HTMLElement; expect(subButton).toBeTruthy(); @@ -950,7 +943,7 @@ describe("Sidebar.Collapsible", () => { tooltip?: string; } = {}) { return ( - + @@ -1000,9 +993,7 @@ describe("Sidebar.Collapsible", () => { const trigger = screen.getByRole("button", { name: /Compute/i }); fireEvent.mouseEnter(trigger.parentElement!); - const popup1 = document.querySelector( - "[data-sidebar='collapsible-preview']", - ) as HTMLElement; + const popup1 = document.querySelector(OPEN_FLYOUT) as HTMLElement; expect(popup1).toBeTruthy(); // Hover the nested parent inside popup 1 to open popup 2. @@ -1011,8 +1002,7 @@ describe("Sidebar.Collapsible", () => { ) as HTMLElement; fireEvent.mouseEnter(nestedParent.parentElement!); - const popups = () => - document.querySelectorAll("[data-sidebar='collapsible-preview']"); + const popups = () => document.querySelectorAll(OPEN_FLYOUT); expect(popups().length).toBe(2); // Click a leaf (link) in the deepest popup: navigation is allowed and the @@ -1026,15 +1016,41 @@ describe("Sidebar.Collapsible", () => { 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( - "[data-sidebar='collapsible-preview']", - ) as HTMLElement; + const popup1 = document.querySelector(OPEN_FLYOUT) as HTMLElement; const nestedParent = popup1.querySelector( "[data-sidebar='menu-sub-button']", ) as HTMLElement; @@ -1051,20 +1067,16 @@ describe("Sidebar.Collapsible", () => { // 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("[data-sidebar='collapsible-preview']").length, - ).toBe(1); + expect(document.querySelectorAll(OPEN_FLYOUT).length).toBe(1); expect(nestedParent.getAttribute("data-open")).toBeNull(); fireEvent.mouseEnter(nestedParent.parentElement!); - expect( - document.querySelectorAll("[data-sidebar='collapsible-preview']").length, - ).toBe(2); + expect(document.querySelectorAll(OPEN_FLYOUT).length).toBe(2); }); function HrefParentTest() { return ( - + @@ -1114,9 +1126,7 @@ describe("Sidebar.Collapsible", () => { const trigger = screen.getByRole("button", { name: /Compute/i }); fireEvent.mouseEnter(trigger.parentElement!); - const popup1 = document.querySelector( - "[data-sidebar='collapsible-preview']", - ) as HTMLElement; + const popup1 = document.querySelector(OPEN_FLYOUT) as HTMLElement; const nestedParent = popup1.querySelector( 'a[href="/workers"]', ) as HTMLElement; @@ -1128,9 +1138,7 @@ describe("Sidebar.Collapsible", () => { // Clicking navigates (not preventDefault-ed) and dismisses the popup stack. const clickEvent = fireEvent.click(nestedParent); expect(clickEvent).toBe(true); - expect( - document.querySelectorAll("[data-sidebar='collapsible-preview']").length, - ).toBe(0); + expect(document.querySelectorAll(OPEN_FLYOUT).length).toBe(0); }); it("should have role=region on content", () => { @@ -1341,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( diff --git a/packages/kumo/src/components/sidebar/sidebar.tsx b/packages/kumo/src/components/sidebar/sidebar.tsx index cb8c058d7..2013d7ac6 100644 --- a/packages/kumo/src/components/sidebar/sidebar.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.tsx @@ -1,3 +1,4 @@ +import { PreviewCard as PreviewCardBase } from "@base-ui/react/preview-card"; import { ScrollArea as ScrollAreaBase } from "@base-ui/react/scroll-area"; import React, { type ComponentPropsWithoutRef, @@ -14,11 +15,11 @@ import React, { useState, useSyncExternalStore, } from "react"; -import { createPortal } from "react-dom"; 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"; @@ -98,8 +99,10 @@ 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 preview popup titles. */ +/** 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"])'; @@ -212,8 +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; - previewOnHover: boolean; + flyout: boolean; startPeek: () => void; stopPeek: () => void; contained: boolean; @@ -330,11 +335,13 @@ export interface SidebarProviderProps { * 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, previews appear regardless of the + * 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 */ - previewOnHover?: boolean; + flyout?: boolean; /** * Duration of sidebar expand/collapse animation in milliseconds. * @default 250 @@ -385,7 +392,7 @@ function SidebarProvider({ onWidthChange, contained = false, peekable = false, - previewOnHover = false, + flyout = false, animationDuration = SIDEBAR_ANIMATION_DURATION_MS, mobileBreakpoint, children, @@ -397,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. @@ -451,6 +459,7 @@ function SidebarProvider({ const stopPeek = useCallback(() => { setIsPeeking(false); + setIsPeekSettled(false); }, []); const state: SidebarState = isPeeking @@ -552,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, @@ -574,8 +581,9 @@ function SidebarProvider({ setIsResizing, setWidth, isPeeking, + isPeekSettled, peekable, - previewOnHover, + flyout, startPeek, stopPeek, contained, @@ -584,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( @@ -606,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 ( @@ -711,6 +761,7 @@ const SidebarRoot = forwardRef( startPeek, stopPeek, contained, + flyout, } = useSidebar(); // --- Mobile a11y hooks (must be before early returns) --- @@ -801,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 ( @@ -1445,7 +1517,7 @@ export interface SidebarMenuButtonProps extends Omit< /** * Collapsed-rail label. Defaults to the button's text; set this to override * (e.g. a shorter label). Not shown when the sidebar is peekable or a hover - * preview supplies the label. + * flyout supplies the label. */ tooltip?: string; /** Anchor id for `useSidebar().scrollToItem(id)`. */ @@ -1498,7 +1570,7 @@ const SidebarMenuButton = forwardRef( const { state, peekable, registerItem } = useSidebar(); const LinkComponent = useLinkComponent(); const isInsideMenuItem = useContext(MenuItemContext); - const { hasPreview } = useContext(SidebarCollapseContext); + const { hasFlyout } = useContext(SidebarCollapseContext); const registerAutoWrap = useCallback( (node: HTMLLIElement | null) => { @@ -1570,7 +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-[preview-open]: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 @@ -1630,8 +1702,8 @@ const SidebarMenuButton = forwardRef( // 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 - // preview owns the label (its popup carries the title and sits in front). - const showTooltip = state === "collapsed" && !peekable && !hasPreview; + // 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 = ( @@ -1808,7 +1880,7 @@ const SidebarMenuSubButton = forwardRef< "before:absolute before:inset-x-0 before:-inset-y-px", "text-kumo-default transition-[color] duration-150", !active && "hover:bg-(--sidebar-active-bg)", - !active && "data-[preview-open]:bg-(--sidebar-active-bg)", + !active && "data-[flyout-open]:bg-(--sidebar-active-bg)", active && "bg-(--sidebar-active-bg)", "focus:outline-none focus-visible:bg-(--sidebar-active-bg) focus-visible:text-kumo-strong", className, @@ -2226,14 +2298,14 @@ SidebarResizeHandle.displayName = "Sidebar.ResizeHandle"; interface SidebarCollapseContextValue { contentId: string; isOpen: boolean; - isPreviewOpen: boolean; + isFlyoutOpen: boolean; isCollapsible: boolean; autoScrollOnOpen: boolean; - /** True when this section can open a hover-preview popup. */ - hasPreview: boolean; + /** True when this section can open a hover-flyout popup. */ + hasFlyout: boolean; toggle: () => void; completeOpenChange: () => void; - closePreview: () => void; + closeFlyout: () => void; } const isCollapsibleContentShown = ( @@ -2244,51 +2316,51 @@ const isCollapsibleContentShown = ( const SidebarCollapseContext = createContext({ contentId: "", isOpen: true, - isPreviewOpen: false, + isFlyoutOpen: false, isCollapsible: false, autoScrollOnOpen: false, - hasPreview: false, + hasFlyout: false, toggle: () => {}, completeOpenChange: () => {}, - closePreview: () => {}, + closeFlyout: () => {}, }); -interface SidebarPreviewContextValue { - /** True when descendants are rendered inside a hover-preview popup. */ - insidePreview: boolean; +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 SidebarPreviewContext = createContext({ - insidePreview: false, +const SidebarFlyoutContext = createContext({ + insideFlyout: false, dismissAll: () => {}, }); -/** Preview popup width in px (matches the `w-56` class below). */ -const PREVIEW_WIDTH = 224; -/** Gap between the trigger and the preview popup in px. */ -const PREVIEW_GAP = 7; -/** Minimum viewport inset for the preview popup in px. */ -const PREVIEW_VIEWPORT_INSET = 8; +/** 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']"; /** - * Preview popup inner padding in px (must match the `p-1.5` class below). + * 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 PREVIEW_PADDING = 6; -/** Preview title row height in px (must match its `min-h-8.5` class). */ -const PREVIEW_TITLE_HEIGHT = 34; +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` (= PREVIEW_PADDING). + * plus its `mb-1.5` (= FLYOUT_PADDING). */ -const PREVIEW_TITLE_OFFSET = PREVIEW_TITLE_HEIGHT + PREVIEW_PADDING; +const FLYOUT_TITLE_OFFSET = FLYOUT_TITLE_HEIGHT + FLYOUT_PADDING; -function getCollapsiblePreviewContent(children: ReactNode) { - let previewContent: ReactNode = null; +function getCollapsibleFlyoutContent(children: ReactNode) { + let flyoutContent: ReactNode = null; React.Children.forEach(children, (child) => { if ( @@ -2296,11 +2368,11 @@ function getCollapsiblePreviewContent(children: ReactNode) { (child.type as { displayName?: string })?.displayName === "Sidebar.CollapsibleContent" ) { - previewContent = (child.props as { children?: ReactNode }).children; + flyoutContent = (child.props as { children?: ReactNode }).children; } }); - return previewContent; + return flyoutContent; } /** @@ -2319,7 +2391,7 @@ function getElementText(children: ReactNode): string { } /** - * Label for a collapsible's preview popup: the trigger's `tooltip` when set, + * 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 { @@ -2402,58 +2474,67 @@ const SidebarCollapsible = forwardRef( const sidebar = useSidebar(); const { animationDuration } = sidebar; const [internalOpen, setInternalOpen] = useState(defaultOpen); - const [previewOpen, setPreviewOpen] = useState(false); - const [previewPosition, setPreviewPosition] = useState({ top: 0, left: 0 }); + 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 previewRef = useRef(null); + const portalContainer = usePortalContainer(); const containerRef = useRef(null); - const closePreviewTimeoutRef = useRef | null>( + const closeFlyoutTimeoutRef = useRef | null>( null, ); - const previewContent = getCollapsiblePreviewContent(children); - const hasPreviewContent = previewContent !== null; - const previewLabel = getCollapsibleTriggerLabel(children); + const flyoutContent = getCollapsibleFlyoutContent(children); + const hasFlyoutContent = flyoutContent !== null; + const flyoutLabel = getCollapsibleTriggerLabel(children); const { isMobile, side, state, - previewOnHover: previewEnabled, + open: sidebarOpen, + peekable, + isPeeking, + isPeekSettled, + flyout: flyoutEnabled, } = useSidebar(); - - const clearPreviewCloseTimeout = useCallback(() => { - if (closePreviewTimeoutRef.current) { - clearTimeout(closePreviewTimeoutRef.current); - closePreviewTimeoutRef.current = null; + // 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 closePreview = useCallback(() => { - clearPreviewCloseTimeout(); - closePreviewTimeoutRef.current = setTimeout(() => { - setPreviewOpen(false); + const closeFlyout = useCallback(() => { + clearFlyoutCloseTimeout(); + closeFlyoutTimeoutRef.current = setTimeout(() => { + setFlyoutOpen(false); }, 120); - }, [clearPreviewCloseTimeout]); + }, [clearFlyoutCloseTimeout]); - const closePreviewImmediately = useCallback(() => { - clearPreviewCloseTimeout(); - setPreviewOpen(false); - }, [clearPreviewCloseTimeout]); + const closeFlyoutImmediately = useCallback(() => { + clearFlyoutCloseTimeout(); + setFlyoutOpen(false); + }, [clearFlyoutCloseTimeout]); // Chain up ancestor popups so a click anywhere in the stack dismisses all. - const parentPreview = useContext(SidebarPreviewContext); + const parentFlyout = useContext(SidebarFlyoutContext); // The title only renders for a collapsed, top-level popup with a label. - const showPreviewTitle = - state === "collapsed" && !parentPreview.insidePreview && !!previewLabel; + const showFlyoutTitle = + state === "collapsed" && !parentFlyout.insideFlyout && !!flyoutLabel; const dismissAll = useCallback(() => { - closePreviewImmediately(); - parentPreview.dismissAll(); - }, [closePreviewImmediately, parentPreview]); + closeFlyoutImmediately(); + parentFlyout.dismissAll(); + }, [closeFlyoutImmediately, parentFlyout]); - const previewProviderValue = useMemo( - () => ({ insidePreview: true, dismissAll }), + const flyoutProviderValue = useMemo( + () => ({ insideFlyout: true, dismissAll }), [dismissAll], ); @@ -2471,134 +2552,125 @@ const SidebarCollapsible = forwardRef( onOpenChangeComplete, ); - const openPreview = useCallback(() => { + const openFlyout = useCallback(() => { if ( - !previewEnabled || + !flyoutEnabled || isMobile || - !hasPreviewContent || + isBlockedByPeek || + !hasFlyoutContent || !containerRef.current ) { return; } // A collapsed rail cannot render inline content (see - // SidebarCollapsibleContent), so preview regardless of open state. - // Otherwise only preview closed sections — open ones show inline. + // SidebarCollapsibleContent), so show the flyout regardless of open state. + // Otherwise only closed sections get one — open ones show inline. if (state !== "collapsed" && isOpen) { return; } - clearPreviewCloseTimeout(); - - const trigger = containerRef.current.querySelector( - "[data-sidebar='menu-button'], [data-sidebar='menu-sub-button']", - ); - const anchor = trigger ?? containerRef.current; - const rect = anchor.getBoundingClientRect(); - - // When the trigger lives inside another preview popup, measure the - // horizontal gap from that popup's outer edge (not the button's, which - // is inset by the popup's padding/indent). Keeps every popup-to-popup - // gap visually equal. - const parentPopup = anchor.closest( - "[data-sidebar='collapsible-preview']", - ); - const horizontalRect = parentPopup?.getBoundingClientRect() ?? rect; - - const rawLeft = - side === "left" - ? horizontalRect.right + PREVIEW_GAP - : horizontalRect.left - PREVIEW_WIDTH - PREVIEW_GAP; - const maxLeft = - window.innerWidth - PREVIEW_WIDTH - PREVIEW_VIEWPORT_INSET; - const maxTop = window.innerHeight - PREVIEW_VIEWPORT_INSET; - - // Align the first item with the trigger, skipping the title (if any) and - // the popup's padding. - const topOffset = showPreviewTitle - ? PREVIEW_TITLE_OFFSET - : PREVIEW_PADDING; - - setPreviewPosition({ - top: Math.min( - Math.max(PREVIEW_VIEWPORT_INSET, rect.top - topOffset), - Math.max(PREVIEW_VIEWPORT_INSET, maxTop), - ), - left: Math.min( - Math.max(PREVIEW_VIEWPORT_INSET, rawLeft), - Math.max(PREVIEW_VIEWPORT_INSET, maxLeft), - ), - }); - setPreviewOpen(true); + clearFlyoutCloseTimeout(); + setFlyoutOpen(true); }, [ - clearPreviewCloseTimeout, - hasPreviewContent, + clearFlyoutCloseTimeout, + hasFlyoutContent, + isBlockedByPeek, isMobile, isOpen, - previewEnabled, - showPreviewTitle, - side, + 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); - openPreview(); + isHoveredRef.current = true; + openFlyout(); }, - [onMouseEnter, openPreview], + [onMouseEnter, openFlyout], ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { onMouseLeave?.(e); - closePreview(); + isHoveredRef.current = false; + closeFlyout(); }, - [closePreview, onMouseLeave], + [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, - isPreviewOpen: previewOpen, + isFlyoutOpen: flyoutOpen, isCollapsible: true, autoScrollOnOpen, - hasPreview: previewEnabled && hasPreviewContent, + hasFlyout: flyoutEnabled && hasFlyoutContent, toggle, completeOpenChange, - closePreview: closePreviewImmediately, + closeFlyout: closeFlyoutImmediately, }), [ contentId, isOpen, - previewOpen, + flyoutOpen, autoScrollOnOpen, - previewEnabled, - hasPreviewContent, + flyoutEnabled, + hasFlyoutContent, toggle, completeOpenChange, - closePreviewImmediately, + closeFlyoutImmediately, ], ); useEffect( () => () => { - clearPreviewCloseTimeout(); + clearFlyoutCloseTimeout(); }, - [clearPreviewCloseTimeout], + [clearFlyoutCloseTimeout], ); - // The preview is a mouse-only affordance: it stays clickable, but its + // 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 (!previewOpen || !previewRef.current) return; - const focusables = previewRef.current.querySelectorAll( + if (!flyoutNode) return; + const focusables = flyoutNode.querySelectorAll( "a[href], button, input, select, textarea, [tabindex]", ); for (const el of focusables) el.setAttribute("tabindex", "-1"); - }, [previewOpen, previewContent]); + }, [flyoutNode, flyoutContent]); const handleFocusIn = useCallback( (e: React.FocusEvent) => { @@ -2646,67 +2718,89 @@ const SidebarCollapsible = forwardRef( {...props} > {children} - {previewOpen && - previewContent && - typeof document !== "undefined" && - createPortal( - - - )} - {/* Flatten the top-level sub-list (no indent/connector) since + {/* Flatten the top-level sub-list (no indent/connector) since the popup already opens from its parent; deeper levels keep theirs. `>` targets only the first level. */} -
[data-sidebar=menu-sub]]:pl-0", - "[&>[data-sidebar=menu-sub]>li[aria-hidden=true]]:hidden", - )} - > - {previewContent} -
-
- , - document.body, - )} +
[data-sidebar=menu-sub]]:pl-0", + "[&>[data-sidebar=menu-sub]>li[aria-hidden=true]]:hidden", + )} + > + {flyoutContent} +
+ + + + + + )}
); @@ -2737,10 +2831,10 @@ export interface SidebarCollapsibleTriggerProps { * ``` */ function SidebarCollapsibleTrigger({ render }: SidebarCollapsibleTriggerProps) { - const { contentId, isOpen, isPreviewOpen, toggle, closePreview } = useContext( + const { contentId, isOpen, isFlyoutOpen, toggle, closeFlyout } = useContext( SidebarCollapseContext, ); - const { insidePreview } = useContext(SidebarPreviewContext); + const { insideFlyout } = useContext(SidebarFlyoutContext); const hasHref = Boolean((render.props as { href?: string }).href); @@ -2748,12 +2842,12 @@ function SidebarCollapsibleTrigger({ render }: SidebarCollapsibleTriggerProps) { "aria-expanded": isOpen, "aria-controls": contentId, "data-open": isOpen || undefined, - "data-preview-open": isPreviewOpen || 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. - ...(insidePreview && !hasHref + ...(insideFlyout && !hasHref ? { className: cn( (render.props as { className?: string }).className, @@ -2771,7 +2865,7 @@ function SidebarCollapsibleTrigger({ render }: SidebarCollapsibleTriggerProps) { // 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 (insidePreview) { + 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. @@ -2779,7 +2873,7 @@ function SidebarCollapsibleTrigger({ render }: SidebarCollapsibleTriggerProps) { return; } - closePreview(); + closeFlyout(); toggle(); }, } as Record);