diff --git a/code/core/src/cli/globalSettings.ts b/code/core/src/cli/globalSettings.ts index dcd641513978..bdada792ecf8 100644 --- a/code/core/src/cli/globalSettings.ts +++ b/code/core/src/cli/globalSettings.ts @@ -15,7 +15,13 @@ const userSettingSchema = z.object({ // (we can remove keys once they are deprecated) userSince: z.number().optional(), init: z.object({ skipOnboarding: z.boolean().optional() }).optional(), - checklist: z.object({ completed: z.array(z.string()), skipped: z.array(z.string()) }).optional(), + checklist: z + .object({ + muted: z.union([z.boolean(), z.array(z.string())]).optional(), + accepted: z.array(z.string()), + skipped: z.array(z.string()), + }) + .optional(), }); let settings: Settings | undefined; diff --git a/code/core/src/components/components/Button/Button.tsx b/code/core/src/components/components/Button/Button.tsx index 0df090779421..2abb63a9fd21 100644 --- a/code/core/src/components/components/Button/Button.tsx +++ b/code/core/src/components/components/Button/Button.tsx @@ -1,4 +1,4 @@ -import type { ButtonHTMLAttributes, SyntheticEvent } from 'react'; +import type { ButtonHTMLAttributes } from 'react'; import React, { forwardRef, useEffect, useMemo, useState } from 'react'; import { deprecate } from 'storybook/internal/client-logger'; @@ -12,14 +12,14 @@ import { InteractiveTooltipWrapper } from './helpers/InteractiveTooltipWrapper'; import { useAriaDescription } from './helpers/useAriaDescription'; export interface ButtonProps extends ButtonHTMLAttributes { - as?: 'button' | 'a' | 'label' | typeof Slot; + as?: 'a' | 'button' | 'div' | 'label' | typeof Slot; asChild?: boolean; size?: 'small' | 'medium'; padding?: 'small' | 'medium' | 'none'; variant?: 'outline' | 'solid' | 'ghost'; - onClick?: (event: SyntheticEvent) => void; active?: boolean; disabled?: boolean; + readOnly?: boolean; animation?: 'none' | 'rotate360' | 'glow' | 'jiggle'; /** @@ -67,6 +67,7 @@ export const Button = forwardRef( variant = 'outline', padding = 'medium', disabled = false, + readOnly = false, active, onClick, ariaLabel, @@ -105,7 +106,7 @@ export const Button = forwardRef( const [isAnimating, setIsAnimating] = useState(false); - const handleClick = (event: SyntheticEvent) => { + const handleClick: ButtonProps['onClick'] = (event) => { if (onClick) { onClick(event); } @@ -141,6 +142,7 @@ export const Button = forwardRef( size={size} padding={padding} disabled={disabled} + readOnly={readOnly} active={active} animating={isAnimating} animation={animation} @@ -166,144 +168,158 @@ const StyledButton = styled('button', { animating: boolean; animation: ButtonProps['animation']; } ->(({ theme, variant, size, disabled, active, animating, animation = 'none', padding }) => ({ - border: 0, - cursor: disabled ? 'not-allowed' : 'pointer', - display: 'inline-flex', - gap: '6px', - alignItems: 'center', - justifyContent: 'center', - overflow: 'hidden', - padding: (() => { - if (padding === 'none') { +>( + ({ + theme, + variant, + size, + disabled, + readOnly, + active, + animating, + animation = 'none', + padding, + }) => ({ + border: 0, + cursor: readOnly ? 'inherit' : disabled ? 'not-allowed' : 'pointer', + display: 'inline-flex', + gap: '6px', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + padding: (() => { + if (padding === 'none') { + return 0; + } + if (padding === 'small' && size === 'small') { + return '0 7px'; + } + if (padding === 'small' && size === 'medium') { + return '0 9px'; + } + if (size === 'small') { + return '0 10px'; + } + if (size === 'medium') { + return '0 12px'; + } return 0; - } - if (padding === 'small' && size === 'small') { - return '0 7px'; - } - if (padding === 'small' && size === 'medium') { - return '0 9px'; - } - if (size === 'small') { - return '0 10px'; - } - if (size === 'medium') { - return '0 12px'; - } - return 0; - })(), - height: size === 'small' ? '28px' : '32px', - position: 'relative', - textAlign: 'center', - textDecoration: 'none', - transitionProperty: 'background, box-shadow', - transitionDuration: '150ms', - transitionTimingFunction: 'ease-out', - verticalAlign: 'top', - whiteSpace: 'nowrap', - userSelect: 'none', - opacity: disabled ? 0.5 : 1, - margin: 0, - fontSize: `${theme.typography.size.s1}px`, - fontWeight: theme.typography.weight.bold, - lineHeight: '1', - background: (() => { - if (variant === 'solid') { - return theme.base === 'light' ? theme.color.secondary : darken(0.18, theme.color.secondary); - } - - if (variant === 'outline') { - return theme.button.background; - } - - if (variant === 'ghost' && active) { - return transparentize(0.93, theme.barSelectedColor); - } - - return 'transparent'; - })(), - color: (() => { - if (variant === 'solid') { - return theme.color.lightest; - } - - if (variant === 'outline') { - return theme.input.color; - } - - if (variant === 'ghost' && active) { - return theme.base === 'light' ? darken(0.1, theme.color.secondary) : theme.color.secondary; - } - - if (variant === 'ghost') { - return theme.textMutedColor; - } - return theme.input.color; - })(), - boxShadow: variant === 'outline' ? `${theme.button.border} 0 0 0 1px inset` : 'none', - borderRadius: theme.input.borderRadius, - // Making sure that the button never shrinks below its minimum size - flexShrink: 0, - - '&:hover': { - color: variant === 'ghost' ? theme.color.secondary : undefined, + })(), + height: size === 'small' ? '28px' : '32px', + position: 'relative', + textAlign: 'center', + textDecoration: 'none', + transitionProperty: 'background, box-shadow', + transitionDuration: '150ms', + transitionTimingFunction: 'ease-out', + verticalAlign: 'top', + whiteSpace: 'nowrap', + userSelect: 'none', + opacity: disabled && !readOnly ? 0.5 : 1, + margin: 0, + fontSize: `${theme.typography.size.s1}px`, + fontWeight: theme.typography.weight.bold, + lineHeight: '1', background: (() => { - let bgColor = theme.color.secondary; - if (variant === 'solid') { - bgColor = - theme.base === 'light' - ? lighten(0.1, theme.color.secondary) - : darken(0.3, theme.color.secondary); + return theme.base === 'light' ? theme.color.secondary : darken(0.18, theme.color.secondary); } if (variant === 'outline') { - bgColor = theme.button.background; + return theme.button.background; } - if (variant === 'ghost') { - return transparentize(0.86, theme.color.secondary); + if (variant === 'ghost' && active) { + return transparentize(0.93, theme.barSelectedColor); } - return theme.base === 'light' ? darken(0.02, bgColor) : lighten(0.03, bgColor); - })(), - }, - - '&:active': { - color: variant === 'ghost' ? theme.color.secondary : undefined, - background: (() => { - let bgColor = theme.color.secondary; + return 'transparent'; + })(), + color: (() => { if (variant === 'solid') { - bgColor = theme.color.secondary; + return theme.color.lightest; } if (variant === 'outline') { - bgColor = theme.button.background; + return theme.input.color; + } + + if (variant === 'ghost' && active) { + return theme.base === 'light' ? darken(0.1, theme.color.secondary) : theme.color.secondary; } if (variant === 'ghost') { - return theme.background.hoverable; + return theme.textMutedColor; } - return theme.base === 'light' ? darken(0.02, bgColor) : lighten(0.03, bgColor); + return theme.input.color; })(), - }, - - '&:focus-visible': { - outline: `2px solid ${rgba(theme.color.secondary, 1)}`, - outlineOffset: 2, - // Should ensure focus outline gets drawn above next sibling - zIndex: '1', - }, - - '.sb-bar &:focus-visible, .sb-list &:focus-visible': { - outlineOffset: 0, - }, - - '> svg': { - animation: - animating && animation !== 'none' ? `${theme.animation[animation]} 1000ms ease-out` : '', - }, -})); + boxShadow: variant === 'outline' ? `${theme.button.border} 0 0 0 1px inset` : 'none', + borderRadius: theme.input.borderRadius, + // Making sure that the button never shrinks below its minimum size + flexShrink: 0, + + ...(!readOnly && { + '&:hover': { + color: variant === 'ghost' ? theme.color.secondary : undefined, + background: (() => { + let bgColor = theme.color.secondary; + + if (variant === 'solid') { + bgColor = + theme.base === 'light' + ? lighten(0.1, theme.color.secondary) + : darken(0.3, theme.color.secondary); + } + + if (variant === 'outline') { + bgColor = theme.button.background; + } + + if (variant === 'ghost') { + return transparentize(0.86, theme.color.secondary); + } + return theme.base === 'light' ? darken(0.02, bgColor) : lighten(0.03, bgColor); + })(), + }, + + '&:active': { + color: variant === 'ghost' ? theme.color.secondary : undefined, + background: (() => { + let bgColor = theme.color.secondary; + + if (variant === 'solid') { + bgColor = theme.color.secondary; + } + + if (variant === 'outline') { + bgColor = theme.button.background; + } + + if (variant === 'ghost') { + return theme.background.hoverable; + } + return theme.base === 'light' ? darken(0.02, bgColor) : lighten(0.03, bgColor); + })(), + }, + + '&:focus-visible': { + outline: `2px solid ${rgba(theme.color.secondary, 1)}`, + outlineOffset: 2, + // Should ensure focus outline gets drawn above next sibling + zIndex: '1', + }, + + '.sb-bar &:focus-visible, .sb-list &:focus-visible': { + outlineOffset: 0, + }, + }), + + '> svg': { + animation: + animating && animation !== 'none' ? `${theme.animation[animation]} 1000ms ease-out` : '', + }, + }) +); export const IconButton = forwardRef((props, ref) => { deprecate( diff --git a/code/core/src/components/components/Card/Card.stories.tsx b/code/core/src/components/components/Card/Card.stories.tsx index 720c895aac84..1129fe659b8f 100644 --- a/code/core/src/components/components/Card/Card.stories.tsx +++ b/code/core/src/components/components/Card/Card.stories.tsx @@ -9,55 +9,98 @@ const Contents = ({ children }: { children: React.ReactNode }) => (
{children}
); -export const All = meta.story(() => ( -
- - Default - - - Rainbow - - - Spinning - - - Positive - - - Warning - - - Negative - - - Primary - - - Secondary - - - Ancillary - - - Orange - - - Gold - - - Green - - - Seafoam - - - Purple - - - Ultraviolet - - - Mediumdark - -
+export const Default = meta.story(() => ( + + Default + +)); + +export const Rainbow = meta.story(() => ( + + Rainbow + +)); + +export const Spinning = meta.story(() => ( + + Spinning + +)); + +export const Positive = meta.story(() => ( + + Positive + +)); + +export const Warning = meta.story(() => ( + + Warning + +)); + +export const Negative = meta.story(() => ( + + Negative + +)); + +export const Primary = meta.story(() => ( + + Primary + +)); + +export const Secondary = meta.story(() => ( + + Secondary + +)); + +export const Ancillary = meta.story(() => ( + + Ancillary + +)); + +export const Orange = meta.story(() => ( + + Orange + +)); + +export const Gold = meta.story(() => ( + + Gold + +)); + +export const Green = meta.story(() => ( + + Green + +)); + +export const Seafoam = meta.story(() => ( + + Seafoam + +)); + +export const Purple = meta.story(() => ( + + Purple + +)); + +export const Ultraviolet = meta.story(() => ( + + Ultraviolet + +)); + +export const Mediumdark = meta.story(() => ( + + Mediumdark + )); diff --git a/code/core/src/components/components/Card/Card.tsx b/code/core/src/components/components/Card/Card.tsx index 2efa1b40cb83..15e661d2e1bb 100644 --- a/code/core/src/components/components/Card/Card.tsx +++ b/code/core/src/components/components/Card/Card.tsx @@ -1,4 +1,4 @@ -import React, { type ComponentProps } from 'react'; +import React, { type ComponentProps, forwardRef } from 'react'; import type { CSSObject, color } from 'storybook/theming'; import { keyframes, styled } from 'storybook/theming'; @@ -26,20 +26,22 @@ const slide = keyframes({ }, }); -export const Card = ({ - outlineAnimation = 'none', - outlineColor, - outlineStyles, - ...props -}: { +interface CardProps extends ComponentProps { outlineAnimation?: 'none' | 'rainbow' | 'spin'; outlineColor?: keyof typeof color; outlineStyles?: CSSObject; -} & ComponentProps) => ( - - - -); +} + +export const Card = forwardRef(function Card( + { outlineAnimation = 'none', outlineColor, outlineStyles, ...props }, + ref +) { + return ( + + + + ); +}); export const Content = styled.div(({ theme }) => ({ borderRadius: theme.appBorderRadius, @@ -58,8 +60,41 @@ export const Outline = styled.div<{ overflow: 'hidden', backgroundColor: theme.background.content, borderRadius: theme.appBorderRadius + 1, - boxShadow: `inset 0 0 0 1px ${(animation === 'none' && color && theme.color[color]) || theme.appBorderColor}, 0 1px 2px 0 rgba(0, 0, 0, 0.05), 0px -5px 20px 10px ${theme.background.app}`, + boxShadow: `inset 0 0 0 1px ${(animation === 'none' && color && theme.color[color]) || theme.appBorderColor}, var(--card-box-shadow, transparent 0 0)`, transition: 'box-shadow 1s', + ...styles, + + '@supports (interpolate-size: allow-keywords)': { + interpolateSize: 'allow-keywords', + overflow: 'hidden', + transition: 'all var(--transition-duration, 0.2s), box-shadow 1s', + transitionBehavior: 'allow-discrete', + }, + + '@media (prefers-reduced-motion: reduce)': { + transition: 'box-shadow 1s', + }, + + '&.enter': { + opacity: 0, + blockSize: 0, + contentVisibility: 'hidden', + }, + '&.enter-active': { + opacity: 1, + blockSize: 'auto', + contentVisibility: 'visible', + }, + '&.exit': { + opacity: 1, + blockSize: 'auto', + contentVisibility: 'visible', + }, + '&.exit-active, &.exit-done': { + opacity: 0, + blockSize: 0, + contentVisibility: 'hidden', + }, '&:before': { content: '""', @@ -93,6 +128,6 @@ export const Outline = styled.div<{ : `conic-gradient(transparent 90deg, #029CFD 150deg, #37D5D3 210deg, transparent 270deg)`, }), - ...styles, + ...(styles && typeof styles['&:before'] === 'object' ? styles['&:before'] : {}), }, })); diff --git a/code/core/src/components/components/Collapsible/Collapsible.stories.tsx b/code/core/src/components/components/Collapsible/Collapsible.stories.tsx index 9a6cd34cb671..2706958ceb9e 100644 --- a/code/core/src/components/components/Collapsible/Collapsible.stories.tsx +++ b/code/core/src/components/components/Collapsible/Collapsible.stories.tsx @@ -1,15 +1,16 @@ import { useState } from 'react'; import preview from '../../../../../.storybook/preview'; +import type { useCollapsible } from './Collapsible'; import { Collapsible } from './Collapsible'; const toggle = ({ isCollapsed, - toggleCollapsed, + toggleProps, }: { isCollapsed: boolean; - toggleCollapsed: () => void; -}) => ; + toggleProps: ReturnType['toggleProps']; +}) => ; const content =
Peekaboo!
; @@ -23,16 +24,22 @@ const meta = preview.meta({ export const Default = meta.story({}); -export const Open = meta.story({ - play: ({ canvas, userEvent }) => userEvent.click(canvas.getByRole('button', { name: 'Open' })), +export const Collapsed = meta.story({ + args: { + collapsed: true, + }, }); -export const InitialOpen = meta.story({ +export const Disabled = meta.story({ args: { - initialCollapsed: false, + disabled: true, }, }); +export const Toggled = meta.story({ + play: ({ canvas, userEvent }) => userEvent.click(canvas.getByRole('button', { name: 'Close' })), +}); + export const Controlled = meta.story({ render: () => { const [collapsed, setCollapsed] = useState(true); @@ -43,4 +50,5 @@ export const Controlled = meta.story({ ); }, + play: ({ canvas, userEvent }) => userEvent.click(canvas.getByRole('button', { name: 'Toggle' })), }); diff --git a/code/core/src/components/components/Collapsible/Collapsible.tsx b/code/core/src/components/components/Collapsible/Collapsible.tsx index a803ff18ec66..5ac8e3f23020 100644 --- a/code/core/src/components/components/Collapsible/Collapsible.tsx +++ b/code/core/src/components/components/Collapsible/Collapsible.tsx @@ -1,5 +1,11 @@ -import React, { type ComponentProps } from 'react'; -import { useEffect, useId, useState } from 'react'; +import React, { + type ComponentProps, + type SyntheticEvent, + useCallback, + useEffect, + useId, + useState, +} from 'react'; import { styled } from 'storybook/theming'; @@ -7,19 +13,23 @@ export const Collapsible = ({ children, summary, collapsed, - initialCollapsed = true, + disabled, + state: providedState, + ...props }: { children: React.ReactNode | ((state: ReturnType) => React.ReactNode); summary?: React.ReactNode | ((state: ReturnType) => React.ReactNode); collapsed?: boolean; - initialCollapsed?: boolean; -}) => { - const state = useCollapsible(initialCollapsed, collapsed); - + disabled?: boolean; + state?: ReturnType; +} & ComponentProps) => { + const internalState = useCollapsible(collapsed, disabled); + const state = providedState || internalState; return ( <> {typeof summary === 'function' ? summary(state) : summary} ); -const Content = styled.div<{ collapsed: boolean }>(({ collapsed }) => ({ +const Content = styled.div<{ collapsed?: boolean }>(({ collapsed = false }) => ({ blockSize: collapsed ? 0 : 'auto', interpolateSize: 'allow-keywords', contentVisibility: collapsed ? 'hidden' : 'visible', @@ -49,8 +59,8 @@ const Content = styled.div<{ collapsed: boolean }>(({ collapsed }) => ({ }, })); -export const useCollapsible = (initialCollapsed = true, collapsed?: boolean) => { - const [isCollapsed, setCollapsed] = useState(collapsed ?? initialCollapsed); +export const useCollapsible = (collapsed?: boolean, disabled?: boolean) => { + const [isCollapsed, setCollapsed] = useState(!!collapsed); useEffect(() => { if (collapsed !== undefined) { @@ -58,13 +68,30 @@ export const useCollapsible = (initialCollapsed = true, collapsed?: boolean) => } }, [collapsed]); - const toggleCollapsed = () => setCollapsed(!isCollapsed); + const toggleCollapsed = useCallback( + (event?: SyntheticEvent) => { + event?.stopPropagation(); + if (!disabled) { + setCollapsed((value) => !value); + } + }, + [disabled] + ); + const contentId = useId(); const toggleProps = { + disabled, onClick: toggleCollapsed, 'aria-controls': contentId, 'aria-expanded': !isCollapsed, } as const; - return { contentId, isCollapsed, setCollapsed, toggleCollapsed, toggleProps }; + return { + contentId, + isCollapsed, + isDisabled: !!disabled, + setCollapsed, + toggleCollapsed, + toggleProps, + }; }; diff --git a/code/core/src/components/components/FocusProxy/FocusProxy.stories.tsx b/code/core/src/components/components/FocusProxy/FocusProxy.stories.tsx deleted file mode 100644 index 5e6e48f94c7a..000000000000 --- a/code/core/src/components/components/FocusProxy/FocusProxy.stories.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; - -import preview from '../../../../../.storybook/preview'; -import { FocusProxy } from './FocusProxy'; - -const meta = preview.meta({ - component: FocusProxy, -}); - -export const Default = meta.story({ - render: () => ( - - -
A bunch of content
- -
- ), - play: async ({ canvas }) => { - canvas.getByRole('button', { name: 'Focus me' }).focus(); - }, -}); diff --git a/code/core/src/components/components/FocusProxy/FocusProxy.tsx b/code/core/src/components/components/FocusProxy/FocusProxy.tsx deleted file mode 100644 index 41e64ced97eb..000000000000 --- a/code/core/src/components/components/FocusProxy/FocusProxy.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { styled } from 'storybook/theming'; - -export const FocusProxy = styled.div<{ htmlFor: string; outlineOffset?: number }>( - ({ theme, htmlFor, outlineOffset = 0 }) => ({ - width: '100%', - borderRadius: 'inherit', - transition: 'outline-color var(--transition-duration, 0.2s)', - outline: `2px solid transparent`, - outlineOffset, - - [`&:focus, &:has(#${htmlFor}:focus-visible)`]: { - outlineColor: theme.color.secondary, - }, - }) -); diff --git a/code/core/src/components/components/FocusRing/FocusRing.stories.tsx b/code/core/src/components/components/FocusRing/FocusRing.stories.tsx new file mode 100644 index 000000000000..fd9076079d68 --- /dev/null +++ b/code/core/src/components/components/FocusRing/FocusRing.stories.tsx @@ -0,0 +1,68 @@ +import React from 'react'; + +import preview from '../../../../../.storybook/preview'; +import { FocusProxy, FocusRing, FocusTarget } from './FocusRing'; + +const meta = preview.meta({ + component: FocusRing, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}); + +export const Active = meta.story({ + args: { + active: true, + children: 'Focused', + }, +}); + +export const Temporary = meta.story({ + args: { + active: true, + children: 'Focused for 1 second', + highlightDuration: 1000, + }, +}); + +export const Proxy = meta.story({ + render: () => ( + + +
A bunch of content
+ +
+ ), + play: async ({ canvas }) => { + canvas.getByRole('button', { name: 'Focus me' }).focus(); + }, +}); + +export const Target = meta.story({ + render: () => ( + <> + + +
Focused for 2 seconds
+
+ + ), + play: async ({ canvas, userEvent }) => { + await new Promise((resolve) => setTimeout(resolve, 500)); + await userEvent.click(canvas.getByRole('button', { name: 'Set target' })); + }, + beforeEach: () => { + window.location.hash = ''; + }, +}); diff --git a/code/core/src/components/components/FocusRing/FocusRing.tsx b/code/core/src/components/components/FocusRing/FocusRing.tsx new file mode 100644 index 000000000000..4d8acd7602e3 --- /dev/null +++ b/code/core/src/components/components/FocusRing/FocusRing.tsx @@ -0,0 +1,77 @@ +import React, { useEffect, useRef, useState } from 'react'; + +import { styled } from 'storybook/theming'; + +import { useLocationHash } from '../shared/useLocationHash'; + +export const FocusOutline = styled.div<{ active?: boolean; outlineOffset?: number }>( + ({ theme, active = false, outlineOffset = 0 }) => ({ + width: '100%', + borderRadius: 'inherit', + transition: 'outline-color var(--transition-duration, 0.2s)', + outline: `2px solid ${active ? theme.color.secondary : 'transparent'}`, + outlineOffset, + }) +); + +export const FocusProxy = styled(FocusOutline)<{ targetId: string }>(({ theme, targetId }) => ({ + [`&:has(#${targetId}:focus-visible)`]: { + outlineColor: theme.color.secondary, + }, +})); + +export const FocusRing = ({ + active = false, + highlightDuration, + nodeRef, + ...props +}: React.ComponentProps & { + highlightDuration?: number; + nodeRef?: React.RefObject; +}) => { + const [visible, setVisible] = useState(active); + + useEffect(() => { + if (highlightDuration) { + setVisible(active); + const timeout = setTimeout(setVisible, highlightDuration, false); + return () => clearTimeout(timeout); + } + }, [active, highlightDuration]); + + return ; +}; + +export const FocusTarget = ({ + targetHash, + highlightDuration, + ...props +}: Omit, 'active'> & { + targetHash: string; +}) => { + const nodeRef = useRef(null); + const locationHash = useLocationHash(); + const [active, setActive] = useState(locationHash === targetHash); + + useEffect(() => { + const timeouts: ReturnType[] = []; + + setActive(false); + if (locationHash === targetHash) { + timeouts.push( + setTimeout(() => { + setActive(true); + nodeRef.current?.focus({ preventScroll: true }); + nodeRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 0) + ); + if (highlightDuration) { + timeouts.push(setTimeout(setActive, highlightDuration, false)); + } + } + + return () => timeouts.forEach(clearTimeout); + }, [locationHash, targetHash, highlightDuration]); + + return ; +}; diff --git a/code/core/src/components/components/Listbox/Listbox.stories.tsx b/code/core/src/components/components/Listbox/Listbox.stories.tsx index 70f38122756d..435314643f0c 100644 --- a/code/core/src/components/components/Listbox/Listbox.stories.tsx +++ b/code/core/src/components/components/Listbox/Listbox.stories.tsx @@ -1,6 +1,6 @@ import { CheckIcon, EllipsisIcon, PlayAllHollowIcon } from '@storybook/icons'; -import { Form } from '../..'; +import { Badge, Form, ProgressSpinner } from '../..'; import preview from '../../../../../.storybook/preview'; import { Shortcut } from '../../../manager/container/Menu'; import { Listbox, ListboxAction, ListboxButton, ListboxItem, ListboxText } from './Listbox'; @@ -17,7 +17,7 @@ export const Default = meta.story({ Text item - + @@ -32,14 +32,29 @@ export const Default = meta.story({ With a button Go + + + With an inline button + + + 25% + + + + + + With a badge + Check it out + + - With a checkbox + With a checkbox - - + + Active with an icon diff --git a/code/core/src/components/components/Listbox/Listbox.tsx b/code/core/src/components/components/Listbox/Listbox.tsx index d38332089346..88f34202f519 100644 --- a/code/core/src/components/components/Listbox/Listbox.tsx +++ b/code/core/src/components/components/Listbox/Listbox.tsx @@ -5,23 +5,28 @@ import { styled } from 'storybook/theming'; import { Button } from '../Button/Button'; -export const Listbox = styled.ul(({ theme }) => ({ +export const Listbox = styled.div(({ theme }) => ({ listStyle: 'none', margin: 0, padding: 4, - '& + &': { + '& + *': { borderTop: `1px solid ${theme.appBorderColor}`, }, })); -export const ListboxItem = styled.li<{ transitionStatus?: TransitionStatus }>( - { +export const ListboxItem = styled.li<{ active?: boolean; transitionStatus?: TransitionStatus }>( + ({ active, theme }) => ({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 4, + fontSize: theme.typography.size.s1, + fontWeight: active ? theme.typography.weight.bold : theme.typography.weight.regular, + color: active ? theme.color.secondary : theme.color.defaultText, + '--listbox-item-muted-color': active ? theme.color.secondary : theme.color.mediumdark, + '@supports (interpolate-size: allow-keywords)': { interpolateSize: 'allow-keywords', overflow: 'hidden', @@ -32,7 +37,7 @@ export const ListboxItem = styled.li<{ transitionStatus?: TransitionStatus }>( '@media (prefers-reduced-motion: reduce)': { transition: 'none', }, - }, + }), ({ transitionStatus }) => { switch (transitionStatus) { case 'preEnter': @@ -62,19 +67,18 @@ export const ListboxButton = ({ + ); +}; + +const meta = preview.meta({ + component: TextFlip, + args: { + text: 'Use controls to change this', + placeholder: 'This is some long placeholder text', + }, + render: (args) => ( + + ), +}); + +export const Default = meta.story({}); + +export const Increasing = meta.story(() => ); + +export const Decreasing = meta.story(() => ); diff --git a/code/core/src/manager/components/TextFlip.tsx b/code/core/src/manager/components/TextFlip.tsx new file mode 100644 index 000000000000..f4778ea08052 --- /dev/null +++ b/code/core/src/manager/components/TextFlip.tsx @@ -0,0 +1,108 @@ +import type { ComponentProps } from 'react'; +import React, { useRef, useState } from 'react'; + +import { keyframes, styled } from 'storybook/theming'; + +const slideIn = keyframes({ + from: { + transform: 'translateY(var(--slide-in-from))', + opacity: 0, + }, +}); + +const slideOut = keyframes({ + to: { + transform: 'translateY(var(--slide-out-to))', + opacity: 0, + }, +}); + +const Container = styled.div({ + display: 'inline-grid', + gridTemplateColumns: '1fr', + justifyContent: 'center', + alignItems: 'center', +}); + +const Placeholder = styled.div({ + gridArea: '1 / 1', + userSelect: 'none', + visibility: 'hidden', +}); + +const Text = styled.span<{ + duration: number; + isExiting?: boolean; + isEntering?: boolean; + reverse?: boolean; +}>(({ duration, isExiting, isEntering, reverse }) => { + let animation: string | undefined; + + if (isExiting) { + animation = `${slideOut} ${duration}ms forwards`; + } else if (isEntering) { + animation = `${slideIn} ${duration}ms forwards`; + } + + return { + gridArea: '1 / 1', + animation, + pointerEvents: isExiting ? 'none' : 'auto', + userSelect: isExiting ? 'none' : 'text', + '--slide-in-from': reverse ? '-100%' : '100%', + '--slide-out-to': reverse ? '100%' : '-100%', + + '@media (prefers-reduced-motion: reduce)': { + animation: 'none', + opacity: isExiting ? 0 : 1, + transform: 'translateY(0)', + }, + }; +}); + +export const TextFlip = ({ + text, + duration = 250, + placeholder, + ...props +}: { + text: string; + duration?: number; + placeholder?: string; +} & ComponentProps) => { + const textRef = useRef(text); + const [staleValue, setStaleValue] = useState(text); + + const isAnimating = text !== staleValue; + const reverse = isAnimating && numericCompare(staleValue, text); + + textRef.current = text; + + return ( + + {isAnimating && ( + setStaleValue(textRef.current)} + > + {staleValue} + + )} + + {text} + + {placeholder && {placeholder}} + + ); +}; + +function numericCompare(a: string, b: string): boolean { + const na = Number(a); + const nb = Number(b); + return Number.isNaN(na) || Number.isNaN(nb) + ? a.localeCompare(b, undefined, { numeric: true }) > 0 + : na > nb; +} diff --git a/code/core/src/manager/components/sidebar/ChecklistModule.stories.tsx b/code/core/src/manager/components/sidebar/ChecklistModule.stories.tsx index 26f0e2297af3..f6a9d2393618 100644 --- a/code/core/src/manager/components/sidebar/ChecklistModule.stories.tsx +++ b/code/core/src/manager/components/sidebar/ChecklistModule.stories.tsx @@ -23,8 +23,11 @@ const meta = preview.meta({ ], beforeEach: async () => { mockStore.setState({ - completed: ['add-component'], - skipped: ['add-5-10-components'], + loaded: true, + muted: false, + accepted: ['controls'], + done: ['add-component'], + skipped: ['viewports'], }); }, }); diff --git a/code/core/src/manager/components/sidebar/ChecklistModule.tsx b/code/core/src/manager/components/sidebar/ChecklistModule.tsx index 274de9f12720..dfc8c5d9fc37 100644 --- a/code/core/src/manager/components/sidebar/ChecklistModule.tsx +++ b/code/core/src/manager/components/sidebar/ChecklistModule.tsx @@ -1,22 +1,33 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect } from 'react'; import { Card, + Collapsible, Listbox, ListboxAction, ListboxButton, + ListboxIcon, ListboxItem, ListboxText, + ProgressSpinner, + TooltipNote, + WithTooltip, } from 'storybook/internal/components'; -import { StatusFailIcon } from '@storybook/icons'; +import { + ChevronSmallUpIcon, + EyeCloseIcon, + ListUnorderedIcon, + StatusFailIcon, +} from '@storybook/icons'; -import { checklistStore, universalChecklistStore } from '#manager-stores'; +import { checklistStore } from '#manager-stores'; import { type TransitionMapOptions, useTransitionMap } from 'react-transition-state'; -import { experimental_useUniversalStore, useStorybookApi } from 'storybook/manager-api'; +import { useStorybookApi } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; -import { checklistData } from '../../settings/Checklist/checklistData'; +import { TextFlip } from '../TextFlip'; +import { useChecklist } from './useChecklist'; const useTransitionArray = (array: K[], subset: K[], options: TransitionMapOptions) => { const { setItem, toggle, stateMap } = useTransitionMap({ @@ -38,65 +49,179 @@ const useTransitionArray = (array: K[], subset: K[], options: TransitionMapO return Array.from(stateMap); }; -const ItemIcon = styled(StatusFailIcon)(({ theme }) => ({ - color: theme.color.mediumdark, +const CollapsibleWithMargin = styled(Collapsible)(({ collapsed }) => ({ + marginTop: collapsed ? 0 : 16, })); -export const ChecklistModule = () => { - const api = useStorybookApi(); - const [{ completed, skipped }] = experimental_useUniversalStore(universalChecklistStore); +const HoverCard = styled(Card)({ + '&:hover #checklist-module-collapse-toggle': { + opacity: 1, + }, +}); - const allTasks = useMemo(() => checklistData.sections.flatMap(({ items }) => items), []); - const nextTasks = useMemo( - () => allTasks.filter(({ id }) => !completed.includes(id) && !skipped.includes(id)).slice(0, 3), - [allTasks, completed, skipped] - ); +const CollapseToggle = styled(ListboxButton)({ + opacity: 0, + transition: 'opacity var(--transition-duration, 0.2s)', + '&:focus, &:hover': { + opacity: 1, + }, +}); - const transitionItems = useTransitionArray(allTasks, nextTasks, { timeout: 300 }); +const ProgressCircle = styled(ProgressSpinner)(({ theme }) => ({ + color: theme.color.secondary, +})); - if (nextTasks.length === 0) { - return null; +const title = (progress: number) => { + switch (true) { + case progress < 25: + return 'Getting started'; + case progress < 50: + return 'Making progress'; + case progress < 75: + return 'Getting close'; + default: + return 'Almost there'; } +}; + +export const ChecklistModule = () => { + const api = useStorybookApi(); + const { loaded, allItems, nextItems, progress, mute } = useChecklist(); + + const transitionItems = useTransitionArray(allItems, nextItems, { timeout: 300 }); + const hasItems = nextItems.length > 0; return ( - - - - - Project setup - - api.navigateUrl('/settings/guided-tour', { plain: false })}> - {Math.round(((allTasks.length - nextTasks.length) / allTasks.length) * 100)}% - - - - - {transitionItems.map( - ([task, { status, isMounted }]) => - isMounted && ( - - - api.navigateUrl(`/settings/guided-tour#${task.id}`, { plain: false }) - } - > - - {task.label} - - {task.start && ( - { - checklistStore.complete(task.id); - task.start?.({ api }); - }} + + + ( + + + + {loaded && ( + { + e.stopPropagation(); + api.navigateUrl('/settings/guide', { plain: false }); + }} + > + {title(progress)} + + )} + + + + } + trigger="hover" > - Start - - )} + + + + + {loaded && ( + ( + + + { + e.stopPropagation(); + api.navigateUrl('/settings/guide', { plain: false }); + onHide(); + }} + > + + + + Open full guide + + + + { + e.stopPropagation(); + mute(allItems.map(({ id }) => id)); + onHide(); + }} + > + + + + Remove from sidebar + + + + )} + > + e.stopPropagation()}> + + + + + )} + - ) - )} - - + + )} + > + + {transitionItems.map( + ([item, { status, isMounted }]) => + isMounted && ( + + + api.navigateUrl(`/settings/guide#${item.id}`, { plain: false }) + } + > + + + + {item.label} + + {item.action && ( + { + item.action?.onClick({ + api, + accept: () => checklistStore.accept(item.id), + }); + }} + > + {item.action.label} + + )} + + ) + )} + + + + ); }; diff --git a/code/core/src/manager/components/sidebar/Explorer.tsx b/code/core/src/manager/components/sidebar/Explorer.tsx index 359c87e419c3..bd312377bfde 100644 --- a/code/core/src/manager/components/sidebar/Explorer.tsx +++ b/code/core/src/manager/components/sidebar/Explorer.tsx @@ -1,7 +1,6 @@ import type { FC } from 'react'; import React, { useRef } from 'react'; -import { ChecklistModule } from './ChecklistModule'; import { HighlightStyles } from './HighlightStyles'; import { Ref } from './Refs'; import type { CombinedDataset, Selection } from './types'; @@ -39,7 +38,6 @@ export const Explorer: FC = React.memo(function Explorer({ data-highlighted-ref-id={highlighted?.refId} data-highlighted-item-id={highlighted?.itemId} > - {highlighted && } {dataset.entries.map(([refId, ref]) => ( {storyFn()}], + beforeEach: async () => { + mockStore.setState({ + loaded: true, + muted: false, + accepted: ['controls'], + done: ['add-component'], + skipped: ['viewports'], + }); + }, } satisfies Meta; export default meta; @@ -55,9 +64,8 @@ const DoubleThemeRenderingHack = styled.div({ export const Expanded: Story = { globals: { sb_theme: 'light', viewport: 'desktop' }, render: () => { - const menu = useMenu( - { whatsNewData: undefined } as State, - { + const menu = useMenu({ + api: { // @ts-expect-error (Converted from ts-ignore) getShortcutKeys: () => ({}), getAddonsShortcuts: () => ({}), @@ -65,12 +73,11 @@ export const Expanded: Story = { isWhatsNewUnread: () => false, getDocsUrl: () => 'https://storybook.js.org/docs/', }, - false, - false, - false, - false, - false - ); + showToolbar: false, + isPanelShown: false, + isNavShown: false, + enableShortcuts: false, + }); return ( @@ -107,9 +114,8 @@ export const Expanded: Story = { export const ExpandedWithShortcuts: Story = { ...Expanded, render: () => { - const menu = useMenu( - { whatsNewData: undefined } as State, - { + const menu = useMenu({ + api: { // @ts-expect-error (invalid) getShortcutKeys: () => ({ shortcutsPage: ['⌘', '⇧​', ','], @@ -130,12 +136,11 @@ export const ExpandedWithShortcuts: Story = { isWhatsNewUnread: () => false, getDocsUrl: () => 'https://storybook.js.org/docs/', }, - false, - false, - false, - false, - true - ); + showToolbar: false, + isPanelShown: false, + isNavShown: false, + enableShortcuts: true, + }); return ( @@ -161,9 +166,8 @@ export const ExpandedWithShortcuts: Story = { export const ExpandedWithWhatsNew: Story = { ...Expanded, render: () => { - const menu = useMenu( - { whatsNewData: { status: 'SUCCESS', disableWhatsNewNotifications: false } } as State, - { + const menu = useMenu({ + api: { // @ts-expect-error (invalid) getShortcutKeys: () => ({}), getAddonsShortcuts: () => ({}), @@ -171,12 +175,11 @@ export const ExpandedWithWhatsNew: Story = { isWhatsNewUnread: () => true, getDocsUrl: () => 'https://storybook.js.org/docs/', }, - false, - false, - false, - false, - false - ); + showToolbar: false, + isPanelShown: false, + isNavShown: false, + enableShortcuts: false, + }); return ( diff --git a/code/core/src/manager/components/sidebar/Menu.tsx b/code/core/src/manager/components/sidebar/Menu.tsx index 4d03a6a6a48c..010df50f3f4a 100644 --- a/code/core/src/manager/components/sidebar/Menu.tsx +++ b/code/core/src/manager/components/sidebar/Menu.tsx @@ -3,9 +3,13 @@ import React, { useState } from 'react'; import { Button, + Listbox, + ListboxAction, + ListboxIcon, + ListboxItem, + ListboxText, PopoverProvider, ToggleButton, - TooltipLinkList, } from 'storybook/internal/components'; import { CloseIcon, CogIcon } from '@storybook/icons'; @@ -87,7 +91,39 @@ const SidebarMenuList: FC<{ menu: MenuList; onClick: () => void; }> = ({ menu, onClick }) => { - return ; + return ( +
+ {menu + .filter((links) => links.length) + .flatMap((links) => ( + link.id).join('_')}> + {links.map((link) => ( + + + link.onClick?.(e, { + id: link.id, + active: link.active, + disabled: link.disabled, + title: link.title, + href: link.href, + }) + } + > + {(link.icon || link.input) && ( + {link.icon || link.input} + )} + {(link.title || link.center) && ( + {link.title || link.center} + )} + {link.right} + + + ))} + + ))} +
+ ); }; export interface SidebarMenuProps { @@ -108,7 +144,6 @@ export const SidebarMenu: FC = ({ menu, isHighlighted, onClick variant="ghost" ariaLabel="About Storybook" highlighted={!!isHighlighted} - // @ts-expect-error (non strict) onClick={onClick} isMobile={true} > diff --git a/code/core/src/manager/components/sidebar/RefBlocks.tsx b/code/core/src/manager/components/sidebar/RefBlocks.tsx index 4dc9c9344f21..e908249eb068 100644 --- a/code/core/src/manager/components/sidebar/RefBlocks.tsx +++ b/code/core/src/manager/components/sidebar/RefBlocks.tsx @@ -106,7 +106,6 @@ export const AuthBlock: FC<{ loginUrl: string; id: string }> = ({ loginUrl, id } Sign in to browse this Storybook.
- {/* @ts-expect-error (non strict) */} diff --git a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx index 274c67add214..fb13b24c2811 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx @@ -2,13 +2,15 @@ import React from 'react'; import type { DecoratorFunction, StatusesByStoryIdAndTypeId } from 'storybook/internal/types'; +import { global } from '@storybook/global'; + import type { Meta, StoryObj } from '@storybook/react-vite'; import type { IndexHash } from 'storybook/manager-api'; import { ManagerContext } from 'storybook/manager-api'; import { expect, fn, userEvent, within } from 'storybook/test'; -import { internal_fullStatusStore } from '../../manager-stores.mock'; +import { internal_fullStatusStore, universalChecklistStore } from '../../manager-stores.mock'; import { LayoutProvider } from '../layout/LayoutProvider'; import { standardData as standardHeaderData } from './Heading.stories'; import { IconSymbols } from './IconSymbols'; @@ -108,6 +110,13 @@ const meta = { globals: { sb_theme: 'side-by-side' }, beforeEach: () => { internal_fullStatusStore.unset(); + universalChecklistStore.setState({ + loaded: true, + muted: false, + accepted: ['controls'], + done: ['add-component'], + skipped: ['viewports'], + }); }, } satisfies Meta; @@ -168,6 +177,13 @@ export const SimpleInProduction: Story = { args: { showCreateStoryButton: false, }, + beforeEach: () => { + const configType = global.CONFIG_TYPE; + global.CONFIG_TYPE = 'PRODUCTION'; + return () => { + global.CONFIG_TYPE = configType; + }; + }, }; export const Mobile: Story = { diff --git a/code/core/src/manager/components/sidebar/Sidebar.tsx b/code/core/src/manager/components/sidebar/Sidebar.tsx index 777e12b75328..3e6d706b6cf9 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState } from 'react'; -import { Button, ScrollArea, Spaced } from 'storybook/internal/components'; +import { Button, ScrollArea } from 'storybook/internal/components'; import type { API_LoadedRefData, StoryIndex, TagsOptions } from 'storybook/internal/types'; import type { StatusesByStoryIdAndTypeId } from 'storybook/internal/types'; @@ -12,6 +12,7 @@ import { styled } from 'storybook/theming'; import { MEDIA_DESKTOP_BREAKPOINT } from '../../constants'; import { useLayout } from '../layout/LayoutProvider'; +import { ChecklistModule } from './ChecklistModule'; import { CreateNewStoryFileModal } from './CreateNewStoryFileModal'; import { Explorer } from './Explorer'; import type { HeadingProps } from './Heading'; @@ -43,12 +44,11 @@ const Container = styled.nav(({ theme }) => ({ }, })); -const Top = styled(Spaced)({ - paddingLeft: 12, - paddingRight: 12, - paddingBottom: 20, - paddingTop: 16, - flex: 1, +const Stack = styled.div({ + display: 'flex', + flexDirection: 'column', + gap: 16, + padding: '16px 12px 20px 12px', }); const CreateNewStoryButton = styled(Button)<{ isMobile: boolean }>(({ theme, isMobile }) => ({ @@ -156,15 +156,18 @@ export const Sidebar = React.memo(function Sidebar({ return ( - - + +
+ + {!isLoading && global.CONFIG_TYPE === 'DEVELOPMENT' && } +
)} -
+ {isMobile || isLoading ? null : }
diff --git a/code/core/src/manager/components/sidebar/TestingModule.tsx b/code/core/src/manager/components/sidebar/TestingModule.tsx index 77bcc957f9e5..d85e40f7e7d7 100644 --- a/code/core/src/manager/components/sidebar/TestingModule.tsx +++ b/code/core/src/manager/components/sidebar/TestingModule.tsx @@ -17,18 +17,18 @@ import { useDynamicFavicon } from './useDynamicFavicon'; const DEFAULT_HEIGHT = 500; -const HoverCard = styled(Card)({ +const HoverCard = styled(Card)(({ theme }) => ({ display: 'flex', flexDirection: 'column-reverse', + '--card-box-shadow': `0 1px 2px 0 rgba(0, 0, 0, 0.05), 0px -5px 20px 10px ${theme.background.app}`, '&:hover #testing-module-collapse-toggle': { opacity: 1, }, -}); +})); const Collapsible = styled.div(({ theme }) => ({ overflow: 'hidden', - willChange: 'auto', boxShadow: `inset 0 -1px 0 ${theme.appBorderColor}`, })); @@ -64,7 +64,6 @@ const Filters = styled.div({ const CollapseToggle = styled(Button)({ opacity: 0, transition: 'opacity 250ms', - willChange: 'auto', '&:focus, &:hover': { opacity: 1, }, @@ -270,7 +269,6 @@ export const TestingModule = ({ style={{ transform: isCollapsed ? 'none' : 'rotate(180deg)', transition: 'transform 250ms', - willChange: 'auto', }} /> diff --git a/code/core/src/manager/components/sidebar/Tree.tsx b/code/core/src/manager/components/sidebar/Tree.tsx index 997e58455d31..a011c18b8312 100644 --- a/code/core/src/manager/components/sidebar/Tree.tsx +++ b/code/core/src/manager/components/sidebar/Tree.tsx @@ -55,11 +55,6 @@ import { useExpanded } from './useExpanded'; export type ExcludesNull = (x: T | null) => x is T; -const Container = styled.div<{ hasOrphans: boolean }>((props) => ({ - marginTop: props.hasOrphans ? 20 : 0, - marginBottom: 20, -})); - const CollapseButton = styled(Button)(({ theme }) => ({ fontSize: `${theme.typography.size.s1 - 1}px`, fontWeight: theme.typography.weight.bold, @@ -508,7 +503,6 @@ export const Tree = React.memo<{ onSelectStoryId: (storyId: string) => void; }>(function Tree({ isBrowsing, - isMain, refId, data, allStatuses, @@ -717,10 +711,10 @@ export const Tree = React.memo<{ ]); return ( - 0}> +
{treeItems} - +
); }); diff --git a/code/core/src/manager/components/sidebar/TreeNode.tsx b/code/core/src/manager/components/sidebar/TreeNode.tsx index ac9465eb4a72..31f31a9d5dd1 100644 --- a/code/core/src/manager/components/sidebar/TreeNode.tsx +++ b/code/core/src/manager/components/sidebar/TreeNode.tsx @@ -1,7 +1,7 @@ import type { ComponentProps, FC } from 'react'; import React from 'react'; -import { type FunctionInterpolation, type Theme, styled } from 'storybook/theming'; +import { type FunctionInterpolation, styled } from 'storybook/theming'; import { UseSymbol } from './IconSymbols'; import { CollapseIcon } from './components/CollapseIcon'; @@ -74,13 +74,17 @@ const BranchNode = styled.button<{ const LeafNode = styled.a<{ depth?: number }>(commonNodeStyles); -export const RootNode = styled.div(({ theme }) => ({ +export const RootNode = styled.div({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 16, marginBottom: 4, -})); + + '&:first-of-type': { + marginTop: 0, + }, +}); const Wrapper = styled.div({ display: 'flex', diff --git a/code/core/src/manager/components/sidebar/useChecklist.ts b/code/core/src/manager/components/sidebar/useChecklist.ts new file mode 100644 index 000000000000..cb9e47b2afb9 --- /dev/null +++ b/code/core/src/manager/components/sidebar/useChecklist.ts @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useMemo } from 'react'; + +import { checklistStore, universalChecklistStore } from '#manager-stores'; +import { + experimental_useUniversalStore, + useStorybookApi, + useStorybookState, +} from 'storybook/manager-api'; + +import type { ChecklistData } from '../../settings/Checklist/checklistData'; +import { checklistData } from '../../settings/Checklist/checklistData'; + +type ChecklistItem = ChecklistData['sections'][number]['items'][number]; + +const subscriptions = new Map void)>(); + +export const useChecklist = () => { + const api = useStorybookApi(); + const { index } = useStorybookState(); + const [checklistState] = experimental_useUniversalStore(universalChecklistStore); + const { loaded, muted, accepted, done, skipped } = checklistState; + + const isOpen = useCallback( + ({ id }: ChecklistItem) => + !accepted.includes(id) && !done.includes(id) && !skipped.includes(id), + [accepted, done, skipped] + ); + + const isReady = useCallback( + (item: ChecklistItem): boolean => + isOpen(item) && + !(Array.isArray(muted) && muted.includes(item.id)) && + !item.after?.some((id) => !accepted.includes(id) && !done.includes(id)), + [isOpen, accepted, done, muted] + ); + + const allItems = useMemo( + () => checklistData.sections.flatMap(({ items }) => items), + [] + ); + + useEffect(() => { + if (!index || !loaded) { + return; + } + + for (const item of allItems) { + if (!item.subscribe) { + continue; + } + + const open = isOpen(item); + const subscribed = subscriptions.has(item.id); + if (open && !subscribed) { + subscriptions.set( + item.id, + item.subscribe({ + api, + index, + item, + done: () => checklistStore.done(item.id), + skip: () => checklistStore.skip(item.id), + }) + ); + } else if (subscribed && !open) { + subscriptions.get(item.id)?.(); + subscriptions.delete(item.id); + } + } + }, [api, index, loaded, allItems, isOpen]); + + const { openItems, nextItems, progress } = useMemo(() => { + const openItems = allItems.filter(isOpen); + const progress = Math.round(((allItems.length - openItems.length) / allItems.length) * 100); + + // Collect a list of the next 3 tasks that are ready. + // Tasks are pulled from each section in a round-robin fashion, + // so that users can choose their own adventure. + const nextItems = checklistData.sections + .flatMap(({ items }, sectionIndex) => + items.filter(isReady).map((item, itemIndex) => ({ item, itemIndex, sectionIndex })) + ) + .sort((a, b) => a.itemIndex - b.itemIndex) + .slice(0, 3) + .sort((a, b) => a.sectionIndex - b.sectionIndex) + .flatMap(({ item }) => (item ? [item] : [])); + + return { openItems, nextItems, progress }; + }, [allItems, isOpen, isReady]); + + return { + ...checklistData, + ...checklistStore, + ...checklistState, + allItems, + nextItems, + openItems, + progress, + }; +}; diff --git a/code/core/src/manager/container/Menu.stories.tsx b/code/core/src/manager/container/Menu.stories.tsx index def579e9fc0f..88550700480b 100644 --- a/code/core/src/manager/container/Menu.stories.tsx +++ b/code/core/src/manager/container/Menu.stories.tsx @@ -6,6 +6,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { action } from 'storybook/actions'; +import { universalChecklistStore as mockStore } from '../manager-stores.mock'; import { Shortcut } from './Menu'; const onLinkClick = action('onLinkClick'); @@ -25,6 +26,15 @@ export default {
), ], + beforeEach: async () => { + mockStore.setState({ + loaded: true, + muted: false, + accepted: ['controls'], + done: ['add-component'], + skipped: ['viewports'], + }); + }, excludeStories: ['links'], } satisfies Meta; diff --git a/code/core/src/manager/container/Menu.tsx b/code/core/src/manager/container/Menu.tsx index e51f35bb1c9b..db39704b01a9 100644 --- a/code/core/src/manager/container/Menu.tsx +++ b/code/core/src/manager/container/Menu.tsx @@ -1,35 +1,39 @@ import type { FC } from 'react'; import React, { useCallback, useMemo } from 'react'; -import { Badge } from 'storybook/internal/components'; import { STORIES_COLLAPSE_ALL } from 'storybook/internal/core-events'; -import { CheckIcon, CommandIcon, InfoIcon, ShareAltIcon, WandIcon } from '@storybook/icons'; +import { global } from '@storybook/global'; +import { + CheckIcon, + CommandIcon, + DocumentIcon, + InfoIcon, + ListUnorderedIcon, + ShareAltIcon, +} from '@storybook/icons'; -import type { API, State } from 'storybook/manager-api'; +import type { API } from 'storybook/manager-api'; import { shortcutToHumanString } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; -import type { Link } from '../../components/components/tooltip/TooltipLinkList'; - -const focusableUIElements = { - storySearchField: 'storybook-explorer-searchfield', - storyListMenu: 'storybook-explorer-menu', - storyPanelRoot: 'storybook-panel-root', -}; +import { ListboxButton, ListboxIcon, ProgressSpinner } from '../../components'; +import type { NormalLink } from '../../components/components/tooltip/TooltipLinkList'; +import { useChecklist } from '../components/sidebar/useChecklist'; const Key = styled.span(({ theme }) => ({ - display: 'inline-block', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', height: 16, - lineHeight: '16px', - textAlign: 'center', fontSize: '11px', + fontWeight: theme.typography.weight.regular, background: theme.base === 'light' ? 'rgba(0,0,0,0.05)' : 'rgba(255,255,255,0.05)', color: theme.base === 'light' ? theme.color.dark : theme.textMutedColor, borderRadius: 2, userSelect: 'none', pointerEvents: 'none', - padding: '0 6px', + padding: '0 4px', })); const KeyChild = styled.code(({ theme }) => ({ @@ -41,6 +45,10 @@ const KeyChild = styled.code(({ theme }) => ({ }, })); +const ProgressCircle = styled(ProgressSpinner)(({ theme }) => ({ + color: theme.color.secondary, +})); + export const Shortcut: FC<{ keys: string[] }> = ({ keys }) => ( {keys.map((key) => ( @@ -49,16 +57,21 @@ export const Shortcut: FC<{ keys: string[] }> = ({ keys }) => ( ); -export const useMenu = ( - state: State, - api: API, - showToolbar: boolean, - isFullscreen: boolean, - isPanelShown: boolean, - isNavShown: boolean, - enableShortcuts: boolean -): Link[][] => { +export const useMenu = ({ + api, + showToolbar, + isPanelShown, + isNavShown, + enableShortcuts, +}: { + api: API; + showToolbar: boolean; + isPanelShown: boolean; + isNavShown: boolean; + enableShortcuts: boolean; +}): NormalLink[][] => { const shortcutKeys = api.getShortcutKeys(); + const { progress } = useChecklist(); const about = useMemo( () => ({ @@ -70,32 +83,20 @@ export const useMenu = ( [api] ); - const documentation = useMemo(() => { - const docsUrl = api.getDocsUrl({ versioned: true, renderer: true }); - - return { - id: 'documentation', - title: 'Documentation', - href: docsUrl, - icon: , - }; - }, [api]); - - const whatsNewNotificationsEnabled = - state.whatsNewData?.status === 'SUCCESS' && !state.disableWhatsNewNotifications; - const isWhatsNewUnread = api.isWhatsNewUnread(); - - const whatsNew = useMemo( + const guide = useMemo( () => ({ - id: 'whats-new', - title: "What's new?", - onClick: () => api.changeSettingsTab('whats-new'), - right: whatsNewNotificationsEnabled && isWhatsNewUnread && ( - Check it out + id: 'guide', + title: 'Onboarding guide', + onClick: () => api.changeSettingsTab('guide'), + icon: , + right: progress < 100 && ( + + + {progress}% + ), - icon: , }), - [api, whatsNewNotificationsEnabled, isWhatsNewUnread] + [api, progress] ); const shortcuts = useMemo( @@ -116,7 +117,7 @@ export const useMenu = ( onClick: () => api.toggleNav(), active: isNavShown, right: enableShortcuts ? : null, - icon: isNavShown ? : null, + icon: isNavShown ? : <>, }), [api, enableShortcuts, shortcutKeys, isNavShown] ); @@ -128,7 +129,7 @@ export const useMenu = ( onClick: () => api.toggleToolbar(), active: showToolbar, right: enableShortcuts ? : null, - icon: showToolbar ? : null, + icon: showToolbar ? : <>, }), [api, enableShortcuts, shortcutKeys, showToolbar] ); @@ -140,49 +141,18 @@ export const useMenu = ( onClick: () => api.togglePanel(), active: isPanelShown, right: enableShortcuts ? : null, - icon: isPanelShown ? : null, + icon: isPanelShown ? : <>, }), [api, enableShortcuts, shortcutKeys, isPanelShown] ); - const addonsOrientationToggle = useMemo( - () => ({ - id: 'D', - title: 'Change addons orientation', - onClick: () => api.togglePanelPosition(), - right: enableShortcuts ? : null, - }), - [api, enableShortcuts, shortcutKeys] - ); - - const fullscreenToggle = useMemo( - () => ({ - id: 'F', - title: 'Go full screen', - onClick: () => api.toggleFullscreen(), - active: isFullscreen, - right: enableShortcuts ? : null, - icon: isFullscreen ? : null, - }), - [api, enableShortcuts, shortcutKeys, isFullscreen] - ); - - const searchToggle = useMemo( - () => ({ - id: '/', - title: 'Search', - onClick: () => api.focusOnUIElement(focusableUIElements.storySearchField), - right: enableShortcuts ? : null, - }), - [api, enableShortcuts, shortcutKeys] - ); - const up = useMemo( () => ({ id: 'up', title: 'Previous component', onClick: () => api.jumpToComponent(-1), right: enableShortcuts ? : null, + icon: <>, }), [api, enableShortcuts, shortcutKeys] ); @@ -193,6 +163,7 @@ export const useMenu = ( title: 'Next component', onClick: () => api.jumpToComponent(1), right: enableShortcuts ? : null, + icon: <>, }), [api, enableShortcuts, shortcutKeys] ); @@ -203,6 +174,7 @@ export const useMenu = ( title: 'Previous story', onClick: () => api.jumpToStory(-1), right: enableShortcuts ? : null, + icon: <>, }), [api, enableShortcuts, shortcutKeys] ); @@ -213,6 +185,7 @@ export const useMenu = ( title: 'Next story', onClick: () => api.jumpToStory(1), right: enableShortcuts ? : null, + icon: <>, }), [api, enableShortcuts, shortcutKeys] ); @@ -223,10 +196,27 @@ export const useMenu = ( title: 'Collapse all', onClick: () => api.emit(STORIES_COLLAPSE_ALL), right: enableShortcuts ? : null, + icon: <>, }), [api, enableShortcuts, shortcutKeys] ); + const documentation = useMemo(() => { + const docsUrl = api.getDocsUrl({ versioned: true, renderer: true }); + + return { + id: 'documentation', + title: 'Documentation', + href: docsUrl, + right: ( + + + + ), + icon: , + }; + }, [api]); + const getAddonsShortcuts = useCallback(() => { const addonsShortcuts = api.getAddonsShortcuts(); const keys = shortcutKeys as any; @@ -245,37 +235,21 @@ export const useMenu = ( [ [ about, - ...(state.whatsNewData?.status === 'SUCCESS' ? [whatsNew] : []), - documentation, + ...(global.CONFIG_TYPE === 'DEVELOPMENT' ? [guide] : []), ...(enableShortcuts ? [shortcuts] : []), ], - [ - sidebarToggle, - toolbarToogle, - addonsToggle, - addonsOrientationToggle, - fullscreenToggle, - searchToggle, - up, - down, - prev, - next, - collapse, - ], + [sidebarToggle, toolbarToogle, addonsToggle, up, down, prev, next, collapse], getAddonsShortcuts(), - ] satisfies Link[][], + [documentation], + ] satisfies NormalLink[][], [ about, - state, - whatsNew, + guide, documentation, shortcuts, sidebarToggle, toolbarToogle, addonsToggle, - addonsOrientationToggle, - fullscreenToggle, - searchToggle, up, down, prev, diff --git a/code/core/src/manager/container/Sidebar.tsx b/code/core/src/manager/container/Sidebar.tsx index c775c9d11a21..7f664c4e0855 100755 --- a/code/core/src/manager/container/Sidebar.tsx +++ b/code/core/src/manager/container/Sidebar.tsx @@ -30,20 +30,11 @@ const Sidebar = React.memo(function Sideber({ onMenuClick }: SidebarProps) { refs, } = state; - const menu = useMenu( - state, - api, - showToolbar, - api.getIsFullscreen(), - api.getIsPanelShown(), - api.getIsNavShown(), - enableShortcuts - ); - const whatsNewNotificationsEnabled = state.whatsNewData?.status === 'SUCCESS' && !state.disableWhatsNewNotifications; return { + api, title: name, url, indexJson: internal_index, @@ -54,7 +45,9 @@ const Sidebar = React.memo(function Sideber({ onMenuClick }: SidebarProps) { storyId, refId, viewMode, - menu, + showToolbar, + isPanelShown: api.getIsPanelShown(), + isNavShown: api.getIsNavShown(), menuHighlighted: whatsNewNotificationsEnabled && api.isWhatsNewUnread(), enableShortcuts, }; @@ -62,11 +55,18 @@ const Sidebar = React.memo(function Sideber({ onMenuClick }: SidebarProps) { return ( - {(fromState) => { + {({ api, showToolbar, isPanelShown, isNavShown, enableShortcuts, ...state }) => { + const menu = useMenu({ api, showToolbar, isPanelShown, isNavShown, enableShortcuts }); const allStatuses = experimental_useStatusStore(); return ( - + ); }} diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index 3bdddf7eac19..bddac3eecaa5 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -499,7 +499,10 @@ export default { 'EmptyTabContent', 'ErrorFormatter', 'FlexBar', + 'FocusOutline', 'FocusProxy', + 'FocusRing', + 'FocusTarget', 'Form', 'H1', 'H2', @@ -516,6 +519,7 @@ export default { 'Listbox', 'ListboxAction', 'ListboxButton', + 'ListboxIcon', 'ListboxItem', 'ListboxText', 'Loader', @@ -570,6 +574,7 @@ export default { 'interleaveSeparators', 'nameSpaceClassNames', 'resetComponents', + 'useLocationHash', 'useTabsState', 'withReset', ], diff --git a/code/core/src/manager/manager-stores.mock.ts b/code/core/src/manager/manager-stores.mock.ts index a892d976b9d6..43344ece0f18 100644 --- a/code/core/src/manager/manager-stores.mock.ts +++ b/code/core/src/manager/manager-stores.mock.ts @@ -4,8 +4,8 @@ import { } from 'storybook/manager-api'; import * as testUtils from 'storybook/test'; -import type { StoreEvent, StoreState } from '../shared/checklist-store'; -import { UNIVERSAL_CHECKLIST_STORE_OPTIONS, createChecklistStore } from '../shared/checklist-store'; +import type { ChecklistStore, StoreEvent, StoreState, TaskId } from '../shared/checklist-store'; +import { UNIVERSAL_CHECKLIST_STORE_OPTIONS } from '../shared/checklist-store'; import { type StatusStoreEvent, type StatusesByStoryIdAndTypeId, @@ -54,4 +54,43 @@ export const universalChecklistStore = new experimental_MockUniversalStore; -export const checklistStore = createChecklistStore(universalChecklistStore); +export const checklistStore: ChecklistStore = { + accept: (id: TaskId) => { + universalChecklistStore.setState((state) => ({ + ...state, + accepted: state.accepted.includes(id) ? state.accepted : [...state.accepted, id], + skipped: state.skipped.filter((v) => v !== id), + })); + }, + done: (id: TaskId) => { + universalChecklistStore.setState((state) => ({ + ...state, + done: state.done.includes(id) ? state.done : [...state.done, id], + skipped: state.skipped.filter((v) => v !== id), + })); + }, + skip: (id: TaskId) => { + universalChecklistStore.setState((state) => ({ + ...state, + accepted: state.accepted.filter((v) => v !== id), + skipped: state.skipped.includes(id) ? state.skipped : [...state.skipped, id], + })); + }, + reset: (id: TaskId) => { + universalChecklistStore.setState((state) => ({ + ...state, + accepted: state.accepted.filter((v) => v !== id), + skipped: state.skipped.filter((v) => v !== id), + })); + }, + mute: (value: boolean | Array) => { + universalChecklistStore.setState((state) => ({ + ...state, + muted: Array.isArray(value) + ? Array.from( + new Set([...(Array.isArray(state.muted) ? state.muted : []), ...(value || [])]) + ) + : value, + })); + }, +}; diff --git a/code/core/src/manager/manager-stores.ts b/code/core/src/manager/manager-stores.ts index 1845cc693924..4d1ff0a75444 100644 --- a/code/core/src/manager/manager-stores.ts +++ b/code/core/src/manager/manager-stores.ts @@ -1,12 +1,12 @@ import { experimental_UniversalStore } from 'storybook/manager-api'; +import type { ChecklistStore } from '../shared/checklist-store'; import { type StoreEvent, type StoreState, + type TaskId, UNIVERSAL_CHECKLIST_STORE_OPTIONS, - createChecklistStore, } from '../shared/checklist-store'; -import type { UniversalStore } from '../shared/universal-store'; export { internal_fullStatusStore, @@ -20,6 +20,45 @@ export { export const universalChecklistStore = experimental_UniversalStore.create({ ...UNIVERSAL_CHECKLIST_STORE_OPTIONS, leader: globalThis.CONFIG_TYPE === 'PRODUCTION', -}) as UniversalStore; +}); -export const checklistStore = createChecklistStore(universalChecklistStore); +export const checklistStore: ChecklistStore = { + accept: (id: TaskId) => { + universalChecklistStore.setState((state) => ({ + ...state, + accepted: state.accepted.includes(id) ? state.accepted : [...state.accepted, id], + skipped: state.skipped.filter((v) => v !== id), + })); + }, + done: (id: TaskId) => { + universalChecklistStore.setState((state) => ({ + ...state, + done: state.done.includes(id) ? state.done : [...state.done, id], + skipped: state.skipped.filter((v) => v !== id), + })); + }, + skip: (id: TaskId) => { + universalChecklistStore.setState((state) => ({ + ...state, + accepted: state.accepted.filter((v) => v !== id), + skipped: state.skipped.includes(id) ? state.skipped : [...state.skipped, id], + })); + }, + reset: (id: TaskId) => { + universalChecklistStore.setState((state) => ({ + ...state, + accepted: state.accepted.filter((v) => v !== id), + skipped: state.skipped.filter((v) => v !== id), + })); + }, + mute: (value: boolean | Array) => { + universalChecklistStore.setState((state) => ({ + ...state, + muted: Array.isArray(value) + ? Array.from( + new Set([...(Array.isArray(state.muted) ? state.muted : []), ...(value || [])]) + ) + : value, + })); + }, +}; diff --git a/code/core/src/manager/settings/Checklist/Checklist.stories.tsx b/code/core/src/manager/settings/Checklist/Checklist.stories.tsx index 59e06a8e9a4c..37185e62804d 100644 --- a/code/core/src/manager/settings/Checklist/Checklist.stories.tsx +++ b/code/core/src/manager/settings/Checklist/Checklist.stories.tsx @@ -1,11 +1,11 @@ import React from 'react'; +import { checklistStore } from '#manager-stores'; import { ManagerContext } from 'storybook/manager-api'; import { fn } from 'storybook/test'; import { styled } from 'storybook/theming'; import preview from '../../../../../.storybook/preview'; -import { universalChecklistStore as mockStore } from '../../manager-stores.mock'; import { Checklist } from './Checklist'; import { checklistData } from './checklistData'; @@ -31,16 +31,14 @@ const meta = preview.meta({ ), ], - beforeEach: async () => { - mockStore.setState({ - completed: ['add-component'], - skipped: ['add-5-10-components'], - }); - }, }); export const Default = meta.story({ args: { - data: checklistData, + ...checklistData, + ...checklistStore, + accepted: ['controls'], + done: ['add-component'], + skipped: ['viewports'], }, }); diff --git a/code/core/src/manager/settings/Checklist/Checklist.tsx b/code/core/src/manager/settings/Checklist/Checklist.tsx index 91ca13f6b689..077b0fd761ad 100644 --- a/code/core/src/manager/settings/Checklist/Checklist.tsx +++ b/code/core/src/manager/settings/Checklist/Checklist.tsx @@ -1,35 +1,42 @@ -import React, { createRef, useEffect, useState } from 'react'; - -import { Button, Collapsible, FocusProxy, ListboxItem } from 'storybook/internal/components'; - -import { CheckIcon, ChevronSmallDownIcon, StatusPassIcon, UndoIcon } from '@storybook/icons'; - -import { checklistStore, universalChecklistStore } from '#manager-stores'; -import { type API, experimental_useUniversalStore, useStorybookApi } from 'storybook/manager-api'; +import React, { createRef, useCallback, useMemo } from 'react'; + +import { + Button, + Collapsible, + FocusProxy, + FocusTarget, + ListboxItem, + TooltipNote, + WithTooltip, + useLocationHash, +} from 'storybook/internal/components'; + +import { + CheckIcon, + ChevronSmallDownIcon, + LockIcon, + StatusPassIcon, + UndoIcon, +} from '@storybook/icons'; + +import { useStorybookApi } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; -export interface ChecklistData { - sections: { - id: string; - title: string; - items: { - id: string; - label: string; - start?: (args: { api: API }) => void; - predicate?: (args: { api: API; complete: () => void }) => void; - content?: React.ReactNode; - nodeRef?: React.RefObject; - }[]; - }[]; -} -export interface ChecklistState { - next: number; +import type { useChecklist } from '../../components/sidebar/useChecklist'; +import type { ChecklistData } from './checklistData'; + +type ChecklistSection = Omit & { + itemIds: string[]; progress: number; - sections: (ChecklistData['sections'][number] & { - open: boolean; - progress: number; - })[]; -} +}; + +type ChecklistItem = ChecklistData['sections'][number]['items'][number] & { + isAccepted: boolean; // was it manually accepted by the user? + isDone: boolean; // was it automatically completed by the system? + isLockedBy: string[]; // items that must be completed before this item can be completed + isSkipped: boolean; // was it skipped by the user? + nodeRef?: React.RefObject; +}; const Sections = styled.ol(({ theme }) => ({ listStyle: 'none', @@ -112,46 +119,52 @@ const SectionHeading = styled.h3({ cursor: 'default', }); -const ItemSummary = styled.div<{ isCollapsed: boolean }>(({ isCollapsed }) => ({ - fontWeight: 'normal', - display: 'flex', - alignItems: 'center', - gap: 10, - padding: isCollapsed ? '6px 10px 6px 15px' : '10px 10px 10px 15px', - transition: 'padding var(--transition-duration, 0.2s)', - '--toggle-button-rotate': isCollapsed ? '0deg' : '180deg', +const ItemSummary = styled.div<{ isCollapsed: boolean; onClick?: () => void }>( + ({ theme, isCollapsed, onClick }) => ({ + fontWeight: theme.typography.weight.regular, + fontSize: theme.typography.size.s2, + display: 'flex', + alignItems: 'center', + minHeight: 40, + gap: 10, + padding: isCollapsed ? '6px 10px 6px 15px' : '10px 10px 10px 15px', + transition: 'padding var(--transition-duration, 0.2s)', + cursor: onClick ? 'pointer' : 'default', + '--toggle-button-rotate': isCollapsed ? '0deg' : '180deg', - '&:focus-visible': { - outline: 'none', - }, + '&:focus-visible': { + outline: 'none', + }, - h4: { - flex: 1, - margin: 0, - fontSize: 'inherit', - }, -})); + h4: { + flex: 1, + margin: 0, + fontSize: 'inherit', + }, + }) +); const ItemHeading = styled.h4<{ skipped: boolean }>(({ theme, skipped }) => ({ color: skipped ? theme.color.mediumdark : theme.color.defaultText, - cursor: 'default', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', + fontSize: theme.typography.size.s2, })); -const ItemContent = styled.div({ +const ItemContent = styled.div(({ theme }) => ({ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 8, padding: '0 15px 15px 15px', + fontSize: theme.typography.size.s2, p: { margin: 0, lineHeight: 1.4, }, -}); +})); const StatusIcon = styled.div(({ theme }) => ({ position: 'relative', @@ -164,23 +177,25 @@ const StatusIcon = styled.div(({ theme }) => ({ outline: `1px solid ${theme.color.border}`, outlineOffset: -1, })); -const Checked = styled(StatusPassIcon)<{ visible: boolean }>(({ theme, visible }) => ({ - position: 'absolute', - width: 'inherit', - height: 'inherit', - top: 0, - left: 0, - bottom: 0, - right: 0, - padding: 1, - borderRadius: '50%', - background: theme.color.positive, - color: theme.background.content, - opacity: visible ? 1 : 0, - transform: visible ? 'scale(1)' : 'scale(0.7)', - transition: 'all var(--transition-duration, 0.2s)', -})); -const Skipped = styled.span<{ visible: boolean }>(({ theme, visible }) => ({ +const Checked = styled(StatusPassIcon)<{ 'data-visible'?: boolean }>( + ({ theme, 'data-visible': visible }) => ({ + position: 'absolute', + width: 'inherit', + height: 'inherit', + top: 0, + left: 0, + bottom: 0, + right: 0, + padding: 1, + borderRadius: '50%', + background: theme.color.positive, + color: theme.background.content, + opacity: visible ? 1 : 0, + transform: visible ? 'scale(1)' : 'scale(0.7)', + transition: 'all var(--transition-duration, 0.2s)', + }) +); +const Skipped = styled.span<{ visible?: boolean }>(({ theme, visible }) => ({ display: 'flex', alignItems: 'center', color: theme.textMutedColor, @@ -215,74 +230,72 @@ const ToggleButton = styled(Button)({ }, }); -export const Checklist = ({ data }: { data: ChecklistData }) => { +export const Checklist = ({ + sections, + accepted, + done, + skipped, + accept, + skip, + reset, +}: Pick< + ReturnType, + 'sections' | 'accepted' | 'done' | 'skipped' | 'accept' | 'skip' | 'reset' +>) => { const api = useStorybookApi(); - const [checklistState] = experimental_useUniversalStore(universalChecklistStore); - const { completed, skipped } = checklistState; - const [hash, setHash] = useState(globalThis.window.location.hash ?? ''); - - useEffect(() => { - const updateHash = () => setHash(globalThis.window.location.hash ?? ''); - const interval = setInterval(updateHash, 100); - return () => clearInterval(interval); - }, []); - - // universalChecklistStore.untilReady().then(() => checklistStore.complete('whats-new-sb-9')); - - // useEffect(() => { - // // const componentTestStatusStore = experimental_getStatusStore(STATUS_TYPE_ID_COMPONENT_TEST); - // // const a11yStatusStore = experimental_getStatusStore(STATUS_TYPE_ID_A11Y); - - // data.sections.forEach((section) => { - // section.items.forEach((item) => { - // const complete = () => setCompleted((completed) => new Set([...completed, item.id])); - // item.predicate?.({ api, complete }); - // }); - // }); - // }, [data, api]); - - const state: ChecklistState = { - next: 0, - progress: 0, - sections: data.sections.map((section) => ({ - ...section, - items: section.items.map((item) => ({ ...item, nodeRef: createRef() })), - open: false, - progress: - (section.items.reduce( - (a, b) => (completed.includes(b.id) || skipped.includes(b.id) ? a + 1 : a), - 0 - ) / - section.items.length) * - 100, - })), - }; - - const next = state.sections.findIndex(({ progress }) => progress < 100); - - const targetHash = hash.slice(1); - - // Focus the target element when the hash changes - useEffect(() => { - const timeout = setTimeout(() => { - const target = - targetHash && globalThis.document.querySelector(`[for="toggle-${targetHash}"]`); - if (target instanceof HTMLElement) { - target.focus({ preventScroll: true }); - target.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - }, 200); - return () => clearTimeout(timeout); - }, [targetHash]); + const locationHash = useLocationHash(); + + const isCompleted = useCallback( + (id: string) => accepted.includes(id) || done.includes(id) || skipped.includes(id), + [accepted, done, skipped] + ); + + const sectionsById: Record = useMemo( + () => + Object.fromEntries( + sections.map(({ items, ...section }) => { + const progress = + (items.reduce((a, b) => (isCompleted(b.id) ? a + 1 : a), 0) / items.length) * 100; + const itemIds = items.map(({ id }) => id); + return [section.id, { ...section, itemIds, progress }]; + }) + ), + [sections, isCompleted] + ); + + const itemsById: Record = useMemo( + () => + Object.fromEntries( + sections.flatMap(({ items }) => + items.map((item) => { + const isAccepted = accepted.includes(item.id); + const isDone = done.includes(item.id); + const isLockedBy = + item.after?.filter((id) => !accepted.includes(id) && !done.includes(id)) ?? []; + const isSkipped = skipped.includes(item.id); + const nodeRef = createRef(); + return [item.id, { ...item, isAccepted, isDone, isLockedBy, isSkipped, nodeRef }]; + }) + ) + ), + [sections, accepted, done, skipped] + ); + + const next = useMemo( + () => Object.values(sections).findIndex(({ items }) => items.some((it) => !isCompleted(it.id))), + [sections, isCompleted] + ); return ( - {state.sections.map(({ id, title, items, progress }, index) => { - const hasTarget = items.some(({ id }) => id === targetHash); + {sections.map(({ id }, index) => { + const { title, itemIds, progress } = sectionsById[id]; + const hasTarget = itemIds.some((id) => id === locationHash); const collapsed = !hasTarget && (progress === 0 || progress === 100) && next !== index; + return (
  • - + ( @@ -292,7 +305,7 @@ export const Checklist = ({ data }: { data: ChecklistData }) => { onClick={toggleCollapsed} > - + {title} @@ -310,92 +323,125 @@ export const Checklist = ({ data }: { data: ChecklistData }) => { )} > - {items.map((item) => { - const isCompleted = progress === 100 || completed.includes(item.id); - const isCollapsed = item.id !== targetHash && isCompleted; - const isSkipped = skipped.includes(item.id); + {itemIds.map((itemId) => { + const { isAccepted, isDone, isLockedBy, isSkipped, ...item } = + itemsById[itemId]; + + const isChecked = isAccepted || isDone; + const isCompleted = isAccepted || isDone || isSkipped; + const isCollapsed = isChecked && itemId !== locationHash; + const isLocked = isLockedBy.length > 0; return ( - - + event.preventDefault()} // Prevent focus on click > - ( - - - - Skipped - - {item.label} - - {item.content && ( - - - - )} - {!isCompleted && !isSkipped && item.start && ( - - )} - {!isCompleted && !isSkipped && !item.start && !item.predicate && ( - - )} - {!isCompleted && !isSkipped && ( - - )} - {(isCompleted || isSkipped) && ( - - )} - - - )} - > - {item.content && {item.content}} - - + + ( + + + + Skipped + + {item.label} + + {item.content && ( + + + + )} + {isLocked && ( + `“${itemsById[id].label}”`).join(', ')} first`} + /> + } + > + + + )} + {!isCompleted && !isLocked && item.action && ( + + )} + {!isCompleted && + !isLocked && + !item.action && + !item.subscribe && ( + + )} + {!isCompleted && !isLocked && ( + + )} + {(isAccepted || isSkipped) && !isLocked && ( + + )} + + + )} + > + {item.content && {item.content}} + + + ); })} diff --git a/code/core/src/manager/settings/Checklist/checklistData.tsx b/code/core/src/manager/settings/Checklist/checklistData.tsx index e852d581d514..439aa2205001 100644 --- a/code/core/src/manager/settings/Checklist/checklistData.tsx +++ b/code/core/src/manager/settings/Checklist/checklistData.tsx @@ -1,8 +1,33 @@ import React from 'react'; import { Link } from 'storybook/internal/components'; +import type { API_IndexHash } from 'storybook/internal/types'; -import type { ChecklistData } from './Checklist'; +import { type API } from 'storybook/manager-api'; + +export interface ChecklistData { + sections: { + id: string; + title: string; + items: { + id: string; + label: string; + after?: string[]; + content?: React.ReactNode; + action?: { + label: string; + onClick: (args: { api: API; accept: () => void }) => void; + }; + subscribe?: (args: { + api: API; + index: API_IndexHash; + item: ChecklistData['sections'][number]['items'][number]; + done: () => void; + skip: () => void; + }) => void | (() => void); + }[]; + }[]; +} export const checklistData: ChecklistData = { sections: [ @@ -13,22 +38,65 @@ export const checklistData: ChecklistData = { { id: 'whats-new-sb-9', label: "See what's new", - start: ({ api }) => api.navigate('/settings/whats-new'), + action: { + label: 'Start', + onClick: ({ api }) => api.navigate('/settings/whats-new'), + }, }, { id: 'add-component', label: 'Add component', - predicate: ({ complete }) => complete(), + content: ( + <> +

    + A story captures the rendered state of a UI component. It's an object with + annotations that describe the component's behavior and appearance given a set of + arguments. +

    +

    + Storybook uses the generic term arguments (args for short) when talking about + React's props, Vue's props, Angular's @Input, and other similar concepts. +

    +

    + We define stories according to the Component Story Format (CSF), an ES6 module-based + standard that is easy to write and portable between tools. +

    + + ), + // subscribe: ({ done }) => done(), }, { id: 'add-5-10-components', + after: ['add-component'], label: 'Add 5-10 total components', - predicate: ({ complete }) => complete(), + content: ( + <> + A story is an object that describes how to render a component. You can have multiple + stories per component, and those stories can build upon one another. For example, we + can add Secondary and Tertiary stories based on our Primary story from above. + + ), + subscribe: ({ done }) => done(), }, { id: 'check-improve-coverage', + after: ['add-component'], label: 'Check + improve coverage', - predicate: ({ complete }) => setTimeout(complete, 3000), + content: ( + <> +

    + Test coverage is the practice of measuring whether existing tests fully cover your + code. It marks which conditions, logic branches, functions and variables in your + code are and are not being tested. +

    +

    + Coverage tests examine the instrumented code against a set of industry-accepted best + practices. They act as the last line of QA to improve the quality of your test + suite. +

    + + ), + subscribe: ({ done }) => setTimeout(done, 3000), }, ], }, @@ -38,8 +106,9 @@ export const checklistData: ChecklistData = { items: [ { id: 'run-tests', + after: ['add-component'], label: 'Run tests', - predicate: ({ complete }) => complete(), + subscribe: ({ done }) => done(), content: ( <>

    @@ -60,6 +129,7 @@ export const checklistData: ChecklistData = { }, { id: 'write-interactions', + after: ['add-component'], label: 'Write interactions', content: ( <> @@ -80,8 +150,9 @@ export const checklistData: ChecklistData = { }, { id: 'accessibility-tests', + after: ['add-component'], label: 'Accessibility tests', - predicate: ({ complete }) => complete(), + subscribe: ({ done }) => done(), content: ( <>

    @@ -102,8 +173,9 @@ export const checklistData: ChecklistData = { }, { id: 'visual-tests', + after: ['add-component'], label: 'Visual tests', - predicate: ({ complete }) => complete(), + subscribe: ({ done }) => done(), content: ( <>

    @@ -125,6 +197,7 @@ export const checklistData: ChecklistData = { }, { id: 'viewports', + after: ['add-component'], label: 'Viewports', content: ( <> @@ -153,19 +226,41 @@ export const checklistData: ChecklistData = { items: [ { id: 'controls', + after: ['add-component'], label: 'Controls', + content: ( + <> + Storybook Controls gives you a graphical UI to interact with a component's arguments + dynamically without needing to code. Use the Controls panel to edit the inputs to your + stories and see the results in real-time. It's a great way to explore your components + and test different states. + + ), }, { id: 'autodocs', + after: ['add-component'], label: 'Autodocs', - }, - { - id: 'comments', - label: 'Comments', + content: ( + <> + Storybook Autodocs is a powerful tool that can help you quickly generate comprehensive + documentation for your UI components. By leveraging Autodocs, you're transforming your + stories into living documentation which can be further extended with MDX and Doc + Blocks to provide a clear and concise understanding of your components' functionality. + + ), }, { id: 'share-story', + after: ['add-component'], label: 'Share story', + content: ( + <> + Teams publish Storybook online to review and collaborate on works in progress. That + allows developers, designers, PMs, and other stakeholders to check if the UI looks + right without touching code or requiring a local dev environment. + + ), }, ], }, diff --git a/code/core/src/manager/settings/GuidePage.stories.tsx b/code/core/src/manager/settings/GuidePage.stories.tsx new file mode 100644 index 000000000000..927ee2981eac --- /dev/null +++ b/code/core/src/manager/settings/GuidePage.stories.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { ManagerContext } from 'storybook/manager-api'; +import { fn } from 'storybook/test'; + +import preview from '../../../../.storybook/preview'; +import { universalChecklistStore as mockStore } from '../manager-stores.mock'; +import { GuidePage } from './GuidePage'; + +const managerContext: any = { + state: {}, + api: { + navigateUrl: fn().mockName('api::navigateUrl'), + }, +}; + +const meta = preview.meta({ + component: GuidePage, + decorators: [ + (Story) => ( + + + + ), + ], + beforeEach: async () => { + mockStore.setState({ + loaded: true, + muted: false, + accepted: ['controls'], + done: ['add-component'], + skipped: ['viewports'], + }); + }, +}); + +export const Default = meta.story({}); diff --git a/code/core/src/manager/settings/GuidedTourPage.tsx b/code/core/src/manager/settings/GuidePage.tsx similarity index 55% rename from code/core/src/manager/settings/GuidedTourPage.tsx rename to code/core/src/manager/settings/GuidePage.tsx index edc705686b3c..02083d9e2124 100644 --- a/code/core/src/manager/settings/GuidedTourPage.tsx +++ b/code/core/src/manager/settings/GuidePage.tsx @@ -1,9 +1,11 @@ import React from 'react'; +import { Link } from 'storybook/internal/components'; + import { styled } from 'storybook/theming'; +import { useChecklist } from '../components/sidebar/useChecklist'; import { Checklist } from './Checklist/Checklist'; -import { checklistData } from './Checklist/checklistData'; const Container = styled.div(({ theme }) => ({ display: 'flex', @@ -32,17 +34,32 @@ const Intro = styled.div(({ theme }) => ({ }, })); -export const GuidedTourPage = () => { +export const GuidePage = () => { + const checklist = useChecklist(); + return ( -

    Guided tour

    +

    Guide

    Learn the basics. Set up Storybook. You know the drill. This isn't your first time setting up software so get to it!

    - + + {checklist.muted ? ( +
    + Want to see this in the sidebar?{' '} + checklist.mute(false)}>Show in sidebar +
    + ) : ( +
    + Don't want to see this in the sidebar?{' '} + checklist.mute(checklist.allItems.map(({ id }) => id))}> + Remove from sidebar + +
    + )} ); }; diff --git a/code/core/src/manager/settings/index.tsx b/code/core/src/manager/settings/index.tsx index 9eead7d8796d..6a75d4aa78c2 100644 --- a/code/core/src/manager/settings/index.tsx +++ b/code/core/src/manager/settings/index.tsx @@ -13,7 +13,7 @@ import { styled } from 'storybook/theming'; import { matchesKeyCode, matchesModifiers } from '../keybinding'; import { AboutPage } from './AboutPage'; -import { GuidedTourPage } from './GuidedTourPage'; +import { GuidePage } from './GuidePage'; import { ShortcutsPage } from './ShortcutsPage'; import { WhatsNewPage } from './whats_new_page'; @@ -62,25 +62,29 @@ const Pages: FC<{ ), }, - { - id: 'guided-tour', - title: 'Guided tour', - children: ( - - - - ), - }, - { - id: 'shortcuts', - title: 'Keyboard shortcuts', + ]; + + if (global.CONFIG_TYPE === 'DEVELOPMENT') { + tabsToInclude.push({ + id: 'guide', + title: 'Guide', children: ( - - + + ), - }, - ]; + }); + } + + tabsToInclude.push({ + id: 'shortcuts', + title: 'Keyboard shortcuts', + children: ( + + + + ), + }); if (enableWhatsNew) { tabsToInclude.push({ diff --git a/code/core/src/shared/checklist-store/index.ts b/code/core/src/shared/checklist-store/index.ts index 20fb9034ec40..3e81fea36b90 100644 --- a/code/core/src/shared/checklist-store/index.ts +++ b/code/core/src/shared/checklist-store/index.ts @@ -1,50 +1,39 @@ -import type { UniversalStore } from '../universal-store'; import type { StoreOptions } from '../universal-store/types'; export type TaskId = string; export type StoreState = { - completed: Array; + loaded: boolean; + muted: boolean | Array; + accepted: Array; + done: Array; skipped: Array; }; export type StoreEvent = - | { type: 'complete'; payload: TaskId } + | { type: 'accept'; payload: TaskId } + | { type: 'done'; payload: TaskId } | { type: 'skip'; payload: TaskId } - | { type: 'reset'; payload: TaskId }; + | { type: 'reset'; payload: TaskId } + | { type: 'mute'; payload: boolean | Array }; export const UNIVERSAL_CHECKLIST_STORE_OPTIONS: StoreOptions = { id: 'storybook/checklist', - initialState: { completed: ['add-component'], skipped: [] } as StoreState, + initialState: { + loaded: false, + muted: false, + accepted: [], + done: [], + skipped: [], + } as StoreState, } as const; export type ChecklistStore = { - complete: (id: TaskId) => void; + accept: (id: TaskId) => void; + done: (id: TaskId) => void; skip: (id: TaskId) => void; reset: (id: TaskId) => void; + mute: (value: boolean | Array) => void; }; export type ChecklistStoreEnvironment = 'server' | 'manager' | 'preview'; - -export const createChecklistStore = ( - universalStore: UniversalStore -): ChecklistStore => ({ - complete: (id: TaskId) => { - universalStore.setState((state) => ({ - completed: state.completed.includes(id) ? state.completed : [...state.completed, id], - skipped: state.skipped.filter((v) => v !== id), - })); - }, - skip: (id: TaskId) => { - universalStore.setState((state) => ({ - completed: state.completed.filter((v) => v !== id), - skipped: state.skipped.includes(id) ? state.skipped : [...state.skipped, id], - })); - }, - reset: (id: TaskId) => { - universalStore.setState((state) => ({ - completed: state.completed.filter((v) => v !== id), - skipped: state.skipped.filter((v) => v !== id), - })); - }, -});