From 43ff7fbcefd880f072ab3bbed78122cc423e14c6 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 26 Nov 2025 12:07:32 +0100 Subject: [PATCH 1/3] Update useChecklist to provide 'ready' state, disable ChecklistWidget animations until settled down and only delay transition for outgoing items --- .../components/sidebar/ChecklistWidget.tsx | 56 +++++++++++++------ .../components/sidebar/useChecklist.ts | 29 ++++++++-- 2 files changed, 63 insertions(+), 22 deletions(-) diff --git a/code/core/src/manager/components/sidebar/ChecklistWidget.tsx b/code/core/src/manager/components/sidebar/ChecklistWidget.tsx index 0e7be1740be7..140a3efe9845 100644 --- a/code/core/src/manager/components/sidebar/ChecklistWidget.tsx +++ b/code/core/src/manager/components/sidebar/ChecklistWidget.tsx @@ -173,28 +173,48 @@ const OpenGuideButton = ({ export const ChecklistWidget = () => { const api = useStorybookApi(); - const { loaded, allItems, nextItems, progress, accept, mute, items } = useChecklist(); - const [renderItems, setItems] = useState([]); + const { loaded, ready, allItems, nextItems, progress, accept, mute, items } = useChecklist(); + const [renderItems, setRenderItems] = useState(nextItems); + const [animated, setAnimated] = useState(false); - const hasItems = renderItems.length > 0; - const transitionItems = useTransitionArray(allItems, renderItems, { - keyFn: (item) => item.id, - timeout: 300, - }); + useEffect(() => { + if (ready) { + // Don't animate anything until the checklist items have settled down. + const timeout = setTimeout(setAnimated, 1000, true); + return () => clearTimeout(timeout); + } + }, [ready]); useEffect(() => { - // Render old items (with updated status) for 2 seconds before + if (!animated) { + setRenderItems(nextItems); + return; + } + + // Render outgoing items with updated state for 2 seconds before // rendering new items, in order to allow exit transition. - setItems((current) => - current.map((item) => ({ - ...item, - isCompleted: items[item.id].status === 'accepted' || items[item.id].status === 'done', - isSkipped: items[item.id].status === 'skipped', - })) - ); - const timeout = setTimeout(setItems, 2000, nextItems); + setRenderItems((current) => { + let animateOut = false; + const prevItems = current.map((item) => { + const { status } = items[item.id]; + const isAccepted = status === 'accepted'; + const isDone = status === 'done'; + const isSkipped = status === 'skipped'; + animateOut = animateOut || isAccepted || isDone || isSkipped; + return { ...item, isCompleted: isAccepted || isDone, isAccepted, isDone, isSkipped }; + }); + return animateOut ? prevItems : nextItems; + }); + + const timeout = setTimeout(setRenderItems, 2000, nextItems); return () => clearTimeout(timeout); - }, [nextItems, items]); + }, [animated, nextItems, items]); + + const hasItems = renderItems.length > 0; + const transitionItems = useTransitionArray(allItems, renderItems, { + keyFn: (item) => item.id, + timeout: animated ? 300 : 0, + }); return ( @@ -288,7 +308,7 @@ export const ChecklistWidget = () => { onClick={() => api.navigate(`/settings/guide#${item.id}`)} > - {item.isCompleted ? ( + {item.isCompleted && animated ? ( ) : ( diff --git a/code/core/src/manager/components/sidebar/useChecklist.ts b/code/core/src/manager/components/sidebar/useChecklist.ts index 5e03df924abf..30466d01be51 100644 --- a/code/core/src/manager/components/sidebar/useChecklist.ts +++ b/code/core/src/manager/components/sidebar/useChecklist.ts @@ -1,14 +1,16 @@ import { useEffect, useMemo, useState } from 'react'; -import type { API_IndexHash } from 'storybook/internal/types'; +import { PREVIEW_INITIALIZED } from 'storybook/internal/core-events'; +import { type API_IndexHash } from 'storybook/internal/types'; import { internal_checklistStore as checklistStore, internal_universalChecklistStore as universalChecklistStore, } from '#manager-stores'; -import { throttle } from 'es-toolkit/function'; +import { debounce, throttle } from 'es-toolkit/function'; import { type API, + experimental_UniversalStore, experimental_useUniversalStore, useStorybookApi, useStorybookState, @@ -111,6 +113,12 @@ export const useChecklist = () => { const index = useStoryIndex(); const [checklistState] = experimental_useUniversalStore(universalChecklistStore); const { loaded, items, widget } = checklistState; + const { status } = universalChecklistStore; + + const [initialized, setInitialized] = useState(false); + const [ready, setReady] = useState(false); + + const debounceReady = useMemo(() => debounce(() => setReady(true), 500), []); const itemsById = useMemo>(() => { return Object.fromEntries( @@ -185,7 +193,7 @@ export const useChecklist = () => { }, [allItems]); useEffect(() => { - if (!loaded) { + if (!loaded || status !== experimental_UniversalStore.Status.READY) { return; } @@ -214,9 +222,22 @@ export const useChecklist = () => { } } } - }, [api, loaded, allItems]); + }, [api, loaded, status, allItems]); + + useEffect(() => { + const initialize = () => setInitialized(true); + api.once(PREVIEW_INITIALIZED, initialize); + return () => api.off(PREVIEW_INITIALIZED, initialize); + }, [api]); + + useEffect(() => { + if (initialized && items && status === experimental_UniversalStore.Status.READY) { + debounceReady(); + } + }, [initialized, items, status, debounceReady]); return { + ready, allItems, ...itemCollections, ...checklistStore, From 6b777f415a9217d94e4acb0737a56fc6dae73df9 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 26 Nov 2025 13:19:26 +0100 Subject: [PATCH 2/3] Initialize after 1s in case PREVIEW_INITIALIZED never fires --- code/core/src/manager/components/sidebar/useChecklist.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/code/core/src/manager/components/sidebar/useChecklist.ts b/code/core/src/manager/components/sidebar/useChecklist.ts index 30466d01be51..7c8b39b3aee6 100644 --- a/code/core/src/manager/components/sidebar/useChecklist.ts +++ b/code/core/src/manager/components/sidebar/useChecklist.ts @@ -226,8 +226,12 @@ export const useChecklist = () => { useEffect(() => { const initialize = () => setInitialized(true); + const timeout = setTimeout(initialize, 1000); api.once(PREVIEW_INITIALIZED, initialize); - return () => api.off(PREVIEW_INITIALIZED, initialize); + return () => { + clearTimeout(timeout); + api.off(PREVIEW_INITIALIZED, initialize); + }; }, [api]); useEffect(() => { From 8ee2b2be86c5bf570b2e4cc7eeb6725cd583e39e Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 26 Nov 2025 16:26:17 +0100 Subject: [PATCH 3/3] Track collapsible state in sessionStorage --- .../components/Collapsible/Collapsible.tsx | 47 +++++++++++++++++-- .../components/sidebar/ChecklistWidget.tsx | 3 +- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/code/core/src/components/components/Collapsible/Collapsible.tsx b/code/core/src/components/components/Collapsible/Collapsible.tsx index fd542a1027c2..6317efe972e6 100644 --- a/code/core/src/components/components/Collapsible/Collapsible.tsx +++ b/code/core/src/components/components/Collapsible/Collapsible.tsx @@ -34,6 +34,8 @@ export const Collapsible = Object.assign( summary, collapsed, disabled, + initialCollapsed, + storageKey, state: providedState, ...props }: { @@ -41,9 +43,11 @@ export const Collapsible = Object.assign( summary?: ReactNode | ((state: ReturnType) => ReactNode); collapsed?: boolean; disabled?: boolean; + initialCollapsed?: boolean; + storageKey?: string; state?: ReturnType; } & ComponentProps) { - const internalState = useCollapsible(collapsed, disabled); + const internalState = useCollapsible({ collapsed, disabled, initialCollapsed, storageKey }); const state = providedState || internalState; return ( <> @@ -64,14 +68,47 @@ export const Collapsible = Object.assign( } ); -export const useCollapsible = (collapsed?: boolean, disabled?: boolean) => { - const [isCollapsed, setCollapsed] = useState(!!collapsed); +const useSessionState = (key: string | undefined, initialValue: T) => { + const [value, setValue] = useState(() => { + try { + return (JSON.parse(sessionStorage.getItem(key!)!) as T) ?? initialValue; + } catch { + return initialValue; + } + }); + + useEffect(() => { + try { + if (key) { + sessionStorage.setItem(key, JSON.stringify(value)); + } + } catch {} + }, [key, value]); + + return [value, setValue] as const; +}; + +export const useCollapsible = ({ + collapsed, + disabled, + initialCollapsed = collapsed, + storageKey, +}: { + collapsed?: boolean; + disabled?: boolean; + initialCollapsed?: boolean; + storageKey?: string; +}) => { + const [isCollapsed, setCollapsed] = useSessionState( + storageKey && `useCollapsible:${storageKey}`, + !!initialCollapsed + ); useEffect(() => { if (collapsed !== undefined) { setCollapsed(collapsed); } - }, [collapsed]); + }, [collapsed, setCollapsed]); const toggleCollapsed = useCallback( (event?: SyntheticEvent) => { @@ -80,7 +117,7 @@ export const useCollapsible = (collapsed?: boolean, disabled?: boolean) => { setCollapsed((value) => !value); } }, - [disabled] + [disabled, setCollapsed] ); const contentId = useId(); diff --git a/code/core/src/manager/components/sidebar/ChecklistWidget.tsx b/code/core/src/manager/components/sidebar/ChecklistWidget.tsx index 140a3efe9845..129480d1584b 100644 --- a/code/core/src/manager/components/sidebar/ChecklistWidget.tsx +++ b/code/core/src/manager/components/sidebar/ChecklistWidget.tsx @@ -220,7 +220,8 @@ export const ChecklistWidget = () => { (