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();
+
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 (