Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions code/core/src/components/components/Collapsible/Collapsible.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,20 @@ export const Collapsible = Object.assign(
summary,
collapsed,
disabled,
initialCollapsed,
storageKey,
state: providedState,
...props
}: {
children: ReactNode | ((state: ReturnType<typeof useCollapsible>) => ReactNode);
summary?: ReactNode | ((state: ReturnType<typeof useCollapsible>) => ReactNode);
collapsed?: boolean;
disabled?: boolean;
initialCollapsed?: boolean;
storageKey?: string;
state?: ReturnType<typeof useCollapsible>;
} & ComponentProps<typeof CollapsibleContent>) {
const internalState = useCollapsible(collapsed, disabled);
const internalState = useCollapsible({ collapsed, disabled, initialCollapsed, storageKey });
const state = providedState || internalState;
return (
<>
Expand All @@ -64,14 +68,47 @@ export const Collapsible = Object.assign(
}
);

export const useCollapsible = (collapsed?: boolean, disabled?: boolean) => {
const [isCollapsed, setCollapsed] = useState(!!collapsed);
const useSessionState = <T,>(key: string | undefined, initialValue: T) => {
const [value, setValue] = useState<T>(() => {
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<Element, Event>) => {
Expand All @@ -80,7 +117,7 @@ export const useCollapsible = (collapsed?: boolean, disabled?: boolean) => {
setCollapsed((value) => !value);
}
},
[disabled]
[disabled, setCollapsed]
);

const contentId = useId();
Expand Down
59 changes: 40 additions & 19 deletions code/core/src/manager/components/sidebar/ChecklistWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,34 +173,55 @@ const OpenGuideButton = ({

export const ChecklistWidget = () => {
const api = useStorybookApi();
const { loaded, allItems, nextItems, progress, accept, mute, items } = useChecklist();
const [renderItems, setItems] = useState<ChecklistItem[]>([]);
const { loaded, ready, allItems, nextItems, progress, accept, mute, items } = useChecklist();
const [renderItems, setRenderItems] = useState<ChecklistItem[]>(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 (
<CollapsibleWithMargin collapsed={!hasItems || !loaded}>
<HoverCard id="storybook-checklist-widget" outlineAnimation="rainbow">
<Collapsible
collapsed={!hasItems}
storageKey="checklist-widget"
initialCollapsed={!hasItems}
disabled={!hasItems}
summary={({ isCollapsed, toggleCollapsed, toggleProps }) => (
<ActionList as="div" onClick={toggleCollapsed}>
Expand Down Expand Up @@ -288,7 +309,7 @@ export const ChecklistWidget = () => {
onClick={() => api.navigate(`/settings/guide#${item.id}`)}
>
<ActionList.Icon>
{item.isCompleted ? (
{item.isCompleted && animated ? (
<Particles anchor={Checked} key={item.id} />
) : (
<StatusFailIcon />
Expand Down
33 changes: 29 additions & 4 deletions code/core/src/manager/components/sidebar/useChecklist.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<Record<string, RawItemWithSection>>(() => {
return Object.fromEntries(
Expand Down Expand Up @@ -185,7 +193,7 @@ export const useChecklist = () => {
}, [allItems]);

useEffect(() => {
if (!loaded) {
if (!loaded || status !== experimental_UniversalStore.Status.READY) {
return;
}

Expand Down Expand Up @@ -214,9 +222,26 @@ export const useChecklist = () => {
}
}
}
}, [api, loaded, allItems]);
}, [api, loaded, status, allItems]);

useEffect(() => {
const initialize = () => setInitialized(true);
const timeout = setTimeout(initialize, 1000);
api.once(PREVIEW_INITIALIZED, initialize);
return () => {
clearTimeout(timeout);
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,
Expand Down