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-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

diff --git a/packages/kumo/src/components/sidebar/sidebar.test.tsx b/packages/kumo/src/components/sidebar/sidebar.test.tsx index b0fa66385..5085ad55e 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, @@ -75,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(), @@ -257,6 +265,255 @@ 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("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(); + + try { + render( + + + + + , + ); + + const trigger = screen.getByRole("button", { + name: "Collapse sidebar", + }); + fireEvent.click(trigger); + fireEvent.click(trigger); + expect(onOpenChangeComplete).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(100); + }); + expect(onOpenChangeComplete).not.toHaveBeenCalled(); + + act(() => { + vi.runAllTimers(); + }); + + expect(onOpenChangeComplete).toHaveBeenCalledOnce(); + expect(onOpenChangeComplete).toHaveBeenCalledWith(true); + } finally { + vi.useRealTimers(); + } + }); + + 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(); + 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(); + } + }); }); // ============================================================================ @@ -266,10 +523,14 @@ describe("Sidebar toggle", () => { describe("Sidebar.Collapsible", () => { function CollapsibleTest({ defaultOpen = false, + open, autoScrollOnOpen = false, + onOpenChangeComplete, }: { defaultOpen?: boolean; + open?: boolean; autoScrollOnOpen?: boolean; + onOpenChangeComplete?: (open: boolean) => void; }) { return ( @@ -278,7 +539,9 @@ 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("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 241f95bb5..f6b9ecddf 100644 --- a/packages/kumo/src/components/sidebar/sidebar.tsx +++ b/packages/kumo/src/components/sidebar/sidebar.tsx @@ -96,10 +96,68 @@ 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"])'; +function useOpenChangeComplete( + open: boolean, + duration: number, + onComplete?: (open: boolean) => void, + active = true, +) { + const enabled = active && onComplete !== undefined; + const callbackRef = useRef(onComplete); + const durationRef = useRef(duration); + const previousOpenRef = useRef(open); + const previousActiveRef = useRef(active); + 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(() => { + const openChanged = previousOpenRef.current !== open; + const wasActive = previousActiveRef.current; + previousOpenRef.current = open; + previousActiveRef.current = active; + if (!enabled) { + pendingOpenRef.current = undefined; + clearTimeout(timeoutRef.current); + return; + } + if (!wasActive || !openChanged) return; + + pendingOpenRef.current = open; + 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); + }, [active, complete, enabled, open]); + + return complete; +} + // ============================================================================ // Mobile detection hook // ============================================================================ @@ -235,6 +293,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 +361,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 +572,43 @@ function SidebarProvider({ [state, open, openMobile, isMobile, width, isResizing, isPeeking], ); + const completeDesktopOpenChange = useOpenChangeComplete( + open, + animationDuration, + onOpenChangeComplete, + !isMobile, + ); + const completeMobileOpenChange = useOpenChangeComplete( + openMobile, + animationDuration, + onOpenChangeComplete, + isMobile, + ); + const completeOpenChange = isMobile + ? completeMobileOpenChange + : completeDesktopOpenChange; + const transitionProperty = isMobile ? "transform" : "width"; + const handleOpenTransitionEnd = useCallback( + (event: React.TransitionEvent) => { + const target = event.target as HTMLElement; + if ( + target.closest("[data-sidebar-wrapper]") === event.currentTarget && + target.dataset.sidebar === "sidebar" && + event.propertyName === transitionProperty + ) { + completeOpenChange(); + } + }, + [completeOpenChange, transitionProperty], + ); + return (

void; + completeOpenChange: () => void; } const SidebarCollapseContext = createContext({ @@ -2112,6 +2205,7 @@ const SidebarCollapseContext = createContext({ isCollapsible: false, autoScrollOnOpen: false, toggle: () => {}, + completeOpenChange: () => {}, }); export interface SidebarCollapsibleProps extends ComponentPropsWithoutRef<"div"> { @@ -2121,6 +2215,8 @@ export interface SidebarCollapsibleProps extends ComponentPropsWithoutRef<"div"> 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,6 +2249,7 @@ const SidebarCollapsible = forwardRef( defaultOpen = false, open: openProp, onOpenChange, + onOpenChangeComplete, autoScrollOnOpen = false, className, children, @@ -2160,6 +2257,7 @@ const SidebarCollapsible = forwardRef( }, ref, ) => { + const { animationDuration } = useSidebar(); const [internalOpen, setInternalOpen] = useState(defaultOpen); const isOpen = openProp ?? internalOpen; const contentId = useId(); @@ -2173,6 +2271,12 @@ const SidebarCollapsible = forwardRef( keyboardExpandedRef.current = false; }, [isOpen, onOpenChange]); + const completeOpenChange = useOpenChangeComplete( + isOpen, + animationDuration, + onOpenChangeComplete, + ); + const contextValue = useMemo( () => ({ contentId, @@ -2180,8 +2284,9 @@ const SidebarCollapsible = forwardRef( isCollapsible: true, autoScrollOnOpen, toggle, + completeOpenChange, }), - [contentId, isOpen, autoScrollOnOpen, toggle], + [contentId, isOpen, autoScrollOnOpen, toggle, completeOpenChange], ); const handleFocusIn = useCallback( @@ -2282,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"; @@ -2336,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 (