From 07ad0e824ac2741523374a186914be02fc2a399b Mon Sep 17 00:00:00 2001 From: michi Date: Fri, 11 Sep 2026 16:40:28 +0100 Subject: [PATCH 1/5] feat: add sidebar transition completion callbacks --- .changeset/calm-sidebars-complete.md | 5 + .../src/components/sidebar/sidebar.test.tsx | 99 +++++++++++++++++++ .../kumo/src/components/sidebar/sidebar.tsx | 86 ++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 .changeset/calm-sidebars-complete.md diff --git a/.changeset/calm-sidebars-complete.md b/.changeset/calm-sidebars-complete.md new file mode 100644 index 000000000..084101662 --- /dev/null +++ b/.changeset/calm-sidebars-complete.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": minor +--- + +Add sidebar open-change completion callbacks. diff --git a/packages/kumo/src/components/sidebar/sidebar.test.tsx b/packages/kumo/src/components/sidebar/sidebar.test.tsx index b0fa66385..ca0a7185e 100644 --- a/packages/kumo/src/components/sidebar/sidebar.test.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.test.tsx @@ -49,6 +49,12 @@ import { // Helpers // --------------------------------------------------------------------------- +function fireTransitionEnd(element: HTMLElement, propertyName: string) { + const event = new Event("transitionend", { bubbles: true }); + Object.defineProperty(event, "propertyName", { value: propertyName }); + fireEvent(element, event); +} + /** Minimal sidebar wrapper for tests that need Provider context. */ function TestSidebar({ children, @@ -257,6 +263,73 @@ describe("Sidebar toggle", () => { await user.click(screen.getByRole("button", { name: "Collapse sidebar" })); expect(onOpenChange).toHaveBeenCalledWith(false); }); + + it("calls onOpenChangeComplete after the sidebar transition finishes", async () => { + const onOpenChangeComplete = vi.fn(); + const user = userEvent.setup(); + render( + + + + + , + ); + + await user.click(screen.getByRole("button", { name: "Collapse sidebar" })); + expect(onOpenChangeComplete).not.toHaveBeenCalled(); + + fireTransitionEnd( + document.querySelector('[data-sidebar="sidebar"]')!, + "opacity", + ); + expect(onOpenChangeComplete).not.toHaveBeenCalled(); + + fireTransitionEnd( + document.querySelector('[data-sidebar="sidebar"]')!, + "width", + ); + fireTransitionEnd( + document.querySelector('[data-sidebar="sidebar"]')!, + "width", + ); + + expect(onOpenChangeComplete).toHaveBeenCalledOnce(); + expect(onOpenChangeComplete).toHaveBeenCalledWith(false); + }); + + it("completes the latest state when no transition event fires", () => { + vi.useFakeTimers(); + const onOpenChangeComplete = vi.fn(); + + try { + render( + + + + + , + ); + + const trigger = screen.getByRole("button", { + name: "Collapse sidebar", + }); + fireEvent.click(trigger); + fireEvent.click(trigger); + expect(onOpenChangeComplete).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(250); + }); + + expect(onOpenChangeComplete).toHaveBeenCalledOnce(); + expect(onOpenChangeComplete).toHaveBeenCalledWith(true); + } finally { + vi.useRealTimers(); + } + }); }); // ============================================================================ @@ -267,9 +340,11 @@ describe("Sidebar.Collapsible", () => { function CollapsibleTest({ defaultOpen = false, autoScrollOnOpen = false, + onOpenChangeComplete, }: { defaultOpen?: boolean; autoScrollOnOpen?: boolean; + onOpenChangeComplete?: (open: boolean) => void; }) { return ( @@ -279,6 +354,7 @@ describe("Sidebar.Collapsible", () => { { expect(content.getAttribute("aria-hidden")).toBe("true"); }); + it("calls onOpenChangeComplete after the content transition finishes", () => { + const onOpenChangeComplete = vi.fn(); + render(); + + fireEvent.click(screen.getByText("Compute").closest("button")!); + expect(onOpenChangeComplete).not.toHaveBeenCalled(); + + fireTransitionEnd(screen.getByTestId("collapsible-content"), "opacity"); + expect(onOpenChangeComplete).not.toHaveBeenCalled(); + + fireTransitionEnd( + screen.getByTestId("collapsible-content"), + "grid-template-rows", + ); + fireTransitionEnd( + screen.getByTestId("collapsible-content"), + "grid-template-rows", + ); + + expect(onOpenChangeComplete).toHaveBeenCalledOnce(); + expect(onOpenChangeComplete).toHaveBeenCalledWith(true); + }); + it("should scroll opened content into view when enabled", () => { vi.useFakeTimers(); const scrollIntoView = vi.fn(); diff --git a/packages/kumo/src/components/sidebar/sidebar.tsx b/packages/kumo/src/components/sidebar/sidebar.tsx index 241f95bb5..07acac1ff 100644 --- a/packages/kumo/src/components/sidebar/sidebar.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.tsx @@ -100,6 +100,44 @@ const MOBILE_BREAKPOINT = 768; const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; +function useOpenChangeComplete( + open: boolean, + duration: number, + onComplete?: (open: boolean) => void, +) { + const callbackRef = useRef(onComplete); + const durationRef = useRef(duration); + const previousOpenRef = useRef(open); + const pendingOpenRef = useRef(undefined); + const timeoutRef = useRef | undefined>( + undefined, + ); + callbackRef.current = onComplete; + durationRef.current = duration; + + const complete = useCallback(() => { + const pendingOpen = pendingOpenRef.current; + if (pendingOpen === undefined) return; + + pendingOpenRef.current = undefined; + clearTimeout(timeoutRef.current); + callbackRef.current?.(pendingOpen); + }, []); + + useEffect(() => { + if (previousOpenRef.current === open) return; + + previousOpenRef.current = open; + if (!callbackRef.current) return; + + pendingOpenRef.current = open; + timeoutRef.current = setTimeout(complete, durationRef.current); + return () => clearTimeout(timeoutRef.current); + }, [complete, open]); + + return complete; +} + // ============================================================================ // Mobile detection hook // ============================================================================ @@ -235,6 +273,8 @@ export interface SidebarProviderProps { open?: boolean; /** Callback when open state changes (controlled mode). */ onOpenChange?: (open: boolean) => void; + /** Callback after the sidebar finishes its open or close transition. */ + onOpenChangeComplete?: (open: boolean) => void; /** Sidebar layout variant. @default "sidebar" */ variant?: SidebarVariant; /** Which side the sidebar is on. @default "left" */ @@ -301,6 +341,7 @@ function SidebarProvider({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, + onOpenChangeComplete, variant = KUMO_SIDEBAR_DEFAULT_VARIANTS.variant, side = KUMO_SIDEBAR_DEFAULT_VARIANTS.side, collapsible = KUMO_SIDEBAR_DEFAULT_VARIANTS.collapsible, @@ -511,12 +552,33 @@ function SidebarProvider({ [state, open, openMobile, isMobile, width, isResizing, isPeeking], ); + const currentOpen = isMobile ? openMobile : open; + const completeOpenChange = useOpenChangeComplete( + currentOpen, + animationDuration, + onOpenChangeComplete, + ); + const transitionProperty = isMobile ? "transform" : "width"; + const handleOpenTransitionEnd = useCallback( + (event: React.TransitionEvent) => { + const target = event.target as HTMLElement; + if ( + target.dataset.sidebar === "sidebar" && + event.propertyName === transitionProperty + ) { + completeOpenChange(); + } + }, + [completeOpenChange, transitionProperty], + ); + return (
open?: boolean; /** Callback when open state changes. */ onOpenChange?: (open: boolean) => void; + /** Callback after the content finishes its open or close transition. */ + onOpenChangeComplete?: (open: boolean) => void; /** Scroll the expanded content into view after opening. @default false */ autoScrollOnOpen?: boolean; } @@ -2153,13 +2217,16 @@ const SidebarCollapsible = forwardRef( defaultOpen = false, open: openProp, onOpenChange, + onOpenChangeComplete, autoScrollOnOpen = false, className, children, + onTransitionEnd, ...props }, ref, ) => { + const { animationDuration } = useSidebar(); const [internalOpen, setInternalOpen] = useState(defaultOpen); const isOpen = openProp ?? internalOpen; const contentId = useId(); @@ -2213,6 +2280,24 @@ const SidebarCollapsible = forwardRef( [onOpenChange], ); + const completeOpenChange = useOpenChangeComplete( + isOpen, + animationDuration, + onOpenChangeComplete, + ); + const handleOpenTransitionEnd = useCallback( + (event: React.TransitionEvent) => { + onTransitionEnd?.(event); + if ( + (event.target as HTMLElement).id === contentId && + event.propertyName === "grid-template-rows" + ) { + completeOpenChange(); + } + }, + [completeOpenChange, contentId, onTransitionEnd], + ); + return (
( className={cn("min-w-0", className)} onFocus={handleFocusIn} onBlur={handleFocusOut} + onTransitionEnd={handleOpenTransitionEnd} {...props} > {children} From aaa7459976c08832f3a441e934a9735af2018da8 Mon Sep 17 00:00:00 2001 From: michi Date: Fri, 11 Sep 2026 17:16:55 +0100 Subject: [PATCH 2/5] fix: harden sidebar transition completion callbacks --- .../src/components/sidebar/sidebar.test.tsx | 140 +++++++++++++++++- .../kumo/src/components/sidebar/sidebar.tsx | 44 ++++-- 2 files changed, 171 insertions(+), 13 deletions(-) diff --git a/packages/kumo/src/components/sidebar/sidebar.test.tsx b/packages/kumo/src/components/sidebar/sidebar.test.tsx index ca0a7185e..e855980bf 100644 --- a/packages/kumo/src/components/sidebar/sidebar.test.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.test.tsx @@ -81,11 +81,13 @@ function StateReader() { ); } -function setMobileMatchMedia(matches: boolean) { +function setMobileMatchMedia(matches: boolean, prefersReducedMotion = false) { Object.defineProperty(window, "matchMedia", { writable: true, value: vi.fn().mockImplementation((query: string) => ({ - matches, + matches: query.includes("prefers-reduced-motion") + ? prefersReducedMotion + : matches, media: query, onchange: null, addListener: vi.fn(), @@ -306,7 +308,11 @@ describe("Sidebar toggle", () => { try { render( - + @@ -321,7 +327,12 @@ describe("Sidebar toggle", () => { expect(onOpenChangeComplete).not.toHaveBeenCalled(); act(() => { - vi.advanceTimersByTime(250); + vi.advanceTimersByTime(100); + }); + expect(onOpenChangeComplete).not.toHaveBeenCalled(); + + act(() => { + vi.runAllTimers(); }); expect(onOpenChangeComplete).toHaveBeenCalledOnce(); @@ -330,6 +341,127 @@ describe("Sidebar toggle", () => { vi.useRealTimers(); } }); + + it("completes immediately when reduced motion disables transitions", () => { + setMobileMatchMedia(false, true); + const onOpenChangeComplete = vi.fn(); + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" })); + + expect(onOpenChangeComplete).toHaveBeenCalledOnce(); + expect(onOpenChangeComplete).toHaveBeenCalledWith(false); + }); + + it("clears pending state when the callback is removed", () => { + vi.useFakeTimers(); + const firstCallback = vi.fn(); + const nextCallback = vi.fn(); + const sidebar = ( + open: boolean, + onOpenChangeComplete?: (open: boolean) => void, + ) => ( + + + + + + ); + + try { + const { rerender } = render(sidebar(true, firstCallback)); + rerender(sidebar(false, firstCallback)); + rerender(sidebar(true)); + rerender(sidebar(true, nextCallback)); + + fireTransitionEnd( + document.querySelector('[data-sidebar="sidebar"]')!, + "width", + ); + act(() => { + vi.runAllTimers(); + }); + + expect(firstCallback).not.toHaveBeenCalled(); + expect(nextCallback).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not complete when switching between desktop and mobile", () => { + vi.useFakeTimers(); + const onOpenChangeComplete = vi.fn(); + const sidebar = (mobileBreakpoint: number) => ( + + + + + + ); + + try { + const { rerender } = render(sidebar(768)); + setMobileMatchMedia(true); + rerender(sidebar(769)); + + fireTransitionEnd( + document.querySelector('[data-sidebar="sidebar"]')!, + "transform", + ); + act(() => { + vi.runAllTimers(); + }); + + expect(onOpenChangeComplete).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not complete a controlled open state when switching to mobile", () => { + vi.useFakeTimers(); + const onOpenChangeComplete = vi.fn(); + const sidebar = (mobileBreakpoint: number) => ( + + + + + + ); + + try { + const { rerender } = render(sidebar(768)); + setMobileMatchMedia(true); + rerender(sidebar(769)); + + fireTransitionEnd( + document.querySelector('[data-sidebar="sidebar"]')!, + "transform", + ); + act(() => { + vi.runAllTimers(); + }); + + expect(onOpenChangeComplete).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); }); // ============================================================================ diff --git a/packages/kumo/src/components/sidebar/sidebar.tsx b/packages/kumo/src/components/sidebar/sidebar.tsx index 07acac1ff..cf54cd5af 100644 --- a/packages/kumo/src/components/sidebar/sidebar.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.tsx @@ -96,6 +96,7 @@ const SIDEBAR_WIDTH = "16.25rem"; 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; const MOBILE_BREAKPOINT = 768; const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; @@ -105,9 +106,11 @@ function useOpenChangeComplete( duration: number, onComplete?: (open: boolean) => void, ) { + const enabled = onComplete !== undefined; const callbackRef = useRef(onComplete); const durationRef = useRef(duration); const previousOpenRef = useRef(open); + const previousEnabledRef = useRef(enabled); const pendingOpenRef = useRef(undefined); const timeoutRef = useRef | undefined>( undefined, @@ -125,15 +128,31 @@ function useOpenChangeComplete( }, []); useEffect(() => { - if (previousOpenRef.current === open) return; - + const openChanged = previousOpenRef.current !== open; + const wasEnabled = previousEnabledRef.current; previousOpenRef.current = open; - if (!callbackRef.current) return; + previousEnabledRef.current = enabled; + if (!enabled) { + pendingOpenRef.current = undefined; + clearTimeout(timeoutRef.current); + return; + } + if (!wasEnabled || !openChanged) return; pendingOpenRef.current = open; - timeoutRef.current = setTimeout(complete, durationRef.current); + if ( + durationRef.current === 0 || + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + complete(); + return; + } + timeoutRef.current = setTimeout( + complete, + durationRef.current + TRANSITION_FALLBACK_GRACE_MS, + ); return () => clearTimeout(timeoutRef.current); - }, [complete, open]); + }, [complete, enabled, open]); return complete; } @@ -552,12 +571,19 @@ function SidebarProvider({ [state, open, openMobile, isMobile, width, isResizing, isPeeking], ); - const currentOpen = isMobile ? openMobile : open; - const completeOpenChange = useOpenChangeComplete( - currentOpen, + const completeDesktopOpenChange = useOpenChangeComplete( + open, + animationDuration, + isMobile ? undefined : onOpenChangeComplete, + ); + const completeMobileOpenChange = useOpenChangeComplete( + openMobile, animationDuration, - onOpenChangeComplete, + isMobile ? onOpenChangeComplete : undefined, ); + const completeOpenChange = isMobile + ? completeMobileOpenChange + : completeDesktopOpenChange; const transitionProperty = isMobile ? "transform" : "width"; const handleOpenTransitionEnd = useCallback( (event: React.TransitionEvent) => { From 478cfdb713ec77893fd857bddd94b0e4c6f97251 Mon Sep 17 00:00:00 2001 From: michi Date: Mon, 14 Sep 2026 13:08:45 +0100 Subject: [PATCH 3/5] fix: isolate sidebar transition completion events --- .../src/components/sidebar/sidebar.test.tsx | 108 +++++++++++++++++- .../kumo/src/components/sidebar/sidebar.tsx | 77 +++++++------ 2 files changed, 146 insertions(+), 39 deletions(-) diff --git a/packages/kumo/src/components/sidebar/sidebar.test.tsx b/packages/kumo/src/components/sidebar/sidebar.test.tsx index e855980bf..57b88ca3f 100644 --- a/packages/kumo/src/components/sidebar/sidebar.test.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.test.tsx @@ -270,10 +270,7 @@ describe("Sidebar toggle", () => { const onOpenChangeComplete = vi.fn(); const user = userEvent.setup(); render( - + @@ -302,6 +299,40 @@ describe("Sidebar toggle", () => { expect(onOpenChangeComplete).toHaveBeenCalledWith(false); }); + it("does not complete a parent sidebar from a nested sidebar transition", () => { + const outerOnOpenChangeComplete = vi.fn(); + const innerOnOpenChangeComplete = vi.fn(); + const nestedSidebars = (open: boolean) => ( + + + + + + + + + + + ); + const { rerender } = render(nestedSidebars(true)); + rerender(nestedSidebars(false)); + + const sidebars = document.querySelectorAll( + '[data-sidebar="sidebar"]', + ); + fireTransitionEnd(sidebars[1]!, "width"); + + expect(innerOnOpenChangeComplete).toHaveBeenCalledOnce(); + expect(innerOnOpenChangeComplete).toHaveBeenCalledWith(false); + expect(outerOnOpenChangeComplete).not.toHaveBeenCalled(); + }); + it("completes the latest state when no transition event fires", () => { vi.useFakeTimers(); const onOpenChangeComplete = vi.fn(); @@ -342,6 +373,27 @@ describe("Sidebar toggle", () => { } }); + it("completes an open change when the callback is added with it", () => { + const onOpenChangeComplete = vi.fn(); + const sidebar = (open: boolean, callback?: (nextOpen: boolean) => void) => ( + + + + + + ); + const { rerender } = render(sidebar(true)); + rerender(sidebar(false, onOpenChangeComplete)); + + fireTransitionEnd( + document.querySelector('[data-sidebar="sidebar"]')!, + "width", + ); + + expect(onOpenChangeComplete).toHaveBeenCalledOnce(); + expect(onOpenChangeComplete).toHaveBeenCalledWith(false); + }); + it("completes immediately when reduced motion disables transitions", () => { setMobileMatchMedia(false, true); const onOpenChangeComplete = vi.fn(); @@ -471,10 +523,14 @@ describe("Sidebar toggle", () => { describe("Sidebar.Collapsible", () => { function CollapsibleTest({ defaultOpen = false, + open, + contentId, autoScrollOnOpen = false, onOpenChangeComplete, }: { defaultOpen?: boolean; + open?: boolean; + contentId?: string; autoScrollOnOpen?: boolean; onOpenChangeComplete?: (open: boolean) => void; }) { @@ -485,6 +541,7 @@ describe("Sidebar.Collapsible", () => { @@ -496,7 +553,10 @@ describe("Sidebar.Collapsible", () => { } /> - + Workers @@ -577,6 +637,44 @@ describe("Sidebar.Collapsible", () => { expect(onOpenChangeComplete).toHaveBeenCalledWith(true); }); + it("completes from content with a custom id", () => { + const onOpenChangeComplete = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByText("Compute").closest("button")!); + fireTransitionEnd( + screen.getByTestId("collapsible-content"), + "grid-template-rows", + ); + + expect(onOpenChangeComplete).toHaveBeenCalledOnce(); + expect(onOpenChangeComplete).toHaveBeenCalledWith(true); + }); + + it("completes a controlled change when the callback is added with it", () => { + const onOpenChangeComplete = vi.fn(); + const { rerender } = render(); + rerender( + , + ); + + fireTransitionEnd( + screen.getByTestId("collapsible-content"), + "grid-template-rows", + ); + + expect(onOpenChangeComplete).toHaveBeenCalledOnce(); + expect(onOpenChangeComplete).toHaveBeenCalledWith(false); + }); + it("should scroll opened content into view when enabled", () => { vi.useFakeTimers(); const scrollIntoView = vi.fn(); diff --git a/packages/kumo/src/components/sidebar/sidebar.tsx b/packages/kumo/src/components/sidebar/sidebar.tsx index cf54cd5af..f6b9ecddf 100644 --- a/packages/kumo/src/components/sidebar/sidebar.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.tsx @@ -105,12 +105,13 @@ function useOpenChangeComplete( open: boolean, duration: number, onComplete?: (open: boolean) => void, + active = true, ) { - const enabled = onComplete !== undefined; + const enabled = active && onComplete !== undefined; const callbackRef = useRef(onComplete); const durationRef = useRef(duration); const previousOpenRef = useRef(open); - const previousEnabledRef = useRef(enabled); + const previousActiveRef = useRef(active); const pendingOpenRef = useRef(undefined); const timeoutRef = useRef | undefined>( undefined, @@ -129,15 +130,15 @@ function useOpenChangeComplete( useEffect(() => { const openChanged = previousOpenRef.current !== open; - const wasEnabled = previousEnabledRef.current; + const wasActive = previousActiveRef.current; previousOpenRef.current = open; - previousEnabledRef.current = enabled; + previousActiveRef.current = active; if (!enabled) { pendingOpenRef.current = undefined; clearTimeout(timeoutRef.current); return; } - if (!wasEnabled || !openChanged) return; + if (!wasActive || !openChanged) return; pendingOpenRef.current = open; if ( @@ -152,7 +153,7 @@ function useOpenChangeComplete( durationRef.current + TRANSITION_FALLBACK_GRACE_MS, ); return () => clearTimeout(timeoutRef.current); - }, [complete, enabled, open]); + }, [active, complete, enabled, open]); return complete; } @@ -574,12 +575,14 @@ function SidebarProvider({ const completeDesktopOpenChange = useOpenChangeComplete( open, animationDuration, - isMobile ? undefined : onOpenChangeComplete, + onOpenChangeComplete, + !isMobile, ); const completeMobileOpenChange = useOpenChangeComplete( openMobile, animationDuration, - isMobile ? onOpenChangeComplete : undefined, + onOpenChangeComplete, + isMobile, ); const completeOpenChange = isMobile ? completeMobileOpenChange @@ -589,6 +592,7 @@ function SidebarProvider({ (event: React.TransitionEvent) => { const target = event.target as HTMLElement; if ( + target.closest("[data-sidebar-wrapper]") === event.currentTarget && target.dataset.sidebar === "sidebar" && event.propertyName === transitionProperty ) { @@ -2192,6 +2196,7 @@ interface SidebarCollapseContextValue { isCollapsible: boolean; autoScrollOnOpen: boolean; toggle: () => void; + completeOpenChange: () => void; } const SidebarCollapseContext = createContext({ @@ -2200,6 +2205,7 @@ const SidebarCollapseContext = createContext({ isCollapsible: false, autoScrollOnOpen: false, toggle: () => {}, + completeOpenChange: () => {}, }); export interface SidebarCollapsibleProps extends ComponentPropsWithoutRef<"div"> { @@ -2247,7 +2253,6 @@ const SidebarCollapsible = forwardRef( autoScrollOnOpen = false, className, children, - onTransitionEnd, ...props }, ref, @@ -2266,6 +2271,12 @@ const SidebarCollapsible = forwardRef( keyboardExpandedRef.current = false; }, [isOpen, onOpenChange]); + const completeOpenChange = useOpenChangeComplete( + isOpen, + animationDuration, + onOpenChangeComplete, + ); + const contextValue = useMemo( () => ({ contentId, @@ -2273,8 +2284,9 @@ const SidebarCollapsible = forwardRef( isCollapsible: true, autoScrollOnOpen, toggle, + completeOpenChange, }), - [contentId, isOpen, autoScrollOnOpen, toggle], + [contentId, isOpen, autoScrollOnOpen, toggle, completeOpenChange], ); const handleFocusIn = useCallback( @@ -2306,24 +2318,6 @@ const SidebarCollapsible = forwardRef( [onOpenChange], ); - const completeOpenChange = useOpenChangeComplete( - isOpen, - animationDuration, - onOpenChangeComplete, - ); - const handleOpenTransitionEnd = useCallback( - (event: React.TransitionEvent) => { - onTransitionEnd?.(event); - if ( - (event.target as HTMLElement).id === contentId && - event.propertyName === "grid-template-rows" - ) { - completeOpenChange(); - } - }, - [completeOpenChange, contentId, onTransitionEnd], - ); - return (
( className={cn("min-w-0", className)} onFocus={handleFocusIn} onBlur={handleFocusOut} - onTransitionEnd={handleOpenTransitionEnd} {...props} > {children} @@ -2394,12 +2387,14 @@ SidebarCollapsibleTrigger.displayName = "Sidebar.CollapsibleTrigger"; const SidebarCollapsibleContent = forwardRef< HTMLDivElement, ComponentPropsWithoutRef<"div"> ->(({ className, children, ...props }, ref) => { - const { contentId, isOpen: isCollapsibleOpen } = useContext( - SidebarCollapseContext, - ); +>(({ className, children, onTransitionEnd, ...props }, ref) => { + const { + contentId, + isOpen: isCollapsibleOpen, + autoScrollOnOpen, + completeOpenChange, + } = useContext(SidebarCollapseContext); const { state, animationDuration } = useSidebar(); - const { autoScrollOnOpen } = useContext(SidebarCollapseContext); const contentRef = useRef(null); const isOpen = isCollapsibleOpen && state !== "collapsed"; @@ -2448,12 +2443,26 @@ const SidebarCollapsibleContent = forwardRef< [ref, inertRef], ); + const handleOpenTransitionEnd = useCallback( + (event: React.TransitionEvent) => { + onTransitionEnd?.(event); + if ( + event.target === event.currentTarget && + event.propertyName === "grid-template-rows" + ) { + completeOpenChange(); + } + }, + [completeOpenChange, onTransitionEnd], + ); + return (
Date: Mon, 14 Sep 2026 13:17:16 +0100 Subject: [PATCH 4/5] test: keep collapsible id behavior out of scope --- .../src/components/sidebar/sidebar.test.tsx | 26 +------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/packages/kumo/src/components/sidebar/sidebar.test.tsx b/packages/kumo/src/components/sidebar/sidebar.test.tsx index 57b88ca3f..5085ad55e 100644 --- a/packages/kumo/src/components/sidebar/sidebar.test.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.test.tsx @@ -524,13 +524,11 @@ describe("Sidebar.Collapsible", () => { function CollapsibleTest({ defaultOpen = false, open, - contentId, autoScrollOnOpen = false, onOpenChangeComplete, }: { defaultOpen?: boolean; open?: boolean; - contentId?: string; autoScrollOnOpen?: boolean; onOpenChangeComplete?: (open: boolean) => void; }) { @@ -553,10 +551,7 @@ describe("Sidebar.Collapsible", () => { } /> - + Workers @@ -637,25 +632,6 @@ describe("Sidebar.Collapsible", () => { expect(onOpenChangeComplete).toHaveBeenCalledWith(true); }); - it("completes from content with a custom id", () => { - const onOpenChangeComplete = vi.fn(); - render( - , - ); - - fireEvent.click(screen.getByText("Compute").closest("button")!); - fireTransitionEnd( - screen.getByTestId("collapsible-content"), - "grid-template-rows", - ); - - expect(onOpenChangeComplete).toHaveBeenCalledOnce(); - expect(onOpenChangeComplete).toHaveBeenCalledWith(true); - }); - it("completes a controlled change when the callback is added with it", () => { const onOpenChangeComplete = vi.fn(); const { rerender } = render(); From 311145b60234a8f2a9a208bd8c6f1deb9f2979df Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Mon, 14 Sep 2026 10:37:53 -0400 Subject: [PATCH 5/5] docs(sidebar): demonstrate transition completion callbacks --- .../src/components/demos/SidebarDemo.tsx | 74 ++++++++++++++++++- .../src/pages/components/sidebar.astro | 38 ++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx b/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx index 0a6fc9e55..2e8f4cdd6 100644 --- a/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/SidebarDemo.tsx @@ -623,7 +623,77 @@ export function SidebarAutoScrollDemo() { } // --------------------------------------------------------------------------- -// 7. Sliding Views — animated horizontal transitions between surfaces +// 7. Transition completion — run work after sidebar and collapsible animations +// --------------------------------------------------------------------------- + +/** Run layout-dependent work after a sidebar or collapsible finishes transitioning. */ +export function SidebarTransitionCompleteDemo() { + const [completion, setCompletion] = useState( + "No transition has completed yet.", + ); + + return ( + + { + setCompletion(`Sidebar ${open ? "opened" : "collapsed"}.`); + }} + > + + + + Overview + + + Home + + + { + setCompletion( + `Compute section ${open ? "opened" : "collapsed"}.`, + ); + }} + > + + Compute + + + } + /> + + + Workers + Pages + + + + + + + + + + + + +

{completion}

+

+ Toggle the sidebar or open Compute to see the completion callback. +

+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// 8. Sliding Views — animated horizontal transitions between surfaces // --------------------------------------------------------------------------- /** Sidebar with animated sliding views between Account and Zone navigation. */ @@ -712,7 +782,7 @@ export function SidebarSlidingViewsDemo() { } // --------------------------------------------------------------------------- -// 8. Full — kitchen sink showcasing every subcomponent +// 9. Full — kitchen sink showcasing every subcomponent // --------------------------------------------------------------------------- /** Kitchen sink sidebar showcasing every subcomponent: header with account switcher, groups with labels, collapsible sections with nested expandable, badges, sliding views via Domains, and a footer trigger. */ diff --git a/packages/kumo-docs-astro/src/pages/components/sidebar.astro b/packages/kumo-docs-astro/src/pages/components/sidebar.astro index d6f804230..26e244170 100644 --- a/packages/kumo-docs-astro/src/pages/components/sidebar.astro +++ b/packages/kumo-docs-astro/src/pages/components/sidebar.astro @@ -15,6 +15,7 @@ import { SidebarRightDemo, SidebarPeekingDemo, SidebarAutoScrollDemo, + SidebarTransitionCompleteDemo, SidebarSlidingViewsDemo, SidebarMobileDemo, SidebarFullScreenMobileDemo, @@ -255,6 +256,43 @@ const { state, isPeeking } = useSidebar();
+
+ Transition Completion +

+ Use onOpenChangeComplete when work must wait for a sidebar or collapsible transition to settle, such as measuring layout or scrolling newly revealed content. The callback receives the completed open state and also runs when reduced motion disables the transition. +

+ { + setCompletion("Sidebar " + (open ? "opened." : "collapsed.")); + }} +> + + + + + { + setCompletion( + "Compute section " + (open ? "opened." : "collapsed."), + ); + }} + > + Compute} /> + ... + + + + + + +`}> + + +
+
Scroll to Item