+ Set flyout on the Provider (default
+ false) to reveal a closed collapsible's nested
+ content in a flyout on hover, instead of expanding it inline. A collapsed rail can't render inline
+ content, so its sections show a flyout regardless of open state. Flyouts are mouse-only (hidden from the
+ accessibility tree); keyboard and assistive-tech users get inline expansion.
+
+
+ The collapsed top-level popup shows the parent's name as a title (from the trigger's
+ tooltip, or its text when omitted), replacing
+ the trigger's own tooltip. Nested popups omit it — the parent row is already visible beside them. Items
+ with no nested content keep their tooltip instead. Every popup lines its first item up with the trigger, so
+ the pointer can move straight across onto it. Combined with
+ peekable, hovering the collapsed rail peeks
+ first; flyouts only appear once the sidebar has fully expanded.
+
+
+
+
+
+
+ {/* Childless: no popup, tooltip falls back to the button text */}
+ Analytics
+
+
+
+
+ Compute
+
+ }
+ />
+
+ ...
+
+
+
+
+
+
+`}>
+
+
+
+
Sliding Views
diff --git a/packages/kumo/src/components/sidebar/sidebar.test.tsx b/packages/kumo/src/components/sidebar/sidebar.test.tsx
index 61a3c878c..57ec28df9 100644
--- a/packages/kumo/src/components/sidebar/sidebar.test.tsx
+++ b/packages/kumo/src/components/sidebar/sidebar.test.tsx
@@ -57,6 +57,10 @@ function fireTransitionEnd(element: HTMLElement, propertyName: string) {
}
/** Minimal sidebar wrapper for tests that need Provider context. */
+// Base UI keeps a closing flyout mounted (`data-closed`) until its exit
+// animation settles, so only count the open ones.
+const OPEN_FLYOUT = "[data-sidebar='collapsible-flyout']:not([data-closed])";
+
function TestSidebar({
children,
...providerProps
@@ -536,7 +540,7 @@ describe("Sidebar.Collapsible", () => {
sidebarOpen?: boolean;
}) {
return (
-
+
@@ -556,7 +560,10 @@ describe("Sidebar.Collapsible", () => {
/>
- Workers
+
+
+ Workers
+
@@ -599,6 +606,541 @@ describe("Sidebar.Collapsible", () => {
expect(trigger.getAttribute("aria-controls")).toBe(content.id);
});
+ it("should show nested content in a flyout on hover without toggling open", () => {
+ vi.useFakeTimers();
+ render();
+
+ try {
+ const trigger = screen.getByRole("button", { name: /Compute/i });
+ const collapsible = trigger.parentElement!;
+ const content = screen.getByTestId("collapsible-content");
+
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ expect(content.getAttribute("aria-hidden")).toBe("true");
+
+ fireEvent.mouseEnter(collapsible);
+
+ const flyout = document.querySelector(OPEN_FLYOUT);
+ expect(flyout).toBeTruthy();
+ // Mouse-only affordance: hidden from the accessibility tree, but NOT
+ // inert (so it stays clickable). Keyboard users get inline expansion.
+ expect(flyout?.getAttribute("aria-hidden")).toBe("true");
+ expect(flyout?.hasAttribute("inert")).toBe(false);
+ expect(flyout?.hasAttribute("role")).toBe(false);
+ expect(flyout?.textContent).toContain("Workers");
+
+ // The popup opens from its parent trigger, so the top-level sub-list sits
+ // flush: a wrapper collapses the indent and hides the tree connector line
+ // via direct-child (`>`) overrides. Icons still pass through.
+ const flyoutSub = flyout?.querySelector(
+ "[data-sidebar='menu-sub']",
+ ) as HTMLElement;
+ expect(flyoutSub).toBeTruthy();
+ // The line is still in the DOM (hidden via CSS), not removed.
+ expect(
+ flyoutSub.querySelector(":scope > li[aria-hidden='true']"),
+ ).toBeTruthy();
+ // Wrapper carries the top-level-only flatten overrides.
+ const flattenWrapper = flyoutSub.parentElement as HTMLElement;
+ expect(flattenWrapper.className).toContain(
+ "[&>[data-sidebar=menu-sub]]:pl-0",
+ );
+ expect(flattenWrapper.className).toContain(
+ "[&>[data-sidebar=menu-sub]>li[aria-hidden=true]]:hidden",
+ );
+ expect(flyout?.querySelector("[data-testid='sub-icon']")).toBeTruthy();
+ expect(
+ (flyout as HTMLElement).style.getPropertyValue("--sidebar-active-bg"),
+ ).toBe("var(--color-kumo-tint)");
+ expect(trigger.getAttribute("data-flyout-open")).toBe("true");
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ expect(content.getAttribute("aria-hidden")).toBe("true");
+
+ fireEvent.click(trigger);
+
+ expect(document.querySelector(OPEN_FLYOUT)).toBeNull();
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
+ expect(content.getAttribute("aria-hidden")).toBe("false");
+
+ fireEvent.click(trigger);
+ fireEvent.mouseEnter(collapsible);
+
+ fireEvent.mouseLeave(collapsible);
+ fireEvent.mouseEnter(document.querySelector(OPEN_FLYOUT) as HTMLElement);
+ act(() => {
+ vi.advanceTimersByTime(120);
+ });
+
+ expect(document.querySelector(OPEN_FLYOUT)).toBeTruthy();
+
+ fireEvent.mouseLeave(document.querySelector(OPEN_FLYOUT) as HTMLElement);
+ act(() => {
+ vi.advanceTimersByTime(120);
+ });
+
+ expect(document.querySelector(OPEN_FLYOUT)).toBeNull();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ // Single collapsible driving all flyout tests: the provider toggle, initial
+ // rail/section open states, and the trigger's optional `tooltip`.
+ function FlyoutFixture({
+ flyout,
+ peekable,
+ railOpen = true,
+ sectionOpen = false,
+ tooltip,
+ }: {
+ flyout?: boolean;
+ peekable?: boolean;
+ railOpen?: boolean;
+ sectionOpen?: boolean;
+ tooltip?: string;
+ }) {
+ return (
+
+
+
+
+
+
+ Compute
+
+
+ }
+ />
+
+
+ Workers
+
+
+
+
+
+
+
+ );
+ }
+
+ const flyoutEl = () => document.querySelector(OPEN_FLYOUT);
+
+ it("should wait for a peek to finish expanding before showing a flyout", () => {
+ vi.useFakeTimers();
+ try {
+ render();
+ const peekZone = document.querySelector("[data-sidebar='peek-zone']")!;
+ const collapsible = screen.getByRole("button", {
+ name: /Compute/i,
+ }).parentElement!;
+
+ // The pointer enters the rail straight onto the section: peek starts,
+ // but the flyout holds off while the sidebar expands under it.
+ fireEvent.mouseEnter(peekZone);
+ fireEvent.mouseEnter(collapsible);
+ expect(flyoutEl()).toBeNull();
+
+ // Once the peek settles, the resting pointer gets its flyout, laid
+ // out as in an expanded sidebar (no title).
+ act(() => {
+ vi.advanceTimersByTime(400);
+ });
+ expect(flyoutEl()).toBeTruthy();
+ expect(
+ document.querySelector("[data-sidebar='collapsible-flyout-title']"),
+ ).toBeNull();
+
+ // Leaving keeps the peek briefly (to cross into the popup), then ends
+ // it and closes the flyout.
+ fireEvent.mouseLeave(collapsible);
+ fireEvent.mouseLeave(peekZone);
+ act(() => {
+ vi.advanceTimersByTime(400);
+ });
+ expect(flyoutEl()).toBeNull();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("should not show a flyout when flyout is unset (opt-in)", () => {
+ render();
+ fireEvent.mouseEnter(
+ screen.getByRole("button", { name: /Compute/i }).parentElement!,
+ );
+ expect(flyoutEl()).toBeNull();
+ });
+
+ it("should show a flyout when the Provider opts in", () => {
+ render();
+ fireEvent.mouseEnter(
+ screen.getByRole("button", { name: /Compute/i }).parentElement!,
+ );
+ expect(flyoutEl()).toBeTruthy();
+ });
+
+ it("should not show a flyout for an open section when the rail is expanded", () => {
+ render();
+ fireEvent.mouseEnter(
+ screen.getByRole("button", { name: /Compute/i }).parentElement!,
+ );
+ // Expanded rail already shows the section inline → no popup.
+ expect(flyoutEl()).toBeNull();
+ });
+
+ it("should show a flyout for an open section when the rail is collapsed", () => {
+ // A collapsed rail cannot render inline content, so even a section marked
+ // open must fall back to the hover popup.
+ render();
+ fireEvent.mouseEnter(
+ screen.getByRole("button", { name: /Compute/i }).parentElement!,
+ );
+ expect(flyoutEl()).toBeTruthy();
+ });
+
+ // Positioning itself is Base UI's job; assert we anchor beside the trigger,
+ // away from the sidebar's own side, aligned to its start.
+ it.each([
+ ["left", "right"],
+ ["right", "left"],
+ ] as const)(
+ "should anchor the flyout beside the trigger (sidebar %s → %s)",
+ (side, flyoutSide) => {
+ render(
+
+
+
+
+
+ Compute}
+ />
+
+
+ Workers
+
+
+
+
+
+
+ ,
+ );
+ fireEvent.mouseEnter(
+ screen.getByRole("button", { name: /Compute/i }).parentElement!,
+ );
+ const flyout = flyoutEl() as HTMLElement;
+ expect(flyout.getAttribute("data-side")).toBe(flyoutSide);
+ expect(flyout.getAttribute("data-align")).toBe("start");
+ },
+ );
+
+ const titleEl = () =>
+ document.querySelector(
+ "[data-sidebar='collapsible-flyout-title']",
+ ) as HTMLElement | null;
+
+ it("should replace the tooltip with a popup title on collapsed flyout", () => {
+ // With a flyout available the tooltip is suppressed (showTooltip is
+ // gated on !hasFlyout) and the popup carries the parent's name instead.
+ // The tooltip popup is portaled + hover/delay driven and unreliable under
+ // happy-dom, so we assert the observable outcome: flyout + title present,
+ // no tooltip popup leaked into the DOM.
+ render();
+
+ fireEvent.mouseEnter(
+ screen.getByRole("button", { name: /Compute/i }).parentElement!,
+ );
+
+ expect(flyoutEl()).toBeTruthy();
+ expect(titleEl()?.textContent).toBe("Compute");
+ expect(document.querySelector(".kumo-tooltip-popup")).toBeNull();
+ });
+
+ it("should title the collapsed popup from the trigger's tooltip", () => {
+ render();
+ fireEvent.mouseEnter(
+ screen.getByRole("button", { name: /Compute/i }).parentElement!,
+ );
+ expect(titleEl()?.textContent).toBe("Compute");
+ });
+
+ it("should title the collapsed popup from the trigger's text when no tooltip", () => {
+ render();
+ fireEvent.mouseEnter(
+ screen.getByRole("button", { name: /Compute/i }).parentElement!,
+ );
+ // Text extraction skips the chevron element → just the label.
+ expect(titleEl()?.textContent).toBe("Compute");
+ });
+
+ it("should not title the popup when the rail is expanded", () => {
+ render();
+ fireEvent.mouseEnter(
+ screen.getByRole("button", { name: /Compute/i }).parentElement!,
+ );
+ expect(flyoutEl()).toBeTruthy();
+ expect(titleEl()).toBeNull();
+ });
+
+ it("should title only the top-level popup, not nested ones", () => {
+ render();
+
+ // Open the top-level popup, then the nested one.
+ fireEvent.mouseEnter(
+ screen.getByRole("button", { name: /Compute/i }).parentElement!,
+ );
+ const popup1 = flyoutEl() as HTMLElement;
+ fireEvent.mouseEnter(
+ popup1.querySelector("[data-sidebar='menu-sub-button']")!.parentElement!,
+ );
+
+ expect(document.querySelectorAll(OPEN_FLYOUT).length).toBe(2);
+ // Nested popup omits the title — its parent row shows in popup 1 to the
+ // left — so only the top-level "Compute" title exists.
+ const titles = document.querySelectorAll(
+ "[data-sidebar='collapsible-flyout-title']",
+ );
+ expect(titles.length).toBe(1);
+ expect(titles[0].textContent).toBe("Compute");
+ });
+
+ it("should keep flyout items clickable but out of the tab order", () => {
+ render();
+
+ const trigger = screen.getByRole("button", { name: /Compute/i });
+ const collapsible = trigger.parentElement!;
+
+ // Keyboard never opens the popup — the trigger does not advertise one.
+ expect(trigger.hasAttribute("aria-haspopup")).toBe(false);
+
+ fireEvent.mouseEnter(collapsible);
+ const flyout = document.querySelector(OPEN_FLYOUT) as HTMLElement;
+ expect(flyout).toBeTruthy();
+
+ // Items inside are removed from the tab order (keyboard-invisible)...
+ const subButton = flyout.querySelector(
+ "[data-sidebar='menu-sub-button']",
+ ) as HTMLElement;
+ expect(subButton).toBeTruthy();
+ expect(subButton.getAttribute("tabindex")).toBe("-1");
+
+ // ...but remain mouse-clickable.
+ const onClick = vi.fn();
+ subButton.addEventListener("click", onClick);
+ fireEvent.click(subButton);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ function NestedCollapsibleTest({
+ railOpen = true,
+ tooltip,
+ }: {
+ railOpen?: boolean;
+ tooltip?: string;
+ } = {}) {
+ return (
+
+
+
+
+
+
+ Compute
+
+
+ }
+ />
+
+
+
+
+
+ Workers & Pages
+
+
+ }
+ />
+
+
+
+ Overview
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ it("should dismiss the whole popup stack when a leaf item is clicked", () => {
+ render();
+
+ const trigger = screen.getByRole("button", { name: /Compute/i });
+ fireEvent.mouseEnter(trigger.parentElement!);
+
+ const popup1 = document.querySelector(OPEN_FLYOUT) as HTMLElement;
+ expect(popup1).toBeTruthy();
+
+ // Hover the nested parent inside popup 1 to open popup 2.
+ const nestedParent = popup1.querySelector(
+ "[data-sidebar='menu-sub-button']",
+ ) as HTMLElement;
+ fireEvent.mouseEnter(nestedParent.parentElement!);
+
+ const popups = () => document.querySelectorAll(OPEN_FLYOUT);
+ expect(popups().length).toBe(2);
+
+ // Click a leaf (link) in the deepest popup: navigation is allowed and the
+ // entire popup stack is dismissed. (Popups are aria-hidden, so query the
+ // DOM directly rather than by accessible role.)
+ const popup2 = popups()[1] as HTMLElement;
+ const overview = popup2.querySelector('a[href="/overview"]') as HTMLElement;
+ expect(overview).toBeTruthy();
+ const clickEvent = fireEvent.click(overview);
+ expect(clickEvent).toBe(true); // not preventDefault-ed → navigation proceeds
+ expect(popups().length).toBe(0);
+ });
+
+ it("should keep the popup stack open on a press inside a nested popup", async () => {
+ render();
+
+ fireEvent.mouseEnter(
+ screen.getByRole("button", { name: /Compute/i }).parentElement!,
+ );
+ const popup1 = document.querySelector(OPEN_FLYOUT) as HTMLElement;
+ fireEvent.mouseEnter(
+ popup1.querySelector("[data-sidebar='menu-sub-button']")!.parentElement!,
+ );
+ const popups = () => document.querySelectorAll(OPEN_FLYOUT);
+ expect(popups().length).toBe(2);
+
+ // Popup 2 sits outside popup 1's DOM but inside its React tree, so it
+ // counts as inside the stack and nothing closes.
+ const popup2 = popups()[1] as HTMLElement;
+ fireEvent.pointerDown(popup2);
+ fireEvent.mouseDown(popup2);
+ await act(async () => {});
+ expect(popups().length).toBe(2);
+
+ // A real outside press closes the stack.
+ fireEvent.pointerDown(screen.getByTestId("main"));
+ fireEvent.mouseDown(screen.getByTestId("main"));
+ await act(async () => {});
+ expect(popups().length).toBe(0);
+ });
+
+ it("should not toggle a parent's disclosure when clicked inside a popup", () => {
+ render();
+
+ const trigger = screen.getByRole("button", { name: /Compute/i });
+ fireEvent.mouseEnter(trigger.parentElement!);
+
+ const popup1 = document.querySelector(OPEN_FLYOUT) as HTMLElement;
+ const nestedParent = popup1.querySelector(
+ "[data-sidebar='menu-sub-button']",
+ ) as HTMLElement;
+
+ // Chevron on a parent inside the popup is static (never rotated open).
+ const chevron = nestedParent.querySelector("svg") as SVGElement;
+ expect(chevron.getAttribute("class") ?? "").not.toContain("rotate-90");
+
+ // A hover-only parent (children, no href) has no click action → no pointer.
+ // `!` beats the unlayered cursor:pointer base rule in kumo.css. (Only the
+ // class can be checked here; happy-dom doesn't load Tailwind CSS.)
+ expect(nestedParent.className).toContain("cursor-default!");
+
+ // Clicking the parent keeps the popup open (children are hover-only) and
+ // does not flip the disclosure — so hovering it still opens its popup.
+ fireEvent.click(nestedParent);
+ expect(document.querySelectorAll(OPEN_FLYOUT).length).toBe(1);
+ expect(nestedParent.getAttribute("data-open")).toBeNull();
+
+ fireEvent.mouseEnter(nestedParent.parentElement!);
+ expect(document.querySelectorAll(OPEN_FLYOUT).length).toBe(2);
+ });
+
+ function HrefParentTest() {
+ return (
+
+
+
+
+
+
+ Compute
+
+
+ }
+ />
+
+
+
+
+
+ Workers & Pages
+
+
+ }
+ />
+
+
+
+ Overview
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ it("should navigate and dismiss all popups when a parent with href is clicked", () => {
+ render();
+
+ const trigger = screen.getByRole("button", { name: /Compute/i });
+ fireEvent.mouseEnter(trigger.parentElement!);
+
+ const popup1 = document.querySelector(OPEN_FLYOUT) as HTMLElement;
+ const nestedParent = popup1.querySelector(
+ 'a[href="/workers"]',
+ ) as HTMLElement;
+ expect(nestedParent).toBeTruthy();
+
+ // A parent with an href is a real link → keeps the pointer cursor.
+ expect(nestedParent.className).not.toContain("cursor-default");
+
+ // Clicking navigates (not preventDefault-ed) and dismisses the popup stack.
+ const clickEvent = fireEvent.click(nestedParent);
+ expect(clickEvent).toBe(true);
+ expect(document.querySelectorAll(OPEN_FLYOUT).length).toBe(0);
+ });
+
it("should have role=region on content", () => {
render();
const content = screen.getByTestId("collapsible-content");
@@ -807,6 +1349,23 @@ describe("Sidebar peeking", () => {
expect(screen.getByTestId("state-reader").dataset.peeking).toBe("false");
});
+ it("should peek once peekable turns on while already collapsed", () => {
+ const ui = (peekable: boolean) => (
+
+
+
+
+
+ );
+ const { rerender } = render(ui(false));
+ // Nothing else changes, so the context must still pick up the new prop.
+ rerender(ui(true));
+
+ fireEvent.mouseEnter(document.querySelector("[data-sidebar='peek-zone']")!);
+
+ expect(screen.getByTestId("state-reader").dataset.state).toBe("peeking");
+ });
+
it("should peek on mouseEnter when collapsed and peekable", () => {
render(
@@ -1047,6 +1606,37 @@ describe("Sidebar.MenuButton", () => {
expect(link!.getAttribute("data-active")).toBe("true");
expect(link!.getAttribute("data-kumo-part")).toBe("menu-button-link");
});
+
+ // The collapsed rail labels every item via a tooltip. The popup render is
+ // portal + hover-delay driven (unreliable under happy-dom), so we assert the
+ // wrapping: a labelled button becomes a tooltip trigger, a bare one does not.
+ it("should tooltip a collapsed item from its own text when no tooltip is set", () => {
+ render(
+
+
+
+ Analytics
+
+
+ ,
+ );
+ const button = screen.getByRole("button", { name: "Analytics" });
+ expect(button.hasAttribute("data-base-ui-tooltip-trigger")).toBe(true);
+ });
+
+ it("should not tooltip a collapsed item with no label", () => {
+ render(
+
+
+
+
+
+
+ ,
+ );
+ const button = screen.getByRole("button", { name: "icon only" });
+ expect(button.hasAttribute("data-base-ui-tooltip-trigger")).toBe(false);
+ });
});
describe("Sidebar.MenuSub", () => {
diff --git a/packages/kumo/src/components/sidebar/sidebar.tsx b/packages/kumo/src/components/sidebar/sidebar.tsx
index 93e301877..2013d7ac6 100644
--- a/packages/kumo/src/components/sidebar/sidebar.tsx
+++ b/packages/kumo/src/components/sidebar/sidebar.tsx
@@ -1,3 +1,5 @@
+import { PreviewCard as PreviewCardBase } from "@base-ui/react/preview-card";
+import { ScrollArea as ScrollAreaBase } from "@base-ui/react/scroll-area";
import React, {
type ComponentPropsWithoutRef,
type CSSProperties,
@@ -7,20 +9,20 @@ import React, {
useCallback,
useContext,
useEffect,
- useMemo,
useId,
+ useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
-import { ScrollArea as ScrollAreaBase } from "@base-ui/react/scroll-area";
import { CaretRightIcon, XIcon } from "@phosphor-icons/react";
import { cn } from "../../utils/cn";
import { useLinkComponent } from "../../utils/link-provider";
+import { usePortalContainer } from "../../utils/portal-provider";
+import { Button } from "../button";
import { SkeletonLine } from "../loader/skeleton-line";
import { Tooltip, TooltipProvider } from "../tooltip";
-import { Button } from "../button";
// ============================================================================
// Variants (required by Kumo convention)
@@ -97,7 +99,11 @@ const SIDEBAR_WIDTH_ICON = "57px";
const SIDEBAR_EASING = "cubic-bezier(0.77, 0, 0.175, 1)";
const SIDEBAR_ANIMATION_DURATION_MS = 250;
const TRANSITION_FALLBACK_GRACE_MS = 50;
+/** Grace before a peek collapses on mouse leave, with hover flyouts on. */
+const PEEK_LEAVE_DELAY_MS = 120;
const MOBILE_BREAKPOINT = 768;
+/** Label typography shared by group labels and flyout popup titles. */
+const SIDEBAR_LABEL_TEXT = "truncate text-sm font-medium text-kumo-subtle";
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
@@ -209,7 +215,10 @@ export interface SidebarContextValue {
setIsResizing: (resizing: boolean) => void;
setWidth: (width: number) => void;
isPeeking: boolean;
+ /** `true` once a peek has finished expanding. Hover flyouts wait for it. */
+ isPeekSettled: boolean;
peekable: boolean;
+ flyout: boolean;
startPeek: () => void;
stopPeek: () => void;
contained: boolean;
@@ -322,6 +331,17 @@ export interface SidebarProviderProps {
* @default false
*/
peekable?: boolean;
+ /**
+ * When true, hovering a closed collapsible section shows its nested content in
+ * a floating popup instead of expanding inline.
+ *
+ * When the sidebar rail is collapsed, flyouts appear regardless of the
+ * section's open state (inline content cannot render in a collapsed rail).
+ * Combined with `peekable`, the peek handles the collapsed rail: flyouts
+ * wait until it has fully expanded.
+ * @default false
+ */
+ flyout?: boolean;
/**
* Duration of sidebar expand/collapse animation in milliseconds.
* @default 250
@@ -372,6 +392,7 @@ function SidebarProvider({
onWidthChange,
contained = false,
peekable = false,
+ flyout = false,
animationDuration = SIDEBAR_ANIMATION_DURATION_MS,
mobileBreakpoint,
children,
@@ -383,6 +404,7 @@ function SidebarProvider({
const [width, setWidthState] = useState(defaultWidth);
const [isResizing, setIsResizing] = useState(false);
const [isPeeking, setIsPeeking] = useState(false);
+ const [isPeekSettled, setIsPeekSettled] = useState(false);
// When controlled (`openProp` provided), use it directly for mobile too.
// When uncontrolled, use internal `_openMobile` state.
@@ -437,6 +459,7 @@ function SidebarProvider({
const stopPeek = useCallback(() => {
setIsPeeking(false);
+ setIsPeekSettled(false);
}, []);
const state: SidebarState = isPeeking
@@ -538,8 +561,6 @@ function SidebarProvider({
[scrollToItem],
);
- // eslint-disable-next-line react-hooks/exhaustive-deps -- all values are
- // either stable (props, setters) or derived from state that triggers re-render
const contextValue = useMemo(
() => ({
state,
@@ -560,7 +581,9 @@ function SidebarProvider({
setIsResizing,
setWidth,
isPeeking,
+ isPeekSettled,
peekable,
+ flyout,
startPeek,
stopPeek,
contained,
@@ -569,7 +592,42 @@ function SidebarProvider({
scrollToItem,
scrollItemIntoView,
}),
- [state, open, openMobile, isMobile, width, isResizing, isPeeking],
+ [
+ state,
+ open,
+ setOpen,
+ openMobile,
+ setOpenMobile,
+ isMobile,
+ toggleSidebar,
+ variant,
+ side,
+ collapsible,
+ width,
+ resizable,
+ minWidth,
+ maxWidth,
+ isResizing,
+ setWidth,
+ isPeeking,
+ isPeekSettled,
+ peekable,
+ flyout,
+ startPeek,
+ stopPeek,
+ contained,
+ animationDuration,
+ registerItem,
+ scrollToItem,
+ scrollItemIntoView,
+ ],
+ );
+
+ const completePeek = useOpenChangeComplete(
+ isPeeking,
+ animationDuration,
+ setIsPeekSettled,
+ !isMobile,
);
const completeDesktopOpenChange = useOpenChangeComplete(
@@ -591,15 +649,22 @@ function SidebarProvider({
const handleOpenTransitionEnd = useCallback(
(event: React.TransitionEvent) => {
const target = event.target as HTMLElement;
+ if (target.closest("[data-sidebar-wrapper]") !== event.currentTarget) {
+ return;
+ }
if (
- target.closest("[data-sidebar-wrapper]") === event.currentTarget &&
target.dataset.sidebar === "sidebar" &&
event.propertyName === transitionProperty
) {
completeOpenChange();
+ } else if (
+ target.dataset.sidebar === "content-container" &&
+ event.propertyName === "width"
+ ) {
+ completePeek();
}
},
- [completeOpenChange, transitionProperty],
+ [completeOpenChange, completePeek, transitionProperty],
);
return (
@@ -696,6 +761,7 @@ const SidebarRoot = forwardRef(
startPeek,
stopPeek,
contained,
+ flyout,
} = useSidebar();
// --- Mobile a11y hooks (must be before early returns) ---
@@ -786,15 +852,36 @@ const SidebarRoot = forwardRef(
[stopPeek],
);
+ const stopPeekTimeoutRef = useRef | null>(
+ null,
+ );
+ const clearStopPeekTimeout = useCallback(() => {
+ if (stopPeekTimeoutRef.current) {
+ clearTimeout(stopPeekTimeoutRef.current);
+ stopPeekTimeoutRef.current = null;
+ }
+ }, []);
+
+ useEffect(() => clearStopPeekTimeout, [clearStopPeekTimeout]);
+
const handlePeekMouseEnter = useCallback(() => {
isPointerInPeekZoneRef.current = true;
+ clearStopPeekTimeout();
startPeek();
- }, [startPeek]);
+ }, [clearStopPeekTimeout, startPeek]);
const handlePeekMouseLeave = useCallback(() => {
isPointerInPeekZoneRef.current = false;
- stopPeek();
- }, [stopPeek]);
+ clearStopPeekTimeout();
+ // Hover flyouts portal outside the sidebar, past a small gap. Give the
+ // pointer time to cross it: entering a flyout re-enters the peek zone
+ // (portals bubble through the React tree) and cancels the stop.
+ if (flyout) {
+ stopPeekTimeoutRef.current = setTimeout(stopPeek, PEEK_LEAVE_DELAY_MS);
+ } else {
+ stopPeek();
+ }
+ }, [clearStopPeekTimeout, flyout, stopPeek]);
if (collapsible === "none") {
return (
@@ -1274,7 +1361,8 @@ const SidebarGroupLabel = forwardRef<
(
const { state, peekable, registerItem } = useSidebar();
const LinkComponent = useLinkComponent();
const isInsideMenuItem = useContext(MenuItemContext);
+ const { hasFlyout } = useContext(SidebarCollapseContext);
const registerAutoWrap = useCallback(
(node: HTMLLIElement | null) => {
@@ -1548,6 +1642,7 @@ const SidebarMenuButton = forwardRef(
"text-kumo-default",
"transition-[color,box-shadow,outline] duration-(--sidebar-animation-duration)",
!active && "hover:bg-(--sidebar-active-bg)",
+ !active && "data-[flyout-open]:bg-(--sidebar-active-bg)",
// Active state
active && "bg-(--sidebar-active-bg)",
// When a child sub-button is active, don't show active styling on the parent trigger
@@ -1602,16 +1697,18 @@ const SidebarMenuButton = forwardRef(
);
}
- // Always wrap in Tooltip when tooltip text is provided so the DOM
- // structure stays stable across expand/collapse — preventing React from
- // remounting the button (which would kill CSS transitions).
- // The tooltip popup only shows when collapsed and peeking is disabled —
- // when peekable, hovering reveals the full sidebar so tooltips are redundant.
- const showTooltip = state === "collapsed" && !peekable;
- if (tooltip) {
+ // Label every childless item on the collapsed rail: explicit `tooltip`
+ // wins, else the button's own text. Wrapping is unconditional (when there's
+ // any label) so the DOM stays stable across expand/collapse — otherwise
+ // React remounts the button and kills its CSS transitions.
+ // Suppressed when peekable (hover reveals the full sidebar) or when a hover
+ // flyout owns the label (its popup carries the title and sits in front).
+ const showTooltip = state === "collapsed" && !peekable && !hasFlyout;
+ const tooltipText = tooltip ?? getElementText(children);
+ if (tooltipText) {
button = (
void;
completeOpenChange: () => void;
+ closeFlyout: () => void;
}
const isCollapsibleContentShown = (
@@ -2214,12 +2316,107 @@ const isCollapsibleContentShown = (
const SidebarCollapseContext = createContext({
contentId: "",
isOpen: true,
+ isFlyoutOpen: false,
isCollapsible: false,
autoScrollOnOpen: false,
+ hasFlyout: false,
toggle: () => {},
completeOpenChange: () => {},
+ closeFlyout: () => {},
});
+interface SidebarFlyoutContextValue {
+ /** True when descendants are rendered inside a hover-flyout popup. */
+ insideFlyout: boolean;
+ /** Close this popup and every ancestor popup in the chain. */
+ dismissAll: () => void;
+}
+
+const SidebarFlyoutContext = createContext({
+ insideFlyout: false,
+ dismissAll: () => {},
+});
+
+/** Gap between the trigger and the flyout popup in px. */
+const FLYOUT_GAP = 7;
+/** Minimum viewport inset for the flyout popup in px. */
+const FLYOUT_VIEWPORT_INSET = 8;
+const FLYOUT_TRIGGER_SELECTOR =
+ "[data-sidebar='menu-button'], [data-sidebar='menu-sub-button']";
+/**
+ * Flyout popup inner padding in px (must match the `p-1.5` class below).
+ * When there's no title, offsetting the popup top by this amount aligns its
+ * first item with the row that opened it, so nested popups don't drift.
+ */
+const FLYOUT_PADDING = 6;
+/** Flyout title row height in px (must match its `min-h-8.5` class). */
+const FLYOUT_TITLE_HEIGHT = 34;
+/**
+ * How far to pull a titled popup up so its first item, not the title, aligns
+ * with the trigger: the pointer moves straight across onto it. The title's
+ * `-mt-1.5` cancels the top padding, so the first item sits below the title
+ * plus its `mb-1.5` (= FLYOUT_PADDING).
+ */
+const FLYOUT_TITLE_OFFSET = FLYOUT_TITLE_HEIGHT + FLYOUT_PADDING;
+
+function getCollapsibleFlyoutContent(children: ReactNode) {
+ let flyoutContent: ReactNode = null;
+
+ React.Children.forEach(children, (child) => {
+ if (
+ React.isValidElement(child) &&
+ (child.type as { displayName?: string })?.displayName ===
+ "Sidebar.CollapsibleContent"
+ ) {
+ flyoutContent = (child.props as { children?: ReactNode }).children;
+ }
+ });
+
+ return flyoutContent;
+}
+
+/**
+ * The button's own text label: only direct string/number children. Skips the
+ * icon element and nested elements (e.g. a MenuBadge) so a popup title reads
+ * "Workers", not "Workers3".
+ */
+function getElementText(children: ReactNode): string {
+ let text = "";
+ React.Children.forEach(children, (child) => {
+ if (typeof child === "string" || typeof child === "number") {
+ text += child;
+ }
+ });
+ return text.trim();
+}
+
+/**
+ * Label for a collapsible's flyout popup: the trigger's `tooltip` when set,
+ * otherwise its plain-text children. Returns null when neither is available.
+ */
+function getCollapsibleTriggerLabel(children: ReactNode): string | null {
+ let label: string | null = null;
+
+ React.Children.forEach(children, (child) => {
+ if (
+ React.isValidElement(child) &&
+ (child.type as { displayName?: string })?.displayName ===
+ "Sidebar.CollapsibleTrigger"
+ ) {
+ const render = (child.props as { render?: React.ReactElement }).render;
+ if (React.isValidElement(render)) {
+ const props = render.props as {
+ tooltip?: string;
+ children?: ReactNode;
+ };
+ label = props.tooltip?.trim() || getElementText(props.children) || null;
+ }
+ }
+ });
+
+ return label;
+}
+
export interface SidebarCollapsibleProps extends ComponentPropsWithoutRef<"div"> {
/** Initial open state (uncontrolled). @default false */
defaultOpen?: boolean;
@@ -2268,6 +2465,8 @@ const SidebarCollapsible = forwardRef(
autoScrollOnOpen = false,
className,
children,
+ onMouseEnter,
+ onMouseLeave,
...props
},
ref,
@@ -2275,10 +2474,69 @@ const SidebarCollapsible = forwardRef(
const sidebar = useSidebar();
const { animationDuration } = sidebar;
const [internalOpen, setInternalOpen] = useState(defaultOpen);
+ const [flyoutOpen, setFlyoutOpen] = useState(false);
+ const [flyoutNode, setFlyoutNode] = useState(null);
const isOpen = openProp ?? internalOpen;
const isContentShown = isCollapsibleContentShown(isOpen, sidebar);
const contentId = useId();
const keyboardExpandedRef = useRef(false);
+ const portalContainer = usePortalContainer();
+ const containerRef = useRef(null);
+ const closeFlyoutTimeoutRef = useRef | null>(
+ null,
+ );
+ const flyoutContent = getCollapsibleFlyoutContent(children);
+ const hasFlyoutContent = flyoutContent !== null;
+ const flyoutLabel = getCollapsibleTriggerLabel(children);
+ const {
+ isMobile,
+ side,
+ state,
+ open: sidebarOpen,
+ peekable,
+ isPeeking,
+ isPeekSettled,
+ flyout: flyoutEnabled,
+ } = useSidebar();
+ // With `peekable`, hovering the collapsed rail peeks instead: flyouts wait
+ // until the peek has fully expanded, then behave as in an expanded sidebar.
+ const isBlockedByPeek =
+ peekable && !sidebarOpen && !(isPeeking && isPeekSettled);
+ const isHoveredRef = useRef(false);
+
+ const clearFlyoutCloseTimeout = useCallback(() => {
+ if (closeFlyoutTimeoutRef.current) {
+ clearTimeout(closeFlyoutTimeoutRef.current);
+ closeFlyoutTimeoutRef.current = null;
+ }
+ }, []);
+
+ const closeFlyout = useCallback(() => {
+ clearFlyoutCloseTimeout();
+ closeFlyoutTimeoutRef.current = setTimeout(() => {
+ setFlyoutOpen(false);
+ }, 120);
+ }, [clearFlyoutCloseTimeout]);
+
+ const closeFlyoutImmediately = useCallback(() => {
+ clearFlyoutCloseTimeout();
+ setFlyoutOpen(false);
+ }, [clearFlyoutCloseTimeout]);
+
+ // Chain up ancestor popups so a click anywhere in the stack dismisses all.
+ const parentFlyout = useContext(SidebarFlyoutContext);
+ // The title only renders for a collapsed, top-level popup with a label.
+ const showFlyoutTitle =
+ state === "collapsed" && !parentFlyout.insideFlyout && !!flyoutLabel;
+ const dismissAll = useCallback(() => {
+ closeFlyoutImmediately();
+ parentFlyout.dismissAll();
+ }, [closeFlyoutImmediately, parentFlyout]);
+
+ const flyoutProviderValue = useMemo(
+ () => ({ insideFlyout: true, dismissAll }),
+ [dismissAll],
+ );
const toggle = useCallback(() => {
const next = !isOpen;
@@ -2294,18 +2552,126 @@ const SidebarCollapsible = forwardRef(
onOpenChangeComplete,
);
+ const openFlyout = useCallback(() => {
+ if (
+ !flyoutEnabled ||
+ isMobile ||
+ isBlockedByPeek ||
+ !hasFlyoutContent ||
+ !containerRef.current
+ ) {
+ return;
+ }
+ // A collapsed rail cannot render inline content (see
+ // SidebarCollapsibleContent), so show the flyout regardless of open state.
+ // Otherwise only closed sections get one — open ones show inline.
+ if (state !== "collapsed" && isOpen) {
+ return;
+ }
+ clearFlyoutCloseTimeout();
+ setFlyoutOpen(true);
+ }, [
+ clearFlyoutCloseTimeout,
+ hasFlyoutContent,
+ isBlockedByPeek,
+ isMobile,
+ isOpen,
+ flyoutEnabled,
+ state,
+ ]);
+
+ const getFlyoutAnchor = useCallback(
+ () =>
+ containerRef.current?.querySelector(FLYOUT_TRIGGER_SELECTOR) ??
+ containerRef.current,
+ [],
+ );
+
+ // Opening stays ours (hover, peek, keyboard rules); Base UI only reports
+ // dismissals (Escape, outside press).
+ const handleFlyoutOpenChange = useCallback(
+ (open: boolean) => {
+ if (!open) closeFlyoutImmediately();
+ },
+ [closeFlyoutImmediately],
+ );
+
+ const handleMouseEnter = useCallback(
+ (e: React.MouseEvent) => {
+ onMouseEnter?.(e);
+ isHoveredRef.current = true;
+ openFlyout();
+ },
+ [onMouseEnter, openFlyout],
+ );
+
+ const handleMouseLeave = useCallback(
+ (e: React.MouseEvent) => {
+ onMouseLeave?.(e);
+ isHoveredRef.current = false;
+ closeFlyout();
+ },
+ [closeFlyout, onMouseLeave],
+ );
+
+ // Close when a peek ends; open for the resting pointer once it settles
+ // (no fresh mouseenter fires when the sidebar expands under it).
+ const wasBlockedByPeekRef = useRef(isBlockedByPeek);
+ useEffect(() => {
+ const wasBlocked = wasBlockedByPeekRef.current;
+ wasBlockedByPeekRef.current = isBlockedByPeek;
+ if (isBlockedByPeek) {
+ closeFlyoutImmediately();
+ } else if (wasBlocked && isHoveredRef.current) {
+ openFlyout();
+ }
+ }, [isBlockedByPeek, closeFlyoutImmediately, openFlyout]);
+
const contextValue = useMemo(
() => ({
contentId,
isOpen,
+ isFlyoutOpen: flyoutOpen,
isCollapsible: true,
autoScrollOnOpen,
+ hasFlyout: flyoutEnabled && hasFlyoutContent,
toggle,
completeOpenChange,
+ closeFlyout: closeFlyoutImmediately,
}),
- [contentId, isOpen, autoScrollOnOpen, toggle, completeOpenChange],
+ [
+ contentId,
+ isOpen,
+ flyoutOpen,
+ autoScrollOnOpen,
+ flyoutEnabled,
+ hasFlyoutContent,
+ toggle,
+ completeOpenChange,
+ closeFlyoutImmediately,
+ ],
+ );
+
+ useEffect(
+ () => () => {
+ clearFlyoutCloseTimeout();
+ },
+ [clearFlyoutCloseTimeout],
);
+ // The flyout is a mouse-only affordance: it stays clickable, but its
+ // contents are removed from the tab order so keyboard users transparently
+ // get the inline expansion instead (handleFocusIn). Paired with
+ // aria-hidden on the popup, this avoids focusable content in a hidden
+ // subtree.
+ useEffect(() => {
+ if (!flyoutNode) return;
+ const focusables = flyoutNode.querySelectorAll(
+ "a[href], button, input, select, textarea, [tabindex]",
+ );
+ for (const el of focusables) el.setAttribute("tabindex", "-1");
+ }, [flyoutNode, flyoutContent]);
+
const handleFocusIn = useCallback(
(e: React.FocusEvent) => {
// Auto-expand on keyboard focus (focus-visible) when collapsed
@@ -2338,14 +2704,103 @@ const SidebarCollapsible = forwardRef(
return (
{
+ containerRef.current = node;
+ if (typeof ref === "function") ref(node);
+ else if (ref) ref.current = node;
+ }}
data-open={isOpen || undefined}
className={cn("min-w-0", className)}
+ onMouseEnter={handleMouseEnter}
+ onMouseLeave={handleMouseLeave}
onFocus={handleFocusIn}
onBlur={handleFocusOut}
{...props}
>
{children}
+ {hasFlyoutContent && (
+
+
+
+
+
+ {/* Names the parent, hidden by the collapsed rail, styled to
+ match Sidebar.GroupLabel. Negative margins cancel `p-1.5`
+ for a wall-to-wall separator; pl-[18px] = popup p-1.5 (6) +
+ item px-3 (12) aligns the title with the items. Nested
+ popups omit it. */}
+ {showFlyoutTitle && (
+
+ {flyoutLabel}
+
+ )}
+ {/* 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. */}
+