diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 279e8a138b51..4668c15ab5b9 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -81,6 +81,9 @@ const config = defineMain({ directory: '../addons/onboarding/src', titlePrefix: 'addons/onboarding', }, + { + directory: '../addons/onboarding/example-stories', + }, { directory: '../addons/pseudo-states/src', titlePrefix: 'addons/pseudo-states', @@ -100,6 +103,7 @@ const config = defineMain({ }, ], addons: [ + '@storybook/addon-onboarding', '@storybook/addon-themes', '@storybook/addon-docs', '@storybook/addon-designs', diff --git a/code/addons/onboarding/example-stories/Button.stories.tsx b/code/addons/onboarding/example-stories/Button.stories.tsx new file mode 100644 index 000000000000..369da3452f4d --- /dev/null +++ b/code/addons/onboarding/example-stories/Button.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { fn } from 'storybook/test'; + +import { Button } from './Button'; + +const meta = { + title: 'Example/Button', + component: Button, + parameters: { + layout: 'centered', + parameters: { + chromatic: { + disableSnapshot: true, + }, + }, + }, + tags: ['autodocs'], + argTypes: { + backgroundColor: { control: 'color' }, + }, + args: { onClick: fn() }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: { + primary: true, + label: 'Button', + }, +}; + +export const Secondary: Story = { + args: { + label: 'Button', + }, +}; + +export const Large: Story = { + args: { + size: 'large', + label: 'Button', + }, +}; + +export const Small: Story = { + args: { + size: 'small', + label: 'Button', + }, +}; diff --git a/code/addons/onboarding/example-stories/Button.tsx b/code/addons/onboarding/example-stories/Button.tsx new file mode 100644 index 000000000000..f35dafdcb427 --- /dev/null +++ b/code/addons/onboarding/example-stories/Button.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import './button.css'; + +export interface ButtonProps { + /** Is this the principal call to action on the page? */ + primary?: boolean; + /** What background color to use */ + backgroundColor?: string; + /** How large should the button be? */ + size?: 'small' | 'medium' | 'large'; + /** Button contents */ + label: string; + /** Optional click handler */ + onClick?: () => void; +} + +/** Primary UI component for user interaction */ +export const Button = ({ + primary = false, + size = 'medium', + backgroundColor, + label, + ...props +}: ButtonProps) => { + const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + return ( + + ); +}; diff --git a/code/addons/onboarding/example-stories/button.css b/code/addons/onboarding/example-stories/button.css new file mode 100644 index 000000000000..4e3620b0dcbf --- /dev/null +++ b/code/addons/onboarding/example-stories/button.css @@ -0,0 +1,30 @@ +.storybook-button { + display: inline-block; + cursor: pointer; + border: 0; + border-radius: 3em; + font-weight: 700; + line-height: 1; + font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; +} +.storybook-button--primary { + background-color: #555ab9; + color: white; +} +.storybook-button--secondary { + box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; + background-color: transparent; + color: #333; +} +.storybook-button--small { + padding: 10px 16px; + font-size: 12px; +} +.storybook-button--medium { + padding: 11px 20px; + font-size: 14px; +} +.storybook-button--large { + padding: 12px 24px; + font-size: 16px; +} diff --git a/code/addons/onboarding/src/Onboarding.tsx b/code/addons/onboarding/src/Onboarding.tsx index 9d208321b76c..0c667320a118 100644 --- a/code/addons/onboarding/src/Onboarding.tsx +++ b/code/addons/onboarding/src/Onboarding.tsx @@ -3,15 +3,14 @@ import React, { useCallback, useEffect, useState } from 'react'; import { SyntaxHighlighter } from 'storybook/internal/components'; import { SAVE_STORY_RESPONSE } from 'storybook/internal/core-events'; -import type { Step } from 'react-joyride'; import { type API } from 'storybook/manager-api'; import { ThemeProvider, convert, styled, themes } from 'storybook/theming'; +import { HighlightElement } from '../../../core/src/manager/components/TourGuide/HighlightElement'; +import { TourGuide } from '../../../core/src/manager/components/TourGuide/TourGuide'; import { Confetti } from './components/Confetti/Confetti'; -import { HighlightElement } from './components/HighlightElement/HighlightElement'; import type { STORYBOOK_ADDON_ONBOARDING_STEPS } from './constants'; -import { ADDON_CONTROLS_ID, STORYBOOK_ADDON_ONBOARDING_CHANNEL } from './constants'; -import { GuidedTour } from './features/GuidedTour/GuidedTour'; +import { ADDON_CONTROLS_ID, ADDON_ONBOARDING_CHANNEL } from './constants'; import { IntentSurvey } from './features/IntentSurvey/IntentSurvey'; import { SplashScreen } from './features/SplashScreen/SplashScreen'; @@ -44,30 +43,14 @@ const CodeWrapper = styled.div(({ theme }) => ({ const theme = convert(); export type StepKey = (typeof STORYBOOK_ADDON_ONBOARDING_STEPS)[number]; -export type StepDefinition = { - key: StepKey; - hideNextButton?: boolean; - onNextButtonClick?: () => void; -} & Partial< - Pick< - // Unfortunately we can't use ts-expect-error here for some reason - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore Ignore circular reference - Step, - | 'content' - | 'disableBeacon' - | 'disableOverlay' - | 'floaterProps' - | 'offset' - | 'placement' - | 'spotlightClicks' - | 'styles' - | 'target' - | 'title' - > ->; -export default function Onboarding({ api }: { api: API }) { +export default function Onboarding({ + api, + hasCompletedSurvey, +}: { + api: API; + hasCompletedSurvey: boolean; +}) { const [enabled, setEnabled] = useState(true); const [showConfetti, setShowConfetti] = useState(false); const [step, setStep] = useState('1:Intro'); @@ -98,33 +81,36 @@ export default function Onboarding({ api }: { api: API }) { [api] ); - const disableOnboarding = useCallback(() => { - // remove onboarding query parameter from current url - const url = new URL(window.location.href); - // @ts-expect-error (not strict) - const path = decodeURIComponent(url.searchParams.get('path')); - url.search = `?path=${path}&onboarding=false`; - history.replaceState({}, '', url.href); - api.setQueryParams({ onboarding: 'false' }); - setEnabled(false); - }, [api, setEnabled]); + const disableOnboarding = useCallback( + (dismissedStep?: StepKey) => { + if (dismissedStep) { + api.emit(ADDON_ONBOARDING_CHANNEL, { + dismissedStep, + type: 'dismiss', + userAgent, + }); + } + // remove onboarding query parameter from current url + const url = new URL(window.location.href); + url.searchParams.set('onboarding', 'false'); + history.replaceState({}, '', url.href); + api.setQueryParams({ onboarding: 'false' }); + setEnabled(false); + }, + [api, setEnabled, userAgent] + ); - const completeOnboarding = useCallback( + const completeSurvey = useCallback( (answers: Record) => { - api.emit(STORYBOOK_ADDON_ONBOARDING_CHANNEL, { - step: '7:FinishedOnboarding' satisfies StepKey, - type: 'telemetry', - userAgent, - }); - api.emit(STORYBOOK_ADDON_ONBOARDING_CHANNEL, { + api.emit(ADDON_ONBOARDING_CHANNEL, { answers, type: 'survey', userAgent, }); + setStep('7:FinishedOnboarding'); selectStory('configure-your-project--docs'); - disableOnboarding(); }, - [api, selectStory, disableOnboarding, userAgent] + [api, selectStory, userAgent] ); useEffect(() => { @@ -148,6 +134,10 @@ export default function Onboarding({ api }: { api: API }) { useEffect(() => { setStep((current) => { + if (hasCompletedSurvey && current === '6:IntentSurvey') { + return '7:FinishedOnboarding'; + } + if ( ['1:Intro', '5:StoryCreated', '6:IntentSurvey', '7:FinishedOnboarding'].includes(current) ) { @@ -162,12 +152,13 @@ export default function Onboarding({ api }: { api: API }) { return '3:SaveFromControls'; } - if (primaryControl) { + if (primaryControl || current === '2:Controls') { return '2:Controls'; } + return '1:Intro'; }); - }, [createNewStoryForm, primaryControl, saveFromControls]); + }, [hasCompletedSurvey, createNewStoryForm, primaryControl, saveFromControls]); useEffect(() => { return api.on(SAVE_STORY_RESPONSE, ({ payload, success }) => { @@ -183,7 +174,7 @@ export default function Onboarding({ api }: { api: API }) { }, [api]); useEffect( - () => api.emit(STORYBOOK_ADDON_ONBOARDING_CHANNEL, { step, type: 'telemetry', userAgent }), + () => api.emit(ADDON_ONBOARDING_CHANNEL, { step, type: 'telemetry', userAgent }), [api, step, userAgent] ); @@ -196,7 +187,7 @@ export default function Onboarding({ api }: { api: API }) { const snippet = source?.slice(startIndex).trim(); const startingLineNumber = source?.slice(0, startIndex).split('\n').length; - const steps: StepDefinition[] = [ + const controlsTour = [ { key: '2:Controls', target: '#control-primary', @@ -213,7 +204,7 @@ export default function Onboarding({ api }: { api: API }) { disableBeacon: true, disableOverlay: true, spotlightClicks: true, - onNextButtonClick: () => { + onNext: () => { const input = document.querySelector('#control-primary') as HTMLInputElement; input.click(); }, @@ -234,7 +225,7 @@ export default function Onboarding({ api }: { api: API }) { disableBeacon: true, disableOverlay: true, spotlightClicks: true, - onNextButtonClick: () => { + onNext: () => { const button = document.querySelector( 'button[aria-label="Create new story with these settings"]' ) as HTMLButtonElement; @@ -280,7 +271,31 @@ export default function Onboarding({ api }: { api: API }) { }, }, }, - ] as const; + ]; + + const checklistTour = [ + { + key: '7:FinishedOnboarding', + target: '#storybook-checklist-module', + title: 'Continue at your own pace using the guide', + content: ( + <> + Nice! You've got the essentials. You can continue at your own pace using the guide to + discover more of Storybook's capabilities. + + + ), + offset: 0, + placement: 'right-start', + disableBeacon: true, + disableOverlay: true, + styles: { + tooltip: { + width: 350, + }, + }, + }, + ]; return ( @@ -288,13 +303,27 @@ export default function Onboarding({ api }: { api: API }) { {step === '1:Intro' ? ( setStep('2:Controls')} /> ) : step === '6:IntentSurvey' ? ( - + disableOnboarding('6:IntentSurvey')} + /> + ) : step === '7:FinishedOnboarding' ? ( + disableOnboarding()} + onDismiss={() => disableOnboarding(step)} + /> ) : ( - setStep('6:IntentSurvey')} + steps={controlsTour} + onComplete={() => setStep(hasCompletedSurvey ? '7:FinishedOnboarding' : '6:IntentSurvey')} + onDismiss={() => disableOnboarding(step)} /> )} diff --git a/code/addons/onboarding/src/Survey.tsx b/code/addons/onboarding/src/Survey.tsx new file mode 100644 index 000000000000..7bf5f4a43815 --- /dev/null +++ b/code/addons/onboarding/src/Survey.tsx @@ -0,0 +1,47 @@ +import React, { useCallback } from 'react'; + +import { type API } from 'storybook/manager-api'; +import { ThemeProvider, convert } from 'storybook/theming'; + +import { ADDON_ONBOARDING_CHANNEL } from './constants'; +import { IntentSurvey } from './features/IntentSurvey/IntentSurvey'; + +const theme = convert(); + +export default function Survey({ api }: { api: API }) { + // eslint-disable-next-line compat/compat + const userAgent = globalThis?.navigator?.userAgent; + + const disableOnboarding = useCallback(() => { + // remove onboarding query parameter from current url + const url = new URL(window.location.href); + url.searchParams.set('onboarding', 'false'); + history.replaceState({}, '', url.href); + api.setQueryParams({ onboarding: 'false' }); + }, [api]); + + const complete = useCallback( + (answers: Record) => { + api.emit(ADDON_ONBOARDING_CHANNEL, { + answers, + type: 'survey', + userAgent, + }); + disableOnboarding(); + }, + [api, disableOnboarding, userAgent] + ); + + const dismiss = useCallback(() => { + api.emit(ADDON_ONBOARDING_CHANNEL, { + type: 'dismissSurvey', + }); + disableOnboarding(); + }, [api, disableOnboarding]); + + return ( + + + + ); +} diff --git a/code/addons/onboarding/src/components/Button/Button.stories.tsx b/code/addons/onboarding/src/components/Button/Button.stories.tsx deleted file mode 100644 index 1e2f8a9e7931..000000000000 --- a/code/addons/onboarding/src/components/Button/Button.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; - -import { Button } from './Button'; - -const meta: Meta = { - title: 'Components/Button', - component: Button, -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { children: 'Primary Button' }, -}; - -export const Secondary: Story = { - args: { children: 'Secondary Button', variant: 'secondary' }, -}; - -export const Outline: Story = { - args: { children: 'Outline Button', variant: 'outline' }, -}; - -export const White: Story = { - args: { children: 'White Button', variant: 'white' }, -}; diff --git a/code/addons/onboarding/src/components/Button/Button.tsx b/code/addons/onboarding/src/components/Button/Button.tsx deleted file mode 100644 index 74d1bf474be4..000000000000 --- a/code/addons/onboarding/src/components/Button/Button.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import type { ComponentProps } from 'react'; -import React, { forwardRef } from 'react'; - -import { darken, lighten, transparentize } from 'polished'; -import { styled } from 'storybook/theming'; - -export interface ButtonProps extends ComponentProps<'button'> { - children: string; - onClick?: (e: React.MouseEvent) => void; - variant?: 'primary' | 'secondary' | 'outline' | 'white'; -} - -const StyledButton = styled.button<{ variant: ButtonProps['variant'] }>(({ theme, variant }) => ({ - all: 'unset', - boxSizing: 'border-box', - border: 0, - borderRadius: '4px', - cursor: 'pointer', - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - padding: '0 0.75rem', - background: (() => { - if (variant === 'secondary') { - return theme.button.background; - } - - if (variant === 'outline') { - return 'transparent'; - } - - if (variant === 'white') { - return theme.color.lightest; - } - return theme.base === 'light' ? theme.color.secondary : darken(0.18, theme.color.secondary); - })(), - color: (() => { - if (variant === 'secondary' || variant === 'outline') { - return theme.color.defaultText; - } - - if (variant === 'white') { - return theme.base === 'light' ? theme.color.secondary : darken(0.18, theme.color.secondary); - } - - return theme.color.lightest; - })(), - boxShadow: (() => { - if (variant === 'secondary') { - return `${theme.button.border} 0 0 0 1px inset`; - } - - if (variant === 'outline') { - return `${theme.button.border} 0 0 0 1px inset`; - } - return 'none'; - })(), - height: '32px', - fontSize: '0.8125rem', - fontWeight: '700', - fontFamily: theme.typography.fonts.base, - transition: 'background-color, box-shadow, color, opacity', - transitionDuration: '0.16s', - transitionTimingFunction: 'ease-in-out', - textDecoration: 'none', - - '&:hover, &:focus': { - background: (() => { - if (variant === 'secondary' || variant === 'outline') { - return transparentize(0.93, theme.color.secondary); - } - - if (variant === 'white') { - return transparentize(0.1, theme.color.lightest); - } - - return theme.base === 'light' - ? lighten(0.1, theme.color.secondary) - : darken(0.3, theme.color.secondary); - })(), - color: (() => { - if (variant === 'secondary' || variant === 'outline') { - return theme.barSelectedColor; - } - - if (variant === 'white') { - return theme.base === 'light' - ? lighten(0.1, theme.color.secondary) - : darken(0.3, theme.color.secondary); - } - return theme.color.lightest; - })(), - boxShadow: (() => { - if (variant === 'secondary' || variant === 'outline' || variant === 'white') { - return `inset 0 0 0 1px ${theme.barSelectedColor}`; - } - - return 'none'; - })(), - }, - - '&:focus-visible': { - outline: `solid ${theme.color.secondary}`, - outlineOffset: '2px', - outlineWidth: '2px', - }, -})); - -export const Button = forwardRef(function Button( - { children, onClick, variant = 'primary', ...rest }, - ref -) { - return ( - - {children} - - ); -}); diff --git a/code/addons/onboarding/src/components/HighlightElement/HighlightElement.stories.tsx b/code/addons/onboarding/src/components/HighlightElement/HighlightElement.stories.tsx deleted file mode 100644 index f0c6eff65ea2..000000000000 --- a/code/addons/onboarding/src/components/HighlightElement/HighlightElement.stories.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react'; - -import type { Meta, StoryObj } from '@storybook/react-vite'; - -import { expect, waitFor, within } from 'storybook/test'; - -import { HighlightElement } from './HighlightElement'; - -const meta: Meta = { - component: HighlightElement, - parameters: { - layout: 'centered', - chromatic: { - disableSnapshot: true, - }, - }, -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - render: () => ( - <> - - - - ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement.parentElement!); - const button = await canvas.findByRole('button'); - await waitFor(() => expect(button).toHaveStyle('box-shadow: rgba(2,156,253,1) 0 0 2px 1px')); - }, -}; - -export const Pulsating: Story = { - render: () => ( - <> - - - - ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement.parentElement!); - const button = await canvas.findByRole('button'); - await waitFor(() => - expect(button).toHaveStyle( - 'animation: 3s ease-in-out 0s infinite normal none running pulsate' - ) - ); - }, -}; diff --git a/code/addons/onboarding/src/components/HighlightElement/HighlightElement.tsx b/code/addons/onboarding/src/components/HighlightElement/HighlightElement.tsx deleted file mode 100644 index 23fa7489ad68..000000000000 --- a/code/addons/onboarding/src/components/HighlightElement/HighlightElement.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useEffect } from 'react'; - -export function HighlightElement({ - targetSelector, - pulsating = false, -}: { - targetSelector: string; - pulsating?: boolean; -}): JSX.Element | null { - useEffect(() => { - const element = document.querySelector(targetSelector); - - if (element) { - if (pulsating) { - element.style.animation = 'pulsate 3s infinite'; - element.style.transformOrigin = 'center'; - element.style.animationTimingFunction = 'ease-in-out'; - - const keyframes = ` - @keyframes pulsate { - 0% { - box-shadow: rgba(2,156,253,1) 0 0 2px 1px, 0 0 0 0 rgba(2, 156, 253, 0.7), 0 0 0 0 rgba(2, 156, 253, 0.4); - } - 50% { - box-shadow: rgba(2,156,253,1) 0 0 2px 1px, 0 0 0 20px rgba(2, 156, 253, 0), 0 0 0 40px rgba(2, 156, 253, 0); - } - 100% { - box-shadow: rgba(2,156,253,1) 0 0 2px 1px, 0 0 0 0 rgba(2, 156, 253, 0), 0 0 0 0 rgba(2, 156, 253, 0); - } - } - `; - const style = document.createElement('style'); - style.id = 'sb-onboarding-pulsating-effect'; - style.innerHTML = keyframes; - document.head.appendChild(style); - } else { - element.style.boxShadow = 'rgba(2,156,253,1) 0 0 2px 1px'; - } - } - - return () => { - const styleElement = document.querySelector('#sb-onboarding-pulsating-effect'); - - if (styleElement) { - styleElement.remove(); - } - - if (element) { - element.style.animation = ''; - element.style.boxShadow = ''; - } - }; - }, [targetSelector, pulsating]); - - return null; -} diff --git a/code/addons/onboarding/src/constants.ts b/code/addons/onboarding/src/constants.ts index 8da2d52f6a73..b1877f1b01f7 100644 --- a/code/addons/onboarding/src/constants.ts +++ b/code/addons/onboarding/src/constants.ts @@ -1,4 +1,5 @@ -export const STORYBOOK_ADDON_ONBOARDING_CHANNEL = 'STORYBOOK_ADDON_ONBOARDING_CHANNEL'; +export const ADDON_ID = 'storybook/onboarding'; +export const ADDON_ONBOARDING_CHANNEL = `${ADDON_ID}/channel`; // ! please keep this in sync with core/src/controls/constants.ts export const ADDON_CONTROLS_ID = 'addon-controls' as const; diff --git a/code/addons/onboarding/src/features/GuidedTour/GuidedTour.stories.tsx b/code/addons/onboarding/src/features/GuidedTour/GuidedTour.stories.tsx deleted file mode 100644 index 3ddb94096528..000000000000 --- a/code/addons/onboarding/src/features/GuidedTour/GuidedTour.stories.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; - -import { fn } from 'storybook/test'; - -import { GuidedTour } from './GuidedTour'; - -const meta = { - component: GuidedTour, - args: { - onClose: fn(), - onComplete: fn(), - }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - step: '1:Intro', - steps: [ - { - key: '1:Intro', - title: 'Welcome', - content: 'Welcome to the guided tour!', - target: '#storybook-root', - disableBeacon: true, - disableOverlay: true, - }, - { - key: '2:Controls', - title: 'Controls', - content: "Can't reach this step", - target: '#storybook-root', - disableBeacon: true, - disableOverlay: true, - }, - ], - }, -}; diff --git a/code/addons/onboarding/src/features/GuidedTour/GuidedTour.tsx b/code/addons/onboarding/src/features/GuidedTour/GuidedTour.tsx deleted file mode 100644 index 635b545fd881..000000000000 --- a/code/addons/onboarding/src/features/GuidedTour/GuidedTour.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React, { useEffect, useState } from 'react'; - -import { darken } from 'polished'; -import type { CallBackProps } from 'react-joyride'; -import Joyride, { ACTIONS } from 'react-joyride'; -import { useTheme } from 'storybook/theming'; - -import type { StepDefinition, StepKey } from '../../Onboarding'; -import { Tooltip } from './Tooltip'; - -export function GuidedTour({ - step, - steps, - onClose, - onComplete, -}: { - step: StepKey; - steps: StepDefinition[]; - onClose: () => void; - onComplete: () => void; -}) { - const [stepIndex, setStepIndex] = useState(null); - const theme = useTheme(); - - useEffect(() => { - let timeout: ReturnType; - setStepIndex((current) => { - const index = steps.findIndex(({ key }) => key === step); - - if (index === -1) { - return null; - } - - if (index === current) { - return current; - } - timeout = setTimeout(setStepIndex, 500, index); - return null; - }); - return () => clearTimeout(timeout); - }, [step, steps]); - - if (stepIndex === null) { - return null; - } - - return ( - { - if (data.action === ACTIONS.CLOSE) { - onClose(); - } - - if (data.action === ACTIONS.NEXT && data.index === data.size - 1) { - onComplete(); - } - }} - floaterProps={{ - disableAnimation: true, - styles: { - arrow: { - length: 20, - spread: 2, - }, - floater: { - filter: - theme.base === 'light' - ? 'drop-shadow(0px 5px 5px rgba(0,0,0,0.05)) drop-shadow(0 1px 3px rgba(0,0,0,0.1))' - : 'drop-shadow(#fff5 0px 0px 0.5px) drop-shadow(#fff5 0px 0px 0.5px)', - }, - }, - }} - tooltipComponent={Tooltip} - styles={{ - overlay: { - mixBlendMode: 'unset', - backgroundColor: steps[stepIndex]?.target === 'body' ? 'rgba(27, 28, 29, 0.2)' : 'none', - }, - spotlight: { - backgroundColor: 'none', - border: `solid 2px ${theme.base === 'light' ? theme.color.secondary : darken(0.18, theme.color.secondary)}`, - boxShadow: '0px 0px 0px 9999px rgba(27, 28, 29, 0.2)', - }, - tooltip: { - width: 280, - color: theme.color.lightest, - background: - theme.base === 'light' ? theme.color.secondary : darken(0.18, theme.color.secondary), - }, - options: { - zIndex: 9998, - primaryColor: - theme.base === 'light' ? theme.color.secondary : darken(0.18, theme.color.secondary), - arrowColor: - theme.base === 'light' ? theme.color.secondary : darken(0.18, theme.color.secondary), - }, - }} - /> - ); -} diff --git a/code/addons/onboarding/src/features/IntentSurvey/IntentSurvey.tsx b/code/addons/onboarding/src/features/IntentSurvey/IntentSurvey.tsx index fc521153a7c2..9f825a1b2e48 100644 --- a/code/addons/onboarding/src/features/IntentSurvey/IntentSurvey.tsx +++ b/code/addons/onboarding/src/features/IntentSurvey/IntentSurvey.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { Fragment, useState } from 'react'; import { Button, Form, Modal } from 'storybook/internal/components'; @@ -182,14 +182,14 @@ export const IntentSurvey = ({ >
- + Help improve Storybook {(Object.keys(formFields) as Array).map((key) => { const field = formFields[key]; return ( - + {field.label} {field.type === 'checkbox' && ( @@ -233,7 +233,7 @@ export const IntentSurvey = ({ ))} )} - + ); })} diff --git a/code/addons/onboarding/src/manager.tsx b/code/addons/onboarding/src/manager.tsx index 3258b053c51e..1e0009801cdc 100644 --- a/code/addons/onboarding/src/manager.tsx +++ b/code/addons/onboarding/src/manager.tsx @@ -1,56 +1,68 @@ import React, { Suspense, lazy } from 'react'; -import ReactDOM from 'react-dom'; +import { createRoot } from 'react-dom/client'; import { STORY_SPECIFIED } from 'storybook/internal/core-events'; -import { addons } from 'storybook/manager-api'; +import { addons, internal_universalChecklistStore as checklistStore } from 'storybook/manager-api'; -import { ADDON_CONTROLS_ID } from './constants'; +import { ADDON_CONTROLS_ID, ADDON_ID } from './constants'; const Onboarding = lazy(() => import('./Onboarding')); +const Survey = lazy(() => import('./Survey')); + +let root: ReturnType | null = null; +const render = (node: React.ReactNode) => { + let container = document.getElementById('storybook-addon-onboarding'); + if (!container) { + container = document.createElement('div'); + container.id = 'storybook-addon-onboarding'; + document.body.appendChild(container); + } + root = root ?? createRoot(container); + root.render(}>{node}); +}; // The addon is enabled only when: // 1. The onboarding query parameter is present // 2. The example button stories are present -addons.register('@storybook/addon-onboarding', async (api) => { - const urlState = api.getUrlState(); - const isOnboarding = - urlState.path === '/onboarding' || urlState.queryParams.onboarding === 'true'; - - api.once(STORY_SPECIFIED, () => { - const hasButtonStories = - !!api.getData('example-button--primary') || - !!document.getElementById('example-button--primary'); - - if (!hasButtonStories) { - console.warn( - `[@storybook/addon-onboarding] It seems like you have finished the onboarding experience in Storybook! Therefore this addon is not necessary anymore and will not be loaded. You are free to remove it from your project. More info: https://github.com/storybookjs/storybook/tree/next/code/addons/onboarding#uninstalling` - ); - return; - } - - if (!isOnboarding || window.innerWidth < 730) { - return; - } - - api.togglePanel(true); - api.togglePanelPosition('bottom'); - api.setSelectedPanel(ADDON_CONTROLS_ID); - - // Add a new DOM element to document.body, where we will bootstrap our React app - const domNode = document.createElement('div'); - - domNode.id = 'storybook-addon-onboarding'; - // Append the new DOM element to document.body - document.body.appendChild(domNode); - - // Render the React app - // eslint-disable-next-line react/no-deprecated - ReactDOM.render( - }> - - , - domNode - ); +addons.register(ADDON_ID, async (api) => { + const { path, queryParams } = api.getUrlState(); + const isOnboarding = path === '/onboarding' || queryParams.onboarding === 'true'; + const isSurvey = queryParams.onboarding === 'survey'; + + const hasCompletedSurvey = await new Promise((resolve) => { + const unsubscribe = checklistStore.onStateChange(({ loaded, items }) => { + if (loaded) { + unsubscribe(); + resolve(items.onboardingSurvey.status === 'accepted'); + } + }); }); + + if (isSurvey) { + return hasCompletedSurvey ? null : render(); + } + + await new Promise((resolve) => api.once(STORY_SPECIFIED, resolve)); + + const hasButtonStories = + !!api.getData('example-button--primary') || + !!document.getElementById('example-button--primary'); + + if (!hasButtonStories) { + console.warn( + `[@storybook/addon-onboarding] It seems like you have finished the onboarding experience in Storybook! Therefore this addon is not necessary anymore and will not be loaded. You are free to remove it from your project. More info: https://github.com/storybookjs/storybook/tree/next/code/addons/onboarding#uninstalling` + ); + return; + } + + if (!isOnboarding || window.innerWidth < 730) { + return; + } + + api.togglePanel(true); + api.togglePanelPosition('bottom'); + api.setSelectedPanel(ADDON_CONTROLS_ID); + + return render(); }); diff --git a/code/addons/onboarding/src/preset.ts b/code/addons/onboarding/src/preset.ts index 0c07b217f025..09dc26b67c85 100644 --- a/code/addons/onboarding/src/preset.ts +++ b/code/addons/onboarding/src/preset.ts @@ -3,7 +3,7 @@ import { telemetry } from 'storybook/internal/telemetry'; import type { CoreConfig, Options } from 'storybook/internal/types'; import { version as addonVersion } from '../package.json'; -import { STORYBOOK_ADDON_ONBOARDING_CHANNEL } from './constants'; +import { ADDON_ONBOARDING_CHANNEL } from './constants'; type Event = { type: 'telemetry' | 'survey'; @@ -18,7 +18,7 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti return channel; } - channel.on(STORYBOOK_ADDON_ONBOARDING_CHANNEL, ({ type, ...event }: Event) => { + channel.on(ADDON_ONBOARDING_CHANNEL, ({ type, ...event }: Event) => { if (type === 'telemetry') { telemetry('addon-onboarding', { ...event, addonVersion }); } else if (type === 'survey') { diff --git a/code/addons/vitest/src/constants.ts b/code/addons/vitest/src/constants.ts index 1efa660ec4f8..0a8fb9d3a9ad 100644 --- a/code/addons/vitest/src/constants.ts +++ b/code/addons/vitest/src/constants.ts @@ -10,7 +10,7 @@ export { export const ADDON_ID = 'storybook/test'; export const TEST_PROVIDER_ID = `${ADDON_ID}/test-provider`; -export const STORYBOOK_ADDON_TEST_CHANNEL = 'STORYBOOK_ADDON_TEST_CHANNEL'; +export const STORYBOOK_ADDON_TEST_CHANNEL = `${ADDON_ID}/channel`; export const TUTORIAL_VIDEO_LINK = 'https://youtu.be/Waht9qq7AoA'; export const DOCUMENTATION_LINK = 'writing-tests/integrations/vitest-addon'; diff --git a/code/addons/vitest/src/manager.tsx b/code/addons/vitest/src/manager.tsx index 3df2fda319ef..5b1fd9471851 100644 --- a/code/addons/vitest/src/manager.tsx +++ b/code/addons/vitest/src/manager.tsx @@ -13,7 +13,13 @@ import { addons } from 'storybook/manager-api'; import { GlobalErrorContext, GlobalErrorModal } from './components/GlobalErrorModal'; import { SidebarContextMenu } from './components/SidebarContextMenu'; import { TestProviderRender } from './components/TestProviderRender'; -import { A11Y_PANEL_ID, ADDON_ID, COMPONENT_TESTING_PANEL_ID, TEST_PROVIDER_ID } from './constants'; +import { + A11Y_PANEL_ID, + ADDON_ID, + COMPONENT_TESTING_PANEL_ID, + STORYBOOK_ADDON_TEST_CHANNEL, + TEST_PROVIDER_ID, +} from './constants'; import { useTestProvider } from './use-test-provider-state'; addons.register(ADDON_ID, (api) => { @@ -42,6 +48,9 @@ addons.register(ADDON_ID, (api) => { ...state, indexUrl: new URL('index.json', window.location.href).toString(), })); + store.subscribe('TEST_RUN_COMPLETED', ({ payload }) => { + api.emit(STORYBOOK_ADDON_TEST_CHANNEL, { type: 'test-run-completed', payload }); + }); }); addons.add(TEST_PROVIDER_ID, { diff --git a/code/addons/vitest/src/preset.ts b/code/addons/vitest/src/preset.ts index b96623a7ea27..ab32aca082b5 100644 --- a/code/addons/vitest/src/preset.ts +++ b/code/addons/vitest/src/preset.ts @@ -36,15 +36,20 @@ import { runTestRunner } from './node/boot-test-runner'; import type { CachedState, ErrorLike, StoreState } from './types'; import type { StoreEvent } from './types'; -type Event = { - type: 'test-discrepancy'; - payload: { - storyId: StoryId; - browserStatus: 'PASS' | 'FAIL'; - cliStatus: 'FAIL' | 'PASS'; - message: string; - }; -}; +type Event = + | { + type: 'test-discrepancy'; + payload: { + storyId: StoryId; + browserStatus: 'PASS' | 'FAIL'; + cliStatus: 'FAIL' | 'PASS'; + message: string; + }; + } + | { + type: 'test-run-completed'; + payload: StoreState['currentRun']; + }; export const experimental_serverChannel = async (channel: Channel, options: Options) => { const core = await options.presets.apply('core'); @@ -183,13 +188,15 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti const enableCrashReports = core.enableCrashReports || options.enableCrashReports; channel.on(STORYBOOK_ADDON_TEST_CHANNEL, (event: Event) => { - telemetry('addon-test', { - ...event, - payload: { - ...event.payload, - storyId: oneWayHash(event.payload.storyId), - }, - }); + if (event.type !== 'test-run-completed') { + telemetry('addon-test', { + ...event, + payload: { + ...event.payload, + storyId: oneWayHash(event.payload.storyId), + }, + }); + } }); store.subscribe('TOGGLE_WATCHING', async (event) => { diff --git a/code/addons/vitest/src/use-test-provider-state.ts b/code/addons/vitest/src/use-test-provider-state.ts index e9ecb3bd77e7..57ef86103464 100644 --- a/code/addons/vitest/src/use-test-provider-state.ts +++ b/code/addons/vitest/src/use-test-provider-state.ts @@ -63,7 +63,6 @@ export const useTestProvider = ( const testProviderState = experimental_useTestProviderStore((s) => s[ADDON_ID]); const [storeState, setStoreState] = experimental_useUniversalStore(store); - // this follows the same behavior for the green border around the whole testing module in TestingModule.tsx const [isSettingsUpdated, setIsSettingsUpdated] = useState(false); const settingsUpdatedTimeoutRef = useRef>(); useEffect(() => { diff --git a/code/core/build-config.ts b/code/core/build-config.ts index 3912a937d994..ee417c6603aa 100644 --- a/code/core/build-config.ts +++ b/code/core/build-config.ts @@ -134,6 +134,10 @@ const config: BuildEntries = { exportEntries: ['./internal/manager/globals'], entryPoint: './src/manager/globals.ts', }, + { + exportEntries: ['./internal/manager/manager-stores'], + entryPoint: './src/manager/manager-stores.ts', + }, { entryPoint: './src/core-server/presets/common-manager.ts', dts: false, diff --git a/code/core/package.json b/code/core/package.json index 5604f58be055..ea4898445292 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -118,6 +118,10 @@ "default": "./dist/manager/globals.js" }, "./internal/manager/globals-runtime": "./dist/manager/globals-runtime.js", + "./internal/manager/manager-stores": { + "types": "./dist/manager/manager-stores.d.ts", + "default": "./dist/manager/manager-stores.js" + }, "./internal/mocking-utils": { "types": "./dist/mocking-utils/index.d.ts", "default": "./dist/mocking-utils/index.js" diff --git a/code/core/src/cli/globalSettings.ts b/code/core/src/cli/globalSettings.ts index f72e31c6f1a7..58406c2f8b5d 100644 --- a/code/core/src/cli/globalSettings.ts +++ b/code/core/src/cli/globalSettings.ts @@ -5,16 +5,56 @@ import { dirname, join } from 'node:path'; import { dedent } from 'ts-dedent'; import { z } from 'zod'; +import { invariant } from '../common/utils/utils'; + const DEFAULT_SETTINGS_PATH = join(homedir(), '.storybook', 'settings.json'); const VERSION = 1; +const statusValue = z + .strictObject({ + status: z.enum(['open', 'accepted', 'done', 'skipped']).optional(), + mutedAt: z.number().optional(), + }) + .optional(); + const userSettingSchema = z.object({ version: z.number(), // NOTE: every key (and subkey) below must be optional, for forwards compatibility reasons // (we can remove keys once they are deprecated) userSince: z.number().optional(), init: z.object({ skipOnboarding: z.boolean().optional() }).optional(), + checklist: z + .object({ + items: z + .object({ + accessibilityTests: statusValue, + autodocs: statusValue, + ciTests: statusValue, + controls: statusValue, + coverage: statusValue, + guidedTour: statusValue, + installA11y: statusValue, + installChromatic: statusValue, + installDocs: statusValue, + installVitest: statusValue, + mdxDocs: statusValue, + moreComponents: statusValue, + moreStories: statusValue, + onboardingSurvey: statusValue, + organizeStories: statusValue, + publishStorybook: statusValue, + renderComponent: statusValue, + runTests: statusValue, + viewports: statusValue, + visualTests: statusValue, + whatsNewStorybook10: statusValue, + writeInteractions: statusValue, + }) + .optional(), + widget: z.object({ disable: z.boolean().optional() }).optional(), + }) + .optional(), }); let settings: Settings | undefined; @@ -66,6 +106,7 @@ export class Settings { /** Save settings to the file */ async save(): Promise { + invariant(this.filePath, 'No file path to save settings to'); try { await fs.mkdir(dirname(this.filePath), { recursive: true }); await fs.writeFile(this.filePath, JSON.stringify(this.value, null, 2)); diff --git a/code/core/src/components/components/Button/Button.stories.tsx b/code/core/src/components/components/Button/Button.stories.tsx index f186bc5239cd..acf3214bb0e9 100644 --- a/code/core/src/components/components/Button/Button.stories.tsx +++ b/code/core/src/components/components/Button/Button.stories.tsx @@ -294,6 +294,14 @@ export const Disabled = meta.story({ }, }); +export const ReadOnly = meta.story({ + args: { + ariaLabel: false, + readOnly: true, + children: 'ReadOnly Button', + }, +}); + export const WithHref = meta.story({ render: () => ( diff --git a/code/core/src/components/components/Button/Button.tsx b/code/core/src/components/components/Button/Button.tsx index 93a9821151f1..8ce05b96041c 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 { ComponentProps } from 'react'; import React, { forwardRef, useEffect, useMemo, useState } from 'react'; import { deprecate } from 'storybook/internal/client-logger'; @@ -11,15 +11,9 @@ import { isPropValid, styled } from 'storybook/theming'; import { InteractiveTooltipWrapper } from './helpers/InteractiveTooltipWrapper'; import { useAriaDescription } from './helpers/useAriaDescription'; -export interface ButtonProps extends ButtonHTMLAttributes { +export interface ButtonProps extends Omit, 'as'> { + as?: ComponentProps['as'] | typeof Slot; asChild?: boolean; - size?: 'small' | 'medium'; - padding?: 'small' | 'medium' | 'none'; - variant?: 'outline' | 'solid' | 'ghost'; - onClick?: (event: SyntheticEvent) => void; - active?: boolean; - disabled?: boolean; - animation?: 'none' | 'rotate360' | 'glow' | 'jiggle'; /** * A concise action label for the button announced by screen readers. Needed for buttons without @@ -59,12 +53,14 @@ export interface ButtonProps extends ButtonHTMLAttributes { export const Button = forwardRef( ( { + as = 'button', asChild = false, animation = 'none', size = 'small', variant = 'outline', padding = 'medium', disabled = false, + readOnly = false, active, onClick, ariaLabel, @@ -76,7 +72,7 @@ export const Button = forwardRef( }, ref ) => { - let Comp: 'button' | 'a' | typeof Slot = 'button'; + const Comp = asChild ? Slot : as; if (ariaLabel === undefined || ariaLabel === '') { deprecate( @@ -95,9 +91,6 @@ export const Button = forwardRef( ); } - if (asChild) { - Comp = Slot; - } const { ariaDescriptionAttrs, AriaDescription } = useAriaDescription(ariaDescription); const shortcutAttribute = useMemo(() => { @@ -106,7 +99,7 @@ export const Button = forwardRef( const [isAnimating, setIsAnimating] = useState(false); - const handleClick = (event: SyntheticEvent) => { + const handleClick: ButtonProps['onClick'] = (event) => { if (onClick) { onClick(event); } @@ -141,14 +134,15 @@ export const Button = forwardRef( variant={variant} size={size} padding={padding} - disabled={disabled} + disabled={disabled || readOnly} + readOnly={readOnly} active={active} animating={isAnimating} animation={animation} onClick={handleClick} - aria-label={ariaLabel !== false ? ariaLabel : undefined} - aria-keyshortcuts={shortcutAttribute} - {...ariaDescriptionAttrs} + aria-label={!readOnly && ariaLabel !== false ? ariaLabel : undefined} + aria-keyshortcuts={readOnly ? undefined : shortcutAttribute} + {...(readOnly ? {} : ariaDescriptionAttrs)} {...props} /> @@ -162,149 +156,168 @@ Button.displayName = 'Button'; const StyledButton = styled('button', { shouldForwardProp: (prop) => isPropValid(prop), -})< - Omit & { - 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') { +})<{ + size?: 'small' | 'medium'; + padding?: 'small' | 'medium' | 'none'; + variant?: 'outline' | 'solid' | 'ghost'; + active?: boolean; + disabled?: boolean; + readOnly?: boolean; + animating?: boolean; + animation?: 'none' | 'rotate360' | 'glow' | 'jiggle'; +}>( + ({ + 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': { + flex: '0 0 auto', + 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 new file mode 100644 index 000000000000..1129fe659b8f --- /dev/null +++ b/code/core/src/components/components/Card/Card.stories.tsx @@ -0,0 +1,106 @@ +import preview from '../../../../../.storybook/preview'; +import { Card } from './Card'; + +const meta = preview.meta({ + component: Card, +}); + +const Contents = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +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 new file mode 100644 index 000000000000..4485b55e6116 --- /dev/null +++ b/code/core/src/components/components/Card/Card.tsx @@ -0,0 +1,112 @@ +import React, { type ComponentProps, forwardRef } from 'react'; + +import type { CSSObject, color } from 'storybook/theming'; +import { keyframes, styled } from 'storybook/theming'; + +const fadeInOut = keyframes({ + '0%': { opacity: 0 }, + '5%': { opacity: 1 }, + '25%': { opacity: 1 }, + '30%': { opacity: 0 }, +}); + +const spin = keyframes({ + '0%': { transform: 'rotate(0deg)' }, + '10%': { transform: 'rotate(10deg)' }, + '40%': { transform: 'rotate(170deg)' }, + '50%': { transform: 'rotate(180deg)' }, + '60%': { transform: 'rotate(190deg)' }, + '90%': { transform: 'rotate(350deg)' }, + '100%': { transform: 'rotate(360deg)' }, +}); + +const slide = keyframes({ + to: { + backgroundPositionX: '36%', + }, +}); + +const CardContent = styled.div(({ theme }) => ({ + borderRadius: theme.appBorderRadius, + backgroundColor: theme.background.content, + position: 'relative', +})); + +const CardOutline = styled.div<{ + animation?: 'none' | 'rainbow' | 'spin'; + color?: keyof typeof color; +}>(({ animation = 'none', color, theme }) => ({ + position: 'relative', + width: '100%', + padding: 1, + overflow: 'hidden', + backgroundColor: theme.background.content, + borderRadius: theme.appBorderRadius + 1, + 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', + + '@supports (interpolate-size: allow-keywords)': { + interpolateSize: 'allow-keywords', + transition: 'all var(--transition-duration, 0.2s), box-shadow 1s', + transitionBehavior: 'allow-discrete', + }, + + '@media (prefers-reduced-motion: reduce)': { + transition: 'box-shadow 1s', + }, + + '&:before': { + content: '""', + display: animation === 'none' ? 'none' : 'block', + position: 'absolute', + left: 0, + top: 0, + width: '100%', + height: '100%', + opacity: 1, + + ...(animation === 'rainbow' && { + animation: `${slide} 10s infinite linear, ${fadeInOut} 60s infinite linear`, + backgroundImage: `linear-gradient(45deg,rgb(234, 0, 0),rgb(255, 157, 0),rgb(255, 208, 0),rgb(0, 172, 0),rgb(0, 166, 255),rgb(181, 0, 181), rgb(234, 0, 0),rgb(255, 157, 0),rgb(255, 208, 0),rgb(0, 172, 0),rgb(0, 166, 255),rgb(181, 0, 181))`, + backgroundSize: '1000%', + backgroundPositionX: '100%', + }), + + ...(animation === 'spin' && { + left: '50%', + top: '50%', + marginLeft: 'calc(max(100vw, 100vh) * -0.5)', + marginTop: 'calc(max(100vw, 100vh) * -0.5)', + height: 'max(100vw, 100vh)', + width: 'max(100vw, 100vh)', + animation: `${spin} 3s linear infinite`, + backgroundImage: + color === 'negative' + ? // Hardcoded colors to prevent themes from messing with them (orange+gold, secondary+seafoam) + `conic-gradient(transparent 90deg, #FC521F 150deg, #FFAE00 210deg, transparent 270deg)` + : `conic-gradient(transparent 90deg, #029CFD 150deg, #37D5D3 210deg, transparent 270deg)`, + }), + }, +})); + +interface CardProps extends ComponentProps { + outlineAnimation?: 'none' | 'rainbow' | 'spin'; + outlineColor?: keyof typeof color; +} + +export const Card = Object.assign( + forwardRef(function Card( + { outlineAnimation = 'none', outlineColor, ...props }, + ref + ) { + return ( + + + + ); + }), + { + Content: CardContent, + Outline: CardOutline, + } +); diff --git a/code/core/src/components/components/Collapsible/Collapsible.stories.tsx b/code/core/src/components/components/Collapsible/Collapsible.stories.tsx new file mode 100644 index 000000000000..2706958ceb9e --- /dev/null +++ b/code/core/src/components/components/Collapsible/Collapsible.stories.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react'; + +import preview from '../../../../../.storybook/preview'; +import type { useCollapsible } from './Collapsible'; +import { Collapsible } from './Collapsible'; + +const toggle = ({ + isCollapsed, + toggleProps, +}: { + isCollapsed: boolean; + toggleProps: ReturnType['toggleProps']; +}) => ; + +const content =
Peekaboo!
; + +const meta = preview.meta({ + component: Collapsible, + args: { + summary: toggle, + children: content, + }, +}); + +export const Default = meta.story({}); + +export const Collapsed = meta.story({ + args: { + collapsed: true, + }, +}); + +export const Disabled = meta.story({ + args: { + 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); + return ( + <> + + {content} + + ); + }, + 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 new file mode 100644 index 000000000000..fd542a1027c2 --- /dev/null +++ b/code/core/src/components/components/Collapsible/Collapsible.tsx @@ -0,0 +1,102 @@ +import React, { + type ComponentProps, + type ReactNode, + type SyntheticEvent, + useCallback, + useEffect, + useState, +} from 'react'; + +import { useId } from '@react-aria/utils'; +import { styled } from 'storybook/theming'; + +const CollapsibleContent = styled.div<{ collapsed?: boolean }>(({ collapsed = false }) => ({ + blockSize: collapsed ? 0 : 'auto', + contentVisibility: collapsed ? 'hidden' : 'visible', + transform: collapsed ? 'translateY(-10px)' : 'translateY(0)', + opacity: collapsed ? 0 : 1, + overflow: 'hidden', + + '@supports (interpolate-size: allow-keywords)': { + interpolateSize: 'allow-keywords', + transition: 'all var(--transition-duration, 0.2s)', + transitionBehavior: 'allow-discrete', + }, + + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + }, +})); + +export const Collapsible = Object.assign( + function Collapsible({ + children, + summary, + collapsed, + disabled, + state: providedState, + ...props + }: { + children: ReactNode | ((state: ReturnType) => ReactNode); + summary?: ReactNode | ((state: ReturnType) => ReactNode); + collapsed?: boolean; + disabled?: boolean; + state?: ReturnType; + } & ComponentProps) { + const internalState = useCollapsible(collapsed, disabled); + const state = providedState || internalState; + return ( + <> + {typeof summary === 'function' ? summary(state) : summary} + + {typeof children === 'function' ? children(state) : children} + + + ); + }, + { + Content: CollapsibleContent, + } +); + +export const useCollapsible = (collapsed?: boolean, disabled?: boolean) => { + const [isCollapsed, setCollapsed] = useState(!!collapsed); + + useEffect(() => { + if (collapsed !== undefined) { + setCollapsed(collapsed); + } + }, [collapsed]); + + 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, + isDisabled: !!disabled, + setCollapsed, + toggleCollapsed, + toggleProps, + }; +}; diff --git a/code/core/src/components/components/Listbox/Listbox.stories.tsx b/code/core/src/components/components/Listbox/Listbox.stories.tsx new file mode 100644 index 000000000000..2b4b27aea62f --- /dev/null +++ b/code/core/src/components/components/Listbox/Listbox.stories.tsx @@ -0,0 +1,113 @@ +import { CheckIcon, EllipsisIcon, PlayAllHollowIcon } from '@storybook/icons'; + +import { Badge, Form, ProgressSpinner } from '../..'; +import preview from '../../../../../.storybook/preview'; +import { Shortcut } from '../../../manager/container/Menu'; +import { Listbox } from './Listbox'; + +const meta = preview.meta({ + component: Listbox, + decorators: [(Story) =>
{Story()}
], +}); + +export default meta; + +export const Default = meta.story({ + render: () => ( + + + Text item + + + + + + Action item + + + Cool + + + + Hover action + + + Cool + + + + With a button + Go + + + + With an inline button + + + 25% + + + + + + With a badge + Check it out + + + + + + With a checkbox + + + + + + Active with an icon + + + + + + Some very long text which will wrap when the container is too narrow + + + + + Some very long text which will ellipsize when the container is too narrow + + + + ), +}); + +export const Groups = meta.story({ + render: () => ( + <> + + + Alpha + + + Item + + + + + Bravo + + + Item + + + + + Charlie + + + Item + + + + ), +}); diff --git a/code/core/src/components/components/Listbox/Listbox.tsx b/code/core/src/components/components/Listbox/Listbox.tsx new file mode 100644 index 000000000000..0259b924ccb5 --- /dev/null +++ b/code/core/src/components/components/Listbox/Listbox.tsx @@ -0,0 +1,153 @@ +import React, { type ComponentProps, forwardRef } from 'react'; + +import type { TransitionStatus } from 'react-transition-state'; +import { styled } from 'storybook/theming'; + +import { Button } from '../Button/Button'; + +const ListboxItem = styled.div<{ + active?: boolean; + transitionStatus?: TransitionStatus; +}>( + ({ active, theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flex: '0 0 auto', + overflow: 'hidden', + 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', + transition: 'all var(--transition-duration, 0.2s)', + transitionBehavior: 'allow-discrete', + }, + + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + }, + }), + ({ transitionStatus }) => { + switch (transitionStatus) { + case 'preEnter': + case 'exiting': + case 'exited': + return { + opacity: 0, + blockSize: 0, + contentVisibility: 'hidden', + }; + default: + return { + opacity: 1, + blockSize: 'auto', + contentVisibility: 'visible', + }; + } + } +); + +/** + * A Listbox item that shows/hides child elements on hover based on the targetId. Child elements + * must have a `data-target-id` attribute matching the `targetId` prop to be affected by the hover + * behavior. + */ +const ListboxHoverItem = styled(ListboxItem)<{ targetId: string }>(({ targetId }) => ({ + gap: 0, + [`& [data-target-id="${targetId}"]`]: { + inlineSize: 'auto', + marginLeft: 4, + opacity: 1, + '@supports (interpolate-size: allow-keywords)': { + interpolateSize: 'allow-keywords', + transition: 'all var(--transition-duration, 0.2s)', + }, + }, + [`&:not(:hover, :has(:focus-visible)) [data-target-id="${targetId}"]`]: { + inlineSize: 0, + marginLeft: 0, + opacity: 0, + paddingInline: 0, + }, +})); + +const ListboxButton = forwardRef>( + function ListboxButton({ padding = 'small', size = 'medium', variant = 'ghost', ...props }, ref) { + return + ); +}; + +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/TourGuide/HighlightElement.stories.tsx b/code/core/src/manager/components/TourGuide/HighlightElement.stories.tsx new file mode 100644 index 000000000000..5e0510ea9625 --- /dev/null +++ b/code/core/src/manager/components/TourGuide/HighlightElement.stories.tsx @@ -0,0 +1,80 @@ +import { Button } from 'storybook/internal/components'; + +import preview from '../../../../../.storybook/preview'; +import { HighlightElement } from './HighlightElement'; + +const meta = preview.meta({ + component: HighlightElement, + parameters: { + layout: 'centered', + }, +}); + +export const Default = meta.story({ + args: { + targetSelector: '#highlighted', + }, + render: (args: { targetSelector: string; pulsating?: boolean }) => ( +
+ + +
+ ), +}); + +export const Pulsating = meta.story({ + args: { + targetSelector: '#highlighted', + pulsating: true, + }, + render: (args: { targetSelector: string; pulsating?: boolean }) => ( + <> + + + + ), +}); + +export const PulsatingOverflow = meta.story({ + args: { + targetSelector: '#highlighted', + pulsating: true, + }, + render: (args: { targetSelector: string; pulsating?: boolean }) => ( +
+ + +
+ ), +}); + +export const WithScrollableContainer = meta.story({ + args: { + targetSelector: '#highlighted-in-scroll', + pulsating: true, + }, + render: (args: { targetSelector: string; pulsating?: boolean }) => ( +
+
+ +
+ +
+ ), +}); diff --git a/code/core/src/manager/components/TourGuide/HighlightElement.tsx b/code/core/src/manager/components/TourGuide/HighlightElement.tsx new file mode 100644 index 000000000000..6d3d864ecb4e --- /dev/null +++ b/code/core/src/manager/components/TourGuide/HighlightElement.tsx @@ -0,0 +1,161 @@ +import { useEffect } from 'react'; + +const HIGHLIGHT_KEYFRAMES_ID = 'storybook-highlight-element-keyframes'; + +const keyframes = ` + @keyframes sb-highlight-pulsate { + 0% { + box-shadow: rgba(2,156,253,1) 0 0 2px 1px, 0 0 0 0 rgba(2, 156, 253, 0.7), 0 0 0 0 rgba(2, 156, 253, 0.4); + } + 50% { + box-shadow: rgba(2,156,253,1) 0 0 2px 1px, 0 0 0 20px rgba(2, 156, 253, 0), 0 0 0 40px rgba(2, 156, 253, 0); + } + 100% { + box-shadow: rgba(2,156,253,1) 0 0 2px 1px, 0 0 0 0 rgba(2, 156, 253, 0), 0 0 0 0 rgba(2, 156, 253, 0); + } + } +`; + +const createOverlay = (element: HTMLElement) => { + const overlay = document.createElement('div'); + overlay.id = 'storybook-highlight-element'; + overlay.style.position = 'fixed'; + overlay.style.pointerEvents = 'none'; + overlay.style.zIndex = '2147483647'; + overlay.style.transition = 'opacity 0.2s ease-in-out'; + + requestAnimationFrame(() => { + updateOverlayStyles(element, overlay); + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + + return overlay; +}; + +const updateOverlayStyles = (element: HTMLElement, overlay: HTMLDivElement) => { + const rect = element.getBoundingClientRect(); + const computedStyle = window.getComputedStyle(element); + + overlay.style.top = `${rect.top}px`; + overlay.style.left = `${rect.left}px`; + overlay.style.width = `${rect.width}px`; + overlay.style.height = `${rect.height}px`; + overlay.style.borderRadius = computedStyle.borderRadius; +}; + +const findScrollableAncestors = (element: HTMLElement) => { + const scrollableAncestors: Array = [window]; + let parent = element.parentElement; + + while (parent) { + const style = window.getComputedStyle(parent); + const isScrollable = + style.overflow === 'auto' || + style.overflow === 'scroll' || + style.overflowX === 'auto' || + style.overflowX === 'scroll' || + style.overflowY === 'auto' || + style.overflowY === 'scroll'; + + if (isScrollable) { + scrollableAncestors.push(parent); + } + + parent = parent.parentElement; + } + return scrollableAncestors; +}; + +/** + * Highlights a DOM element on the page by creating a visual overlay. The overlay automatically + * updates its position when the element moves or scrolls, and can optionally display a pulsating + * animation effect. + * + * @param props - Component props + * @param props.targetSelector - CSS selector string to identify the element to highlight + * @param props.pulsating - Optional flag to enable a pulsating animation effect + * @returns Returns null (it doesn't render in place, it appends to the body) + * @todo Use createPortal to render the overlay in the body and avoid manually setting styles + */ +export function HighlightElement({ + targetSelector, + pulsating = false, +}: { + targetSelector: string; + pulsating?: boolean; +}) { + useEffect(() => { + const element = document.querySelector(targetSelector); + if (!element || !element.parentElement) { + return; + } + + const overlay = document.body.appendChild(createOverlay(element)); + + if (pulsating) { + if (!document.getElementById(HIGHLIGHT_KEYFRAMES_ID)) { + const style = document.createElement('style'); + style.id = HIGHLIGHT_KEYFRAMES_ID; + style.innerHTML = keyframes; + document.head.appendChild(style); + } + + overlay.style.animation = 'sb-highlight-pulsate 3s infinite'; + overlay.style.transformOrigin = 'center'; + overlay.style.animationTimingFunction = 'ease-in-out'; + } else { + overlay.style.boxShadow = 'rgba(2,156,253,1) 0 0 2px 1px'; + } + + let scrollTimeout: number | null = null; + const handleScroll = () => { + if (overlay.parentElement) { + overlay.remove(); + } + + if (scrollTimeout !== null) { + clearTimeout(scrollTimeout); + } + + scrollTimeout = window.setTimeout(() => { + if (!element) { + return; + } + + updateOverlayStyles(element, overlay); + overlay.style.opacity = '0'; + document.body.appendChild(overlay); + requestAnimationFrame(() => (overlay.style.opacity = '1')); + }, 150); + }; + + const resizeObserver = new ResizeObserver( + () => overlay.parentElement && updateOverlayStyles(element, overlay) + ); + resizeObserver.observe(window.document.body); + resizeObserver.observe(element); + + const scrollContainers = findScrollableAncestors(element); + scrollContainers.forEach((el) => + el.addEventListener('scroll', handleScroll, { passive: true }) + ); + scrollContainers + .filter((el): el is HTMLElement => el !== window) + .forEach((el) => resizeObserver.observe(el)); + + return () => { + if (scrollTimeout !== null) { + clearTimeout(scrollTimeout); + } + + if (overlay.parentElement) { + overlay.remove(); + } + + scrollContainers.forEach((el) => el.removeEventListener('scroll', handleScroll)); + resizeObserver.disconnect(); + }; + }, [targetSelector, pulsating]); + + return null; +} diff --git a/code/core/src/manager/components/TourGuide/TourGuide.stories.tsx b/code/core/src/manager/components/TourGuide/TourGuide.stories.tsx new file mode 100644 index 000000000000..d1ad121e0362 --- /dev/null +++ b/code/core/src/manager/components/TourGuide/TourGuide.stories.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { fn } from 'storybook/test'; + +import { TourGuide } from './TourGuide'; + +const meta = { + component: TourGuide, + args: { + onComplete: fn(), + onDismiss: fn(), + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + steps: [ + { + title: 'One', + content: 'Welcome to the guided tour!', + target: '#storybook-root', + }, + { + title: 'Two', + content: 'More stuff', + target: '#storybook-root', + }, + { + title: 'Three', + content: 'Some more stuff', + target: '#storybook-root', + }, + { + title: 'Four', + content: 'All done', + target: '#storybook-root', + }, + ], + }, +}; + +export const Controlled: Story = { + render: (args) => { + const [step, setStep] = useState('one'); + return ( + setStep((v) => (v === 'one' ? 'two' : 'one'))} + /> + ); + }, + args: { + steps: [ + { + key: 'one', + title: 'One', + content: 'Hello!', + target: '#storybook-root', + }, + { + key: 'two', + title: 'Two', + content: 'I go back and forth!', + target: '#storybook-root', + }, + { + key: 'three', + title: 'Three', + content: "Can't touch this", + target: '#storybook-root', + }, + ], + }, +}; diff --git a/code/core/src/manager/components/TourGuide/TourGuide.tsx b/code/core/src/manager/components/TourGuide/TourGuide.tsx new file mode 100644 index 000000000000..4ad881d073ec --- /dev/null +++ b/code/core/src/manager/components/TourGuide/TourGuide.tsx @@ -0,0 +1,200 @@ +import type { ComponentProps } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { darken } from 'polished'; +import type { CallBackProps } from 'react-joyride'; +import Joyride, { ACTIONS, type Step } from 'react-joyride'; +import { useTheme } from 'storybook/theming'; +import { ThemeProvider, convert, themes } from 'storybook/theming'; + +import { HighlightElement } from './HighlightElement'; +import { TourTooltip } from './TourTooltip'; + +type StepDefinition = { + key?: string; + highlight?: string; + hideNextButton?: boolean; + onNext?: ({ next }: { next: () => void }) => void; +} & Partial< + Pick< + // Unfortunately we can't use ts-expect-error here for some reason + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Ignore circular reference + Step, + | 'content' + | 'disableBeacon' + | 'disableOverlay' + | 'floaterProps' + | 'offset' + | 'placement' + | 'spotlightClicks' + | 'styles' + | 'target' + | 'title' + > +>; + +export const TourGuide = ({ + step, + steps, + onNext, + onComplete, + onDismiss, +}: { + step?: string; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Circular reference in Step type + steps: StepDefinition[]; + onNext?: ({ next }: { next: () => void }) => void; + onComplete?: () => void; + onDismiss?: () => void; +}) => { + const [stepIndex, setStepIndex] = useState(step ? null : 0); + const theme = useTheme(); + + const timeoutRef = useRef | undefined>(undefined); + const updateStepIndex = useCallback((index: number) => { + clearTimeout(timeoutRef.current); + setStepIndex((current) => { + if (index === -1) { + return null; + } + if (current === null || current === index) { + return index; + } + // Briefly hide the tour tooltip while switching steps + timeoutRef.current = setTimeout(setStepIndex, 300, index); + return null; + }); + }, []); + + useEffect( + () => (step ? updateStepIndex(steps.findIndex(({ key }) => key === step)) : undefined), + [step, steps, updateStepIndex] + ); + + const mappedSteps = useMemo(() => { + return steps.map((step, index) => { + const next = () => updateStepIndex(index + 1); + return { + disableBeacon: true, + disableOverlay: true, + spotlightClicks: true, + offset: 0, + ...step, + content: ( + <> + {step.content} + {step.highlight && } + + ), + onNext: step.onNext ? () => step.onNext?.({ next }) : onNext && (() => onNext?.({ next })), + }; + }); + }, [steps, onNext, updateStepIndex]); + + const callback = useCallback( + (data: CallBackProps) => { + if (data.action === ACTIONS.NEXT && data.lifecycle === 'complete') { + if (data.index === data.size - 1) { + onComplete?.(); + } else if (data.step?.onNext) { + data.step.onNext(); + } else { + updateStepIndex(data.index + 1); + } + } + if (data.action === ACTIONS.CLOSE) { + onDismiss?.(); + } + }, + [onComplete, onDismiss, updateStepIndex] + ); + + if (stepIndex === null) { + return null; + } + + return ( + + ); +}; + +let root: ReturnType | null = null; + +TourGuide.render = (props: ComponentProps) => { + let container = document.getElementById('storybook-tour'); + if (!container) { + container = document.createElement('div'); + container.id = 'storybook-tour'; + document.body.appendChild(container); + } + root = root ?? createRoot(container); + root.render( + + { + props.onComplete?.(); + root?.render(null); + root = null; + }} + onDismiss={() => { + props.onDismiss?.(); + root?.render(null); + root = null; + }} + /> + + ); +}; diff --git a/code/addons/onboarding/src/features/GuidedTour/Tooltip.tsx b/code/core/src/manager/components/TourGuide/TourTooltip.tsx similarity index 80% rename from code/addons/onboarding/src/features/GuidedTour/Tooltip.tsx rename to code/core/src/manager/components/TourGuide/TourTooltip.tsx index 1b4d5270a043..2a8f0f26ac91 100644 --- a/code/addons/onboarding/src/features/GuidedTour/Tooltip.tsx +++ b/code/core/src/manager/components/TourGuide/TourTooltip.tsx @@ -5,10 +5,11 @@ import { Button } from 'storybook/internal/components'; import { CloseAltIcon } from '@storybook/icons'; +import { darken, lighten, transparentize } from 'polished'; import type { Step, TooltipRenderProps } from 'react-joyride'; import { color, styled } from 'storybook/theming'; -import { Button as OnboardingButton } from '../../components/Button/Button'; +const ONBOARDING_ARROW_STYLE_ID = 'storybook-onboarding-arrow-style'; const TooltipBody = styled.div` padding: 15px; @@ -56,6 +57,21 @@ const Count = styled.span` font-size: 13px; `; +const NextButton = styled(Button)(({ theme }) => ({ + background: theme.color.lightest, + border: 'none', + boxShadow: 'none', + color: theme.base === 'light' ? theme.color.secondary : darken(0.18, theme.color.secondary), + + '&:hover, &:focus': { + background: transparentize(0.1, theme.color.lightest), + color: + theme.base === 'light' + ? lighten(0.1, theme.color.secondary) + : darken(0.3, theme.color.secondary), + }, +})); + type TooltipProps = { index: number; size: number; @@ -78,7 +94,6 @@ type TooltipProps = { | 'styles' > & { hideNextButton: boolean; - onNextButtonClick: () => void; } >; closeProps: TooltipRenderProps['closeProps']; @@ -86,7 +101,7 @@ type TooltipProps = { tooltipProps: TooltipRenderProps['tooltipProps']; }; -export const Tooltip: FC = ({ +export const TourTooltip: FC = ({ index, size, step, @@ -96,7 +111,7 @@ export const Tooltip: FC = ({ }) => { useEffect(() => { const style = document.createElement('style'); - style.id = '#sb-onboarding-arrow-style'; + style.id = ONBOARDING_ARROW_STYLE_ID; style.innerHTML = ` .__floater__arrow { container-type: size; } .__floater__arrow span { background: ${color.secondary}; } @@ -115,13 +130,7 @@ export const Tooltip: FC = ({ } `; document.head.appendChild(style); - return () => { - const styleElement = document.querySelector('#sb-onboarding-arrow-style'); - - if (styleElement) { - styleElement.remove(); - } - }; + return () => document.getElementById(ONBOARDING_ARROW_STYLE_ID)?.remove(); }, []); return ( @@ -146,13 +155,7 @@ export const Tooltip: FC = ({ {index + 1} of {size} {!step.hideNextButton && ( - - {index + 1 === size ? 'Done' : 'Next'} - + {index + 1 === size ? 'Done' : 'Next'} )} diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index 276fcd94cf96..439bfe300fba 100644 --- a/code/core/src/manager/components/layout/Layout.tsx +++ b/code/core/src/manager/components/layout/Layout.tsx @@ -258,6 +258,8 @@ const ContentContainer = styled.div<{ shown: boolean }>(({ theme, shown }) => ({ })); const PagesContainer = styled.div(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', gridRowStart: 'sidebar-start', gridRowEnd: '-1', gridColumnStart: 'sidebar-end', diff --git a/code/core/src/manager/components/sidebar/ChecklistWidget.stories.tsx b/code/core/src/manager/components/sidebar/ChecklistWidget.stories.tsx new file mode 100644 index 000000000000..39a93f282722 --- /dev/null +++ b/code/core/src/manager/components/sidebar/ChecklistWidget.stories.tsx @@ -0,0 +1,92 @@ +import type { PlayFunction } from 'storybook/internal/csf'; + +import { ManagerContext } from 'storybook/manager-api'; +import { fn } from 'storybook/test'; + +import preview from '../../../../../.storybook/preview'; +import { initialState } from '../../../shared/checklist-store/checklistData.state'; +import { internal_universalChecklistStore as mockStore } from '../../manager-stores.mock'; +import { ChecklistWidget } from './ChecklistWidget'; + +const managerContext: any = { + state: {}, + api: { + getData: fn().mockName('api::getData'), + getIndex: fn().mockName('api::getIndex'), + getUrlState: fn().mockName('api::getUrlState'), + navigate: fn().mockName('api::navigate'), + on: fn().mockName('api::on'), + off: fn().mockName('api::off'), + once: fn().mockName('api::once'), + }, +}; + +const meta = preview.meta({ + component: ChecklistWidget, + decorators: [ + (Story) => ( + +
{Story()}
+
+ ), + ], + beforeEach: async () => { + mockStore.setState({ + loaded: true, + widget: {}, + items: { + ...initialState.items, + controls: { status: 'accepted' }, + renderComponent: { status: 'done' }, + moreComponents: { status: 'skipped' }, + moreStories: { status: 'skipped' }, + }, + }); + }, +}); + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const play: PlayFunction = async ({ step }) => { + await wait(3000); + await step('Complete viewports task', () => { + mockStore.setState({ + loaded: true, + widget: {}, + items: { + ...initialState.items, + controls: { status: 'accepted' }, + renderComponent: { status: 'done' }, + viewports: { status: 'done' }, + moreComponents: { status: 'skipped' }, + moreStories: { status: 'skipped' }, + }, + }); + }); + + await wait(1000); + await step('Skip installVitest task', () => { + mockStore.setState({ + loaded: true, + widget: {}, + items: { + ...initialState.items, + controls: { status: 'accepted' }, + renderComponent: { status: 'done' }, + viewports: { status: 'done' }, + moreComponents: { status: 'skipped' }, + moreStories: { status: 'skipped' }, + installVitest: { status: 'skipped' }, + }, + }); + }); +}; + +export const Default = meta.story({ + play, +}); + +export const Narrow = meta.story({ + decorators: [(Story) =>
{Story()}
], + play, +}); diff --git a/code/core/src/manager/components/sidebar/ChecklistWidget.tsx b/code/core/src/manager/components/sidebar/ChecklistWidget.tsx new file mode 100644 index 000000000000..b6c10e201d9b --- /dev/null +++ b/code/core/src/manager/components/sidebar/ChecklistWidget.tsx @@ -0,0 +1,325 @@ +import React, { useEffect, useRef, useState } from 'react'; + +import { + Card, + Collapsible, + Listbox, + ProgressSpinner, + WithTooltip, +} from 'storybook/internal/components'; + +import { + ChevronSmallUpIcon, + EyeCloseIcon, + ListUnorderedIcon, + StatusFailIcon, + StatusPassIcon, +} from '@storybook/icons'; + +import { type TransitionMapOptions, useTransitionMap } from 'react-transition-state'; +import { useStorybookApi } from 'storybook/manager-api'; +import { keyframes, styled } from 'storybook/theming'; + +import { Optional } from '../Optional/Optional'; +import { Particles } from '../Particles/Particles'; +import { TextFlip } from '../TextFlip'; +import type { ChecklistItem } from './useChecklist'; +import { useChecklist } from './useChecklist'; + +const fadeScaleIn = keyframes` + from { + opacity: 0; + transform: scale(0.7); + } + to { + opacity: 1; + transform: scale(1); + } +`; + +const expand = keyframes` + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +`; + +const useTransitionArray = ( + array: V[], + subset: V[], + options: { keyFn: (item: V) => K } & TransitionMapOptions +) => { + const keyFnRef = useRef(options.keyFn); + const { setItem, toggle, stateMap } = useTransitionMap({ + allowMultiple: true, + mountOnEnter: true, + unmountOnExit: true, + preEnter: true, + ...options, + }); + + useEffect(() => { + const keyFn = keyFnRef.current; + array.forEach((task) => setItem(keyFn(task))); + }, [array, setItem]); + + useEffect(() => { + const keyFn = keyFnRef.current; + array.forEach((task) => toggle(keyFn(task), subset.map(keyFn).includes(keyFn(task)))); + }, [array, subset, toggle]); + + return Array.from(stateMap).map( + ([key, value]) => [array.find((item) => keyFnRef.current(item) === key)!, value] as const + ); +}; + +const CollapsibleWithMargin = styled(Collapsible)(({ collapsed }) => ({ + marginTop: collapsed ? 0 : 16, +})); + +const HoverCard = styled(Card)({ + '&:hover #checklist-module-collapse-toggle': { + opacity: 1, + }, +}); + +const CollapseToggle = styled(Listbox.Button)({ + opacity: 0, + transition: 'opacity var(--transition-duration, 0.2s)', + '&:focus, &:hover': { + opacity: 1, + }, +}); + +const ProgressCircle = styled(ProgressSpinner)(({ theme }) => ({ + color: theme.color.secondary, +})); + +const Checked = styled(StatusPassIcon)(({ theme }) => ({ + padding: 1, + borderRadius: '50%', + background: theme.color.positive, + color: theme.background.content, + animation: `${fadeScaleIn} 500ms forwards`, +})); + +const ItemLabel = styled.span<{ isCompleted: boolean; isSkipped: boolean }>( + ({ theme, isCompleted, isSkipped }) => ({ + position: 'relative', + margin: '0 -2px', + padding: '0 2px', + color: isSkipped + ? theme.color.mediumdark + : isCompleted + ? theme.base === 'dark' + ? theme.color.positive + : theme.color.positiveText + : theme.color.defaultText, + transition: 'color 500ms', + }), + ({ theme, isSkipped }) => + isSkipped && { + '&:after': { + content: '""', + position: 'absolute', + top: '50%', + left: 0, + width: '100%', + height: 1, + background: theme.color.mediumdark, + animation: `${expand} 500ms forwards`, + transformOrigin: 'left', + }, + } +); + +const title = (progress: number) => { + switch (true) { + case progress < 50: + return 'Get started'; + case progress < 75: + return 'Level up'; + default: + return 'Become an expert'; + } +}; + +const OpenGuideAction = ({ children }: { children?: React.ReactNode }) => { + const api = useStorybookApi(); + return ( + { + e.stopPropagation(); + api.navigate('/settings/guide'); + }} + > + + + + {children} + + ); +}; + +export const ChecklistWidget = () => { + const api = useStorybookApi(); + const { loaded, allItems, nextItems, progress, accept, mute, items } = useChecklist(); + const [renderItems, setItems] = useState([]); + + const hasItems = renderItems.length > 0; + const transitionItems = useTransitionArray(allItems, renderItems, { + keyFn: (item) => item.id, + timeout: 300, + }); + + useEffect(() => { + // Render old items (with updated status) 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); + return () => clearTimeout(timeout); + }, [nextItems, items]); + + return ( + + + ( + + + + {loaded && ( + + {title(progress)} + + } + fallback={} + /> + )} + + + + + + {loaded && ( + ( + + + + Open full guide + + + + { + e.stopPropagation(); + mute(allItems.map(({ id }) => id)); + onHide(); + }} + > + + + + Remove from sidebar + + + + )} + > + e.stopPropagation()} + > + + + + + )} + + + + )} + > + + {transitionItems.map( + ([item, { status, isMounted }]) => + isMounted && ( + + api.navigate(`/settings/guide#${item.id}`)} + > + + {item.isCompleted ? ( + + ) : ( + + )} + + + + {item.label} + + + + {item.action && ( + { + e.stopPropagation(); + item.action?.onClick({ + api, + accept: () => accept(item.id), + }); + }} + > + {item.action.label} + + )} + + ) + )} + + + + + ); +}; diff --git a/code/core/src/manager/components/sidebar/Menu.stories.tsx b/code/core/src/manager/components/sidebar/Menu.stories.tsx index 7dcd68ddfec7..4276e5cfdc34 100644 --- a/code/core/src/manager/components/sidebar/Menu.stories.tsx +++ b/code/core/src/manager/components/sidebar/Menu.stories.tsx @@ -6,11 +6,13 @@ import { LinkIcon } from '@storybook/icons'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import type { State } from 'storybook/manager-api'; -import { expect, screen, userEvent, waitFor, within } from 'storybook/test'; +import { ManagerContext } from 'storybook/manager-api'; +import { expect, fn, screen, userEvent, waitFor, within } from 'storybook/test'; import { styled } from 'storybook/theming'; +import { initialState } from '../../../shared/checklist-store/checklistData.state'; import { useMenu } from '../../container/Menu'; +import { internal_universalChecklistStore as mockStore } from '../../manager-stores.mock'; import { LayoutProvider } from '../layout/LayoutProvider'; import { type MenuList, SidebarMenu } from './Menu'; @@ -21,6 +23,19 @@ const fakemenu: MenuList = [ ], ]; +const managerContext: any = { + state: {}, + api: { + getData: fn().mockName('api::getData'), + getIndex: fn().mockName('api::getIndex'), + getUrlState: fn().mockName('api::getUrlState'), + navigate: fn().mockName('api::navigate'), + on: fn().mockName('api::on'), + off: fn().mockName('api::off'), + once: fn().mockName('api::once'), + }, +}; + const meta = { component: SidebarMenu, title: 'Sidebar/Menu', @@ -28,7 +43,25 @@ const meta = { menu: fakemenu, }, globals: { sb_theme: 'side-by-side' }, - decorators: [(storyFn) => {storyFn()}], + decorators: [ + (storyFn) => ( + + {storyFn()} + + ), + ], + beforeEach: async () => { + mockStore.setState({ + loaded: true, + widget: {}, + items: { + ...initialState.items, + controls: { status: 'accepted' }, + renderComponent: { status: 'done' }, + viewports: { status: 'skipped' }, + }, + }); + }, } satisfies Meta; export default meta; @@ -55,9 +88,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 +97,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 +138,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 +160,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 +190,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 +199,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..b5260f63a54d 100644 --- a/code/core/src/manager/components/sidebar/Menu.tsx +++ b/code/core/src/manager/components/sidebar/Menu.tsx @@ -1,12 +1,7 @@ import type { ComponentProps, FC } from 'react'; import React, { useState } from 'react'; -import { - Button, - PopoverProvider, - ToggleButton, - TooltipLinkList, -} from 'storybook/internal/components'; +import { Button, Listbox, PopoverProvider, ToggleButton } from 'storybook/internal/components'; import { CloseIcon, CogIcon } from '@storybook/icons'; @@ -64,6 +59,10 @@ const buttonStyleAdditions = ({ `} `; +const Container = styled.div({ + minWidth: 250, +}); + export const SidebarButton = styled(Button)< ComponentProps & { highlighted: boolean; @@ -85,10 +84,51 @@ const MenuButtonGroup = styled.div({ const SidebarMenuList: FC<{ menu: MenuList; - onClick: () => void; -}> = ({ menu, onClick }) => { - return ; -}; + onHide: () => void; +}> = ({ menu, onHide }) => ( + + {menu + .filter((links) => links.length) + .flatMap((links) => ( + link.id).join('_')}> + {links.map((link) => ( + + { + if (link.disabled) { + e.preventDefault(); + return; + } + link.onClick?.(e, { + id: link.id, + active: link.active, + disabled: link.disabled, + title: link.title, + href: link.href, + }); + if (link.closeOnClick) { + onHide(); + } + }} + > + {(link.icon || link.input) && ( + {link.icon || link.input} + )} + {(link.title || link.center) && ( + {link.title || link.center} + )} + {link.right} + + + ))} + + ))} + +); export interface SidebarMenuProps { menu: MenuList; @@ -108,7 +148,6 @@ export const SidebarMenu: FC = ({ menu, isHighlighted, onClick variant="ghost" ariaLabel="About Storybook" highlighted={!!isHighlighted} - // @ts-expect-error (non strict) onClick={onClick} isMobile={true} > @@ -132,7 +171,7 @@ export const SidebarMenu: FC = ({ menu, isHighlighted, onClick } + popover={({ onHide }) => } onVisibleChange={setIsTooltipVisible} > = ({ 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..c099238e1816 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx @@ -2,13 +2,19 @@ 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 { initialState } from '../../../shared/checklist-store/checklistData.state'; +import { + internal_fullStatusStore, + internal_universalChecklistStore, +} from '../../manager-stores.mock'; import { LayoutProvider } from '../layout/LayoutProvider'; import { standardData as standardHeaderData } from './Heading.stories'; import { IconSymbols } from './IconSymbols'; @@ -40,11 +46,15 @@ const managerContext: any = { emit: fn().mockName('api::emit'), on: fn().mockName('api::on'), off: fn().mockName('api::off'), + once: fn().mockName('api::once'), + getData: fn().mockName('api::getData'), + getIndex: fn().mockName('api::getIndex'), getShortcutKeys: fn(() => ({ search: ['control', 'shift', 's'] })).mockName( 'api::getShortcutKeys' ), getChannel: fn().mockName('api::getChannel'), getElements: fn(() => ({})), + navigate: fn().mockName('api::navigate'), selectStory: fn().mockName('api::selectStory'), experimental_setFilter: fn().mockName('api::experimental_setFilter'), getDocsUrl: () => 'https://storybook.js.org/docs/', @@ -108,6 +118,16 @@ const meta = { globals: { sb_theme: 'side-by-side' }, beforeEach: () => { internal_fullStatusStore.unset(); + internal_universalChecklistStore.setState({ + loaded: true, + widget: {}, + items: { + ...initialState.items, + controls: { status: 'accepted' }, + renderComponent: { status: 'done' }, + viewports: { status: 'skipped' }, + }, + }); }, } satisfies Meta; @@ -168,6 +188,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..6c85835d920c 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 { ChecklistWidget } from './ChecklistWidget'; 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/SidebarBottom.stories.tsx b/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx index 5bc35cc6e4ca..ab447ff38d45 100644 --- a/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx +++ b/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx @@ -51,6 +51,7 @@ const managerContext: any = { api: { on: fn().mockName('api::on'), off: fn().mockName('api::off'), + once: fn().mockName('api::once'), updateTestProviderState: fn(), }, }; @@ -84,6 +85,7 @@ const meta = { api: { on: fn(), off: fn(), + once: fn(), clearNotification: fn(), updateTestProviderState: fn(), emit: fn(), diff --git a/code/core/src/manager/components/sidebar/SidebarBottom.tsx b/code/core/src/manager/components/sidebar/SidebarBottom.tsx index 4301305557c6..dd8e3896f563 100644 --- a/code/core/src/manager/components/sidebar/SidebarBottom.tsx +++ b/code/core/src/manager/components/sidebar/SidebarBottom.tsx @@ -18,7 +18,7 @@ import { styled } from 'storybook/theming'; import type { TestProviderStateByProviderId } from '../../../shared/test-provider-store'; import { NotificationList } from '../notifications/NotificationList'; -import { TestingModule } from './TestingModule'; +import { TestingWidget } from './TestingWidget'; // This ID is used dynamically add/remove space at the bottom to prevent overlapping the main sidebar content. const SIDEBAR_BOTTOM_SPACER_ID = 'sidebar-bottom-spacer'; @@ -56,22 +56,23 @@ const Spacer = styled.div({ const Content = styled.div(({ theme }) => ({ position: 'absolute', + zIndex: 1, bottom: 0, left: 0, right: 0, - padding: '12px 0', - margin: '0 12px', + padding: 12, display: 'flex', flexDirection: 'column', gap: 12, color: theme.color.defaultText, fontSize: theme.typography.size.s1, - overflow: 'hidden', '&:empty': { display: 'none', }, + '--card-box-shadow': `0 1px 2px 0 rgba(0, 0, 0, 0.05), 0px -5px 20px 10px ${theme.background.app}`, + // Integrators can use these to style their custom additions '--sb-sidebar-bottom-card-background': theme.background.content, '--sb-sidebar-bottom-card-border': `1px solid ${theme.appBorderColor}`, @@ -141,7 +142,7 @@ export const SidebarBottomBase = ({ {isDevelopment && ( - (withStatusColor, margin: 3, }); -export interface StatusButtonProps extends ComponentProps { - height?: number; - width?: number; - status: StatusValue; - selectedItem?: boolean; -} +export type StatusButtonProps = ComponentProps; const StyledButton = styled(Button)<{ height?: number; diff --git a/code/core/src/manager/components/sidebar/TestingModule.tsx b/code/core/src/manager/components/sidebar/TestingModule.tsx deleted file mode 100644 index fef81281684f..000000000000 --- a/code/core/src/manager/components/sidebar/TestingModule.tsx +++ /dev/null @@ -1,426 +0,0 @@ -import React, { type SyntheticEvent, useCallback, useEffect, useRef, useState } from 'react'; - -import { once } from 'storybook/internal/client-logger'; -import { Button, ToggleButton } from 'storybook/internal/components'; -import type { - Addon_Collection, - Addon_TestProviderType, - TestProviderStateByProviderId, -} from 'storybook/internal/types'; - -import { ChevronSmallUpIcon, PlayAllHollowIcon, SweepIcon } from '@storybook/icons'; - -import { internal_fullTestProviderStore } from '#manager-stores'; -import { keyframes, styled } from 'storybook/theming'; - -import { useDynamicFavicon } from './useDynamicFavicon'; - -const DEFAULT_HEIGHT = 500; - -const spin = keyframes({ - '0%': { transform: 'rotate(0deg)' }, - '10%': { transform: 'rotate(10deg)' }, - '40%': { transform: 'rotate(170deg)' }, - '50%': { transform: 'rotate(180deg)' }, - '60%': { transform: 'rotate(190deg)' }, - '90%': { transform: 'rotate(350deg)' }, - '100%': { transform: 'rotate(360deg)' }, -}); - -const Outline = styled.div<{ - crashed: boolean; - failed: boolean; - running: boolean; - updated: boolean; -}>(({ crashed, failed, running, updated, theme }) => ({ - position: 'relative', - lineHeight: '16px', - width: '100%', - padding: 1, - overflow: 'hidden', - backgroundColor: `var(--sb-sidebar-bottom-card-background, ${theme.background.content})`, - borderRadius: `var(--sb-sidebar-bottom-card-border-radius, ${theme.appBorderRadius + 1}px)`, - boxShadow: `inset 0 0 0 1px ${crashed && !running ? theme.color.negative : updated ? theme.color.positive : theme.appBorderColor}, var(--sb-sidebar-bottom-card-box-shadow, 0 1px 2px 0 rgba(0, 0, 0, 0.05), 0px -5px 20px 10px ${theme.background.app})`, - transition: 'box-shadow 1s', - - '&:after': { - content: '""', - display: running ? 'block' : 'none', - position: 'absolute', - left: '50%', - top: '50%', - marginLeft: 'calc(max(100vw, 100vh) * -0.5)', - marginTop: 'calc(max(100vw, 100vh) * -0.5)', - height: 'max(100vw, 100vh)', - width: 'max(100vw, 100vh)', - animation: `${spin} 3s linear infinite`, - background: failed - ? // Hardcoded colors to prevent themes from messing with them (orange+gold, secondary+seafoam) - `conic-gradient(transparent 90deg, #FC521F 150deg, #FFAE00 210deg, transparent 270deg)` - : `conic-gradient(transparent 90deg, #029CFD 150deg, #37D5D3 210deg, transparent 270deg)`, - opacity: 1, - willChange: 'auto', - }, -})); - -const Card = styled.div(({ theme }) => ({ - position: 'relative', - zIndex: 1, - borderRadius: theme.appBorderRadius, - backgroundColor: theme.background.content, - display: 'flex', - flexDirection: 'column-reverse', - - '&:hover #testing-module-collapse-toggle': { - opacity: 1, - }, -})); - -const Collapsible = styled.div(({ theme }) => ({ - overflow: 'hidden', - willChange: 'auto', - boxShadow: `inset 0 -1px 0 ${theme.appBorderColor}`, -})); - -const Content = styled.div({ - display: 'flex', - flexDirection: 'column', -}); - -const Bar = styled.div<{ onClick?: (e: SyntheticEvent) => void }>(({ onClick }) => ({ - display: 'flex', - width: '100%', - cursor: onClick ? 'pointer' : 'default', - userSelect: 'none', - alignItems: 'center', - justifyContent: 'space-between', - overflow: 'hidden', - padding: 4, - gap: 4, -})); - -const Action = styled.div({ - display: 'flex', - flexBasis: '100%', - containerType: 'inline-size', -}); - -const Filters = styled.div({ - display: 'flex', - justifyContent: 'flex-end', - gap: 4, -}); - -const CollapseToggle = styled(Button)({ - opacity: 0, - transition: 'opacity 250ms', - willChange: 'auto', - '&:focus, &:hover': { - opacity: 1, - }, -}); - -const RunButton = styled(Button)({ - // 90px is the width of the button when the label is visible - '@container (max-width: 90px)': { - span: { - display: 'none', - }, - }, -}); - -const StatusButton = styled(ToggleButton)<{ pressed: boolean; status: 'negative' | 'warning' }>( - { minWidth: 28 }, - ({ pressed, status, theme }) => - !pressed && - (theme.base === 'light' - ? { - background: { - negative: theme.background.negative, - warning: theme.background.warning, - }[status], - color: { - negative: theme.color.negativeText, - warning: theme.color.warningText, - }[status], - } - : { - background: { - negative: `${theme.color.negative}22`, - warning: `${theme.color.warning}22`, - }[status], - color: { - negative: theme.color.negative, - warning: theme.color.warning, - }[status], - }) -); - -const TestProvider = styled.div(({ theme }) => ({ - padding: 4, - - '&:not(:last-child)': { - boxShadow: `inset 0 -1px 0 ${theme.appBorderColor}`, - }, -})); - -interface TestingModuleProps { - registeredTestProviders: Addon_Collection; - testProviderStates: TestProviderStateByProviderId; - hasStatuses: boolean; - clearStatuses: () => void; - onRunAll: () => void; - errorCount: number; - errorsActive: boolean; - setErrorsActive: (active: boolean) => void; - warningCount: number; - warningsActive: boolean; - setWarningsActive: (active: boolean) => void; - successCount: number; -} - -export const TestingModule = ({ - registeredTestProviders, - testProviderStates, - hasStatuses, - clearStatuses, - onRunAll, - errorCount, - errorsActive, - setErrorsActive, - warningCount, - warningsActive, - setWarningsActive, - successCount, -}: TestingModuleProps) => { - const timeoutRef = useRef>(null); - const contentRef = useRef(null); - const [maxHeight, setMaxHeight] = useState(DEFAULT_HEIGHT); - const [isCollapsed, setCollapsed] = useState(true); - const [isChangingCollapse, setChangingCollapse] = useState(false); - const [isUpdated, setIsUpdated] = useState(false); - const settingsUpdatedTimeoutRef = useRef>(); - - useEffect(() => { - const unsubscribe = internal_fullTestProviderStore.onSettingsChanged(() => { - setIsUpdated(true); - clearTimeout(settingsUpdatedTimeoutRef.current); - settingsUpdatedTimeoutRef.current = setTimeout(() => { - setIsUpdated(false); - }, 1000); - }); - return () => { - unsubscribe(); - clearTimeout(settingsUpdatedTimeoutRef.current); - }; - }, []); - - useEffect(() => { - if (contentRef.current) { - setMaxHeight(contentRef.current?.getBoundingClientRect().height || DEFAULT_HEIGHT); - - const resizeObserver = new ResizeObserver(() => { - requestAnimationFrame(() => { - if (contentRef.current && !isCollapsed) { - const height = contentRef.current?.getBoundingClientRect().height || DEFAULT_HEIGHT; - - setMaxHeight(height); - } - }); - }); - resizeObserver.observe(contentRef.current); - return () => resizeObserver.disconnect(); - } - }, [isCollapsed]); - - const toggleCollapsed = useCallback((event?: SyntheticEvent, value?: boolean) => { - event?.stopPropagation(); - setChangingCollapse(true); - setCollapsed((s) => value ?? !s); - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - timeoutRef.current = setTimeout(() => { - setChangingCollapse(false); - }, 250); - }, []); - - const isRunning = Object.values(testProviderStates).some( - (testProviderState) => testProviderState === 'test-provider-state:running' - ); - const isCrashed = Object.values(testProviderStates).some( - (testProviderState) => testProviderState === 'test-provider-state:crashed' - ); - const hasTestProviders = Object.values(registeredTestProviders).length > 0; - - useEffect(() => { - if (isCrashed && isCollapsed) { - toggleCollapsed(undefined, false); - } - }, [isCrashed, isCollapsed, toggleCollapsed]); - - useDynamicFavicon( - isCrashed - ? 'critical' - : errorCount > 0 - ? 'negative' - : warningCount > 0 - ? 'warning' - : isRunning - ? 'active' - : successCount > 0 - ? 'positive' - : undefined - ); - - if (!hasTestProviders && !errorCount && !warningCount) { - return null; - } - - return ( - 0} - updated={isUpdated} - data-updated={isUpdated} - > - - toggleCollapsed(e) } : {})}> - - {hasTestProviders && ( - { - e.stopPropagation(); - onRunAll(); - }} - disabled={isRunning} - > - - {isRunning ? 'Running...' : 'Run tests'} - - )} - - - {hasTestProviders && ( - toggleCollapsed(e)} - id="testing-module-collapse-toggle" - ariaLabel={isCollapsed ? 'Expand testing module' : 'Collapse testing module'} - > - - - )} - - {errorCount > 0 && ( - { - e.stopPropagation(); - setErrorsActive(!errorsActive); - }} - ariaLabel={`Filter main navigation to show ${errorCount} tests with errors`} - tooltip={ - errorsActive - ? 'Clear test error filter' - : `Filter sidebar to show ${errorCount} tests with errors` - } - > - {errorCount < 1000 ? errorCount : '999+'} - - )} - {warningCount > 0 && ( - { - e.stopPropagation(); - setWarningsActive(!warningsActive); - }} - ariaLabel={`Filter main navigation to show ${warningCount} tests with warnings`} - tooltip={ - warningsActive - ? 'Clear test warning filter' - : `Filter sidebar to show ${warningCount} tests with warnings` - } - > - {warningCount < 1000 ? warningCount : '999+'} - - )} - {hasStatuses && ( - - )} - - - - {hasTestProviders && ( - - - {Object.values(registeredTestProviders).map((registeredTestProvider) => { - const { render: Render, id } = registeredTestProvider; - if (!Render) { - once.warn( - `No render function found for test provider with id '${id}', skipping...` - ); - return null; - } - return ( - - - - ); - })} - - - )} - - - ); -}; diff --git a/code/core/src/manager/components/sidebar/TestingModule.stories.tsx b/code/core/src/manager/components/sidebar/TestingWidget.stories.tsx similarity index 90% rename from code/core/src/manager/components/sidebar/TestingModule.stories.tsx rename to code/core/src/manager/components/sidebar/TestingWidget.stories.tsx index 121ec8fb3c05..feee55c2af7f 100644 --- a/code/core/src/manager/components/sidebar/TestingModule.stories.tsx +++ b/code/core/src/manager/components/sidebar/TestingWidget.stories.tsx @@ -15,7 +15,12 @@ import { expect, fireEvent, fn, waitFor } from 'storybook/test'; import { styled } from 'storybook/theming'; import { internal_fullTestProviderStore } from '../../manager-stores.mock'; -import { TestingModule } from './TestingModule'; +import { TestingWidget } from './TestingWidget'; + +const Wrapper = styled.div(({ theme }) => ({ + maxWidth: 250, + '--card-box-shadow': `0 1px 2px 0 rgba(0, 0, 0, 0.05), 0px -5px 20px 10px ${theme.background.app}`, +})); const TestProvider = styled.div({ padding: 8, @@ -60,8 +65,8 @@ const managerContext: any = { }; const meta = { - component: TestingModule, - title: 'Sidebar/TestingModule', + component: TestingWidget, + title: 'Sidebar/TestingWidget', args: { registeredTestProviders, testProviderStates, @@ -77,16 +82,15 @@ const meta = { successCount: 0, }, decorators: [ - (storyFn) => ( - {storyFn()} - ), (StoryFn) => ( -
- -
+ + + + + ), ], -} satisfies Meta; +} satisfies Meta; export default meta; diff --git a/code/core/src/manager/components/sidebar/TestingWidget.tsx b/code/core/src/manager/components/sidebar/TestingWidget.tsx new file mode 100644 index 000000000000..0978b371c69c --- /dev/null +++ b/code/core/src/manager/components/sidebar/TestingWidget.tsx @@ -0,0 +1,382 @@ +import type { ComponentProps } from 'react'; +import React, { type SyntheticEvent, useCallback, useEffect, useRef, useState } from 'react'; + +import { once } from 'storybook/internal/client-logger'; +import { Button, Card, ToggleButton } from 'storybook/internal/components'; +import type { + Addon_Collection, + Addon_TestProviderType, + TestProviderStateByProviderId, +} from 'storybook/internal/types'; + +import { ChevronSmallUpIcon, PlayAllHollowIcon, SweepIcon } from '@storybook/icons'; + +import { internal_fullTestProviderStore } from '#manager-stores'; +import { styled } from 'storybook/theming'; + +import { Optional } from '../Optional/Optional'; +import { useDynamicFavicon } from './useDynamicFavicon'; + +const DEFAULT_HEIGHT = 500; + +const HoverCard = styled(Card)({ + display: 'flex', + flexDirection: 'column-reverse', + + '&:hover #testing-module-collapse-toggle': { + opacity: 1, + }, +}); + +const Collapsible = styled.div(({ theme }) => ({ + overflow: 'hidden', + boxShadow: `inset 0 -1px 0 ${theme.appBorderColor}`, +})); + +const Content = styled.div({ + display: 'flex', + flexDirection: 'column', +}); + +const Bar = styled.div<{ onClick?: (e: SyntheticEvent) => void }>(({ onClick }) => ({ + display: 'flex', + width: '100%', + cursor: onClick ? 'pointer' : 'default', + userSelect: 'none', + alignItems: 'center', + justifyContent: 'space-between', + overflow: 'hidden', + padding: 4, + gap: 4, +})); + +const Action = styled.div({ + display: 'flex', + flexBasis: '100%', + containerType: 'inline-size', +}); + +const Filters = styled.div({ + display: 'flex', + justifyContent: 'flex-end', + gap: 4, +}); + +const CollapseToggle = styled(Button)({ + opacity: 0, + transition: 'opacity 250ms', + '&:focus, &:hover': { + opacity: 1, + }, +}); + +const RunButton = ({ children, ...props }: ComponentProps) => ( + +); + +const StatusButton = styled(ToggleButton)<{ pressed: boolean; status: 'negative' | 'warning' }>( + { minWidth: 28 }, + ({ pressed, status, theme }) => + !pressed && + (theme.base === 'light' + ? { + background: { + negative: theme.background.negative, + warning: theme.background.warning, + }[status], + color: { + negative: theme.color.negativeText, + warning: theme.color.warningText, + }[status], + } + : { + background: { + negative: `${theme.color.negative}22`, + warning: `${theme.color.warning}22`, + }[status], + color: { + negative: theme.color.negative, + warning: theme.color.warning, + }[status], + }) +); + +const TestProvider = styled.div(({ theme }) => ({ + padding: 4, + + '&:not(:last-child)': { + boxShadow: `inset 0 -1px 0 ${theme.appBorderColor}`, + }, +})); + +interface TestingModuleProps { + registeredTestProviders: Addon_Collection; + testProviderStates: TestProviderStateByProviderId; + hasStatuses: boolean; + clearStatuses: () => void; + onRunAll: () => void; + errorCount: number; + errorsActive: boolean; + setErrorsActive: (active: boolean) => void; + warningCount: number; + warningsActive: boolean; + setWarningsActive: (active: boolean) => void; + successCount: number; +} + +export const TestingWidget = ({ + registeredTestProviders, + testProviderStates, + hasStatuses, + clearStatuses, + onRunAll, + errorCount, + errorsActive, + setErrorsActive, + warningCount, + warningsActive, + setWarningsActive, + successCount, +}: TestingModuleProps) => { + const timeoutRef = useRef>(null); + const contentRef = useRef(null); + const [maxHeight, setMaxHeight] = useState(DEFAULT_HEIGHT); + const [isCollapsed, setCollapsed] = useState(true); + const [isChangingCollapse, setChangingCollapse] = useState(false); + const [isUpdated, setIsUpdated] = useState(false); + const settingsUpdatedTimeoutRef = useRef>(); + + useEffect(() => { + const unsubscribe = internal_fullTestProviderStore.onSettingsChanged(() => { + setIsUpdated(true); + clearTimeout(settingsUpdatedTimeoutRef.current); + settingsUpdatedTimeoutRef.current = setTimeout(() => { + setIsUpdated(false); + }, 1000); + }); + return () => { + unsubscribe(); + clearTimeout(settingsUpdatedTimeoutRef.current); + }; + }, []); + + useEffect(() => { + if (contentRef.current) { + setMaxHeight(contentRef.current?.getBoundingClientRect().height || DEFAULT_HEIGHT); + + const resizeObserver = new ResizeObserver(() => { + requestAnimationFrame(() => { + if (contentRef.current && !isCollapsed) { + const height = contentRef.current?.getBoundingClientRect().height || DEFAULT_HEIGHT; + + setMaxHeight(height); + } + }); + }); + resizeObserver.observe(contentRef.current); + return () => resizeObserver.disconnect(); + } + }, [isCollapsed]); + + const toggleCollapsed = useCallback((event?: SyntheticEvent, value?: boolean) => { + event?.stopPropagation(); + setChangingCollapse(true); + setCollapsed((s) => value ?? !s); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setChangingCollapse(false); + }, 250); + }, []); + + const isRunning = Object.values(testProviderStates).some( + (testProviderState) => testProviderState === 'test-provider-state:running' + ); + const isCrashed = Object.values(testProviderStates).some( + (testProviderState) => testProviderState === 'test-provider-state:crashed' + ); + const hasTestProviders = Object.values(registeredTestProviders).length > 0; + + useEffect(() => { + if (isCrashed && isCollapsed) { + toggleCollapsed(undefined, false); + } + }, [isCrashed, isCollapsed, toggleCollapsed]); + + useDynamicFavicon( + isCrashed + ? 'critical' + : errorCount > 0 + ? 'negative' + : warningCount > 0 + ? 'warning' + : isRunning + ? 'active' + : successCount > 0 + ? 'positive' + : undefined + ); + + if (!hasTestProviders && !errorCount && !warningCount) { + return null; + } + + return ( + 0) ? 'negative' : isUpdated ? 'positive' : undefined + } + > + toggleCollapsed(e) } : {})}> + + {hasTestProviders && ( + { + e.stopPropagation(); + onRunAll(); + }} + > + {isRunning ? 'Running...' : 'Run tests'} + + } + fallback={ + { + e.stopPropagation(); + onRunAll(); + }} + /> + } + /> + )} + + + {hasTestProviders && ( + toggleCollapsed(e)} + id="testing-module-collapse-toggle" + ariaLabel={isCollapsed ? 'Expand testing module' : 'Collapse testing module'} + > + + + )} + + {errorCount > 0 && ( + { + e.stopPropagation(); + setErrorsActive(!errorsActive); + }} + ariaLabel={`Filter main navigation to show ${errorCount} tests with errors`} + tooltip={ + errorsActive + ? 'Clear test error filter' + : `Filter sidebar to show ${errorCount} tests with errors` + } + > + {errorCount < 1000 ? errorCount : '999+'} + + )} + {warningCount > 0 && ( + { + e.stopPropagation(); + setWarningsActive(!warningsActive); + }} + ariaLabel={`Filter main navigation to show ${warningCount} tests with warnings`} + tooltip={ + warningsActive + ? 'Clear test warning filter' + : `Filter sidebar to show ${warningCount} tests with warnings` + } + > + {warningCount < 1000 ? warningCount : '999+'} + + )} + {hasStatuses && ( + + )} + + + + {hasTestProviders && ( + + + {Object.values(registeredTestProviders).map((registeredTestProvider) => { + const { render: Render, id } = registeredTestProvider; + if (!Render) { + once.warn( + `No render function found for test provider with id '${id}', skipping...` + ); + return null; + } + return ( + + + + ); + })} + + + )} + + ); +}; diff --git a/code/core/src/manager/components/sidebar/Tree.stories.tsx b/code/core/src/manager/components/sidebar/Tree.stories.tsx index 4dd919042eaa..006219a7afd1 100644 --- a/code/core/src/manager/components/sidebar/Tree.stories.tsx +++ b/code/core/src/manager/components/sidebar/Tree.stories.tsx @@ -27,6 +27,7 @@ const managerContext: any = { api: { on: fn().mockName('api::on'), off: fn().mockName('api::off'), + once: fn().mockName('api::once'), emit: fn().mockName('api::emit'), getShortcutKeys: fn().mockName('api::getShortcutKeys'), getCurrentStoryData: fn().mockName('api::getCurrentStoryData'), 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..5e03df924abf --- /dev/null +++ b/code/core/src/manager/components/sidebar/useChecklist.ts @@ -0,0 +1,225 @@ +import { useEffect, useMemo, useState } from 'react'; + +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 { + type API, + experimental_useUniversalStore, + useStorybookApi, + useStorybookState, +} from 'storybook/manager-api'; + +import type { ItemState } from '../../../shared/checklist-store'; +import type { ChecklistData } from '../../../shared/checklist-store/checklistData'; +import { checklistData } from '../../../shared/checklist-store/checklistData'; + +type RawItemWithSection = ChecklistData['sections'][number]['items'][number] & { + itemIndex: number; + sectionId: string; + sectionIndex: number; + sectionTitle: string; +}; + +export type ChecklistItem = RawItemWithSection & { + isAvailable: boolean; + isOpen: boolean; + isLockedBy: string | undefined; + isImmutable: boolean; + isReady: boolean; + isCompleted: boolean; + isAccepted: boolean; + isDone: boolean; + isSkipped: boolean; + isMuted: boolean; +}; + +const subscriptions = new Map void)>(); + +const useStoryIndex = () => { + const state = useStorybookState(); + const [index, setIndex] = useState(() => state.index); + const updateIndex = useMemo(() => throttle(setIndex, 500), []); + + useEffect(() => updateIndex(state.index), [state.index, updateIndex]); + useEffect(() => () => updateIndex.cancel?.(), [updateIndex]); + + return index; +}; + +const checkAvailable = ( + item: RawItemWithSection, + itemsById: Record, + context: { api: API; index: API_IndexHash | undefined; item: RawItemWithSection } +) => { + if (item.available && !item.available(context)) { + return false; + } + for (const afterId of item.after ?? []) { + if (itemsById[afterId] && !checkAvailable(itemsById[afterId], itemsById, context)) { + return false; + } + } + return true; +}; + +const checkSkipped = ( + item: RawItemWithSection, + itemsById: Record, + state: Record +) => { + const itemValue = state[item.id]; + if (itemValue.status === 'skipped') { + return true; + } + for (const afterId of item.after ?? []) { + if (itemsById[afterId] && checkSkipped(itemsById[afterId], itemsById, state)) { + return true; + } + } + return false; +}; + +const getAncestorIds = ( + item: RawItemWithSection, + itemsById: Record +): string[] => { + if (!item.after || item.after.length === 0) { + return []; + } + return item.after.flatMap((afterId) => { + const afterItem = itemsById[afterId]; + return afterItem ? [...getAncestorIds(afterItem, itemsById), afterId] : []; + }); +}; + +const checkLockedBy = ( + item: RawItemWithSection, + itemsById: Record, + state: Record +): string | undefined => + getAncestorIds(item, itemsById).find( + (id) => state[id].status !== 'accepted' && state[id].status !== 'done' + ); + +export const useChecklist = () => { + const api = useStorybookApi(); + const index = useStoryIndex(); + const [checklistState] = experimental_useUniversalStore(universalChecklistStore); + const { loaded, items, widget } = checklistState; + + const itemsById = useMemo>(() => { + return Object.fromEntries( + checklistData.sections.flatMap( + ({ items, id: sectionId, title: sectionTitle }, sectionIndex) => + items.map(({ id, ...item }, itemIndex) => { + return [id, { id, itemIndex, sectionId, sectionIndex, sectionTitle, ...item }]; + }) + ) + ); + }, []); + + const allItems = useMemo(() => { + return Object.values(itemsById).map((item) => { + const { status, mutedAt } = items[item.id]; + const isOpen = status === 'open'; + const isAccepted = status === 'accepted'; + const isDone = status === 'done'; + const isCompleted = isAccepted || isDone; + const isSkipped = !isCompleted && checkSkipped(item, itemsById, items); + const isMuted = !!mutedAt || !!widget.disable; + + const isAvailable = isCompleted + ? item.afterCompletion !== 'unavailable' + : checkAvailable(item, itemsById, { api, index, item }); + const isLockedBy = checkLockedBy(item, itemsById, items); + const isImmutable = isCompleted && item.afterCompletion === 'immutable'; + const isReady = isOpen && isAvailable && !isMuted && !isLockedBy; + + return { + ...item, + isAvailable, + isOpen, + isLockedBy, + isImmutable, + isReady, + isCompleted, + isAccepted, + isDone, + isSkipped, + isMuted, + }; + }); + }, [itemsById, items, widget, api, index]); + + const itemCollections = useMemo(() => { + const availableItems = allItems.filter((item) => item.isAvailable); + const openItems = availableItems.filter((item) => item.isOpen); + const readyItems = openItems.filter((item) => item.isReady); + + // 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 = Object.values( + readyItems.reduce>((acc, item) => { + // Reset itemIndex to only include ready items. + acc[item.sectionId] ??= []; + acc[item.sectionId].push({ ...item, itemIndex: acc[item.sectionId].length }); + return acc; + }, {}) + ) + .flat() + .sort((a, b) => a.itemIndex - b.itemIndex) + .slice(0, 3) + .sort((a, b) => a.sectionIndex - b.sectionIndex); + + const progress = availableItems.length + ? Math.round(((availableItems.length - openItems.length) / availableItems.length) * 100) + : 100; + + return { availableItems, openItems, readyItems, nextItems, progress }; + }, [allItems]); + + useEffect(() => { + if (!loaded) { + return; + } + + for (const item of allItems) { + if (!item.subscribe) { + continue; + } + + const subscribed = subscriptions.has(item.id); + if (item.isOpen && item.isAvailable && !subscribed) { + subscriptions.set( + item.id, + item.subscribe({ + api, + item, + accept: () => checklistStore.accept(item.id), + done: () => checklistStore.done(item.id), + skip: () => checklistStore.skip(item.id), + }) + ); + } else if (subscribed && !(item.isOpen && item.isAvailable)) { + const unsubscribe = subscriptions.get(item.id); + subscriptions.delete(item.id); + if (typeof unsubscribe === 'function') { + unsubscribe(); + } + } + } + }, [api, loaded, allItems]); + + return { + allItems, + ...itemCollections, + ...checklistStore, + ...checklistState, + }; +}; diff --git a/code/core/src/manager/components/useLocationHash.ts b/code/core/src/manager/components/useLocationHash.ts new file mode 100644 index 000000000000..17b6b3f0eb73 --- /dev/null +++ b/code/core/src/manager/components/useLocationHash.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react'; + +const hashMonitor = { + currentHash: globalThis.window?.location.hash ?? '', + intervalId: null as ReturnType | null, + listeners: new Set<(hash: string) => void>(), + + start() { + if (this.intervalId === null) { + this.intervalId = setInterval(() => { + const newHash = globalThis.window.location.hash ?? ''; + if (newHash !== this.currentHash) { + this.currentHash = newHash; + this.listeners.forEach((listener) => listener(newHash)); + } + }, 100); + } + }, + stop() { + if (this.intervalId !== null) { + clearInterval(this.intervalId); + this.intervalId = null; + } + }, + subscribe(...listeners: Array<(hash: string) => void>) { + listeners.forEach((listener) => this.listeners.add(listener)); + this.start(); + return () => { + listeners.forEach((listener) => this.listeners.delete(listener)); + if (this.listeners.size === 0) { + this.stop(); + } + }; + }, +}; + +export const useLocationHash = () => { + const [hash, setHash] = useState(globalThis.window?.location.hash ?? ''); + useEffect(() => hashMonitor.subscribe(setHash), []); + return hash.slice(1); +}; diff --git a/code/core/src/manager/container/Menu.stories.tsx b/code/core/src/manager/container/Menu.stories.tsx index def579e9fc0f..4c3758a574b3 100644 --- a/code/core/src/manager/container/Menu.stories.tsx +++ b/code/core/src/manager/container/Menu.stories.tsx @@ -6,6 +6,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { action } from 'storybook/actions'; +import { initialState } from '../../shared/checklist-store/checklistData.state'; +import { internal_universalChecklistStore as mockStore } from '../manager-stores.mock'; import { Shortcut } from './Menu'; const onLinkClick = action('onLinkClick'); @@ -25,6 +27,18 @@ export default {
), ], + beforeEach: async () => { + mockStore.setState({ + loaded: true, + widget: {}, + items: { + ...initialState.items, + controls: { status: 'accepted' }, + renderComponent: { status: 'done' }, + viewports: { status: 'skipped' }, + }, + }); + }, excludeStories: ['links'], } satisfies Meta; diff --git a/code/core/src/manager/container/Menu.tsx b/code/core/src/manager/container/Menu.tsx index e51f35bb1c9b..e01a5abb0060 100644 --- a/code/core/src/manager/container/Menu.tsx +++ b/code/core/src/manager/container/Menu.tsx @@ -1,35 +1,43 @@ import type { FC } from 'react'; import React, { useCallback, useMemo } from 'react'; -import { Badge } from 'storybook/internal/components'; +import { Listbox, ProgressSpinner } 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'; +import type { NormalLink } from '../../components/components/tooltip/TooltipLinkList'; +import { useChecklist } from '../components/sidebar/useChecklist'; -const focusableUIElements = { - storySearchField: 'storybook-explorer-searchfield', - storyListMenu: 'storybook-explorer-menu', - storyPanelRoot: 'storybook-panel-root', +export type MenuItem = NormalLink & { + closeOnClick?: boolean; }; 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 +49,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,53 +61,48 @@ 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; +}): MenuItem[][] => { const shortcutKeys = api.getShortcutKeys(); + const { progress } = useChecklist(); const about = useMemo( () => ({ id: 'about', title: 'About your Storybook', onClick: () => api.changeSettingsTab('about'), + closeOnClick: true, icon: , }), [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'), + closeOnClick: true, + icon: , + right: progress < 100 && ( + + + {progress}% + ), - icon: , }), - [api, whatsNewNotificationsEnabled, isWhatsNewUnread] + [api, progress] ); const shortcuts = useMemo( @@ -103,6 +110,7 @@ export const useMenu = ( id: 'shortcuts', title: 'Keyboard shortcuts', onClick: () => api.changeSettingsTab('shortcuts'), + closeOnClick: true, right: enableShortcuts ? : null, icon: , }), @@ -114,9 +122,10 @@ export const useMenu = ( id: 'S', title: 'Show sidebar', onClick: () => api.toggleNav(), + closeOnClick: true, active: isNavShown, right: enableShortcuts ? : null, - icon: isNavShown ? : null, + icon: isNavShown ? : <>, }), [api, enableShortcuts, shortcutKeys, isNavShown] ); @@ -128,7 +137,7 @@ export const useMenu = ( onClick: () => api.toggleToolbar(), active: showToolbar, right: enableShortcuts ? : null, - icon: showToolbar ? : null, + icon: showToolbar ? : <>, }), [api, enableShortcuts, shortcutKeys, showToolbar] ); @@ -140,49 +149,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 +171,7 @@ export const useMenu = ( title: 'Next component', onClick: () => api.jumpToComponent(1), right: enableShortcuts ? : null, + icon: <>, }), [api, enableShortcuts, shortcutKeys] ); @@ -203,6 +182,7 @@ export const useMenu = ( title: 'Previous story', onClick: () => api.jumpToStory(-1), right: enableShortcuts ? : null, + icon: <>, }), [api, enableShortcuts, shortcutKeys] ); @@ -213,6 +193,7 @@ export const useMenu = ( title: 'Next story', onClick: () => api.jumpToStory(1), right: enableShortcuts ? : null, + icon: <>, }), [api, enableShortcuts, shortcutKeys] ); @@ -223,10 +204,28 @@ 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', + closeOnClick: true, + href: docsUrl, + right: ( + + + + ), + icon: , + }; + }, [api]); + const getAddonsShortcuts = useCallback(() => { const addonsShortcuts = api.getAddonsShortcuts(); const keys = shortcutKeys as any; @@ -245,37 +244,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 4c5f1505a183..044da1a20654 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -330,8 +330,10 @@ export default { 'experimental_useStatusStore', 'experimental_useTestProviderStore', 'experimental_useUniversalStore', + 'internal_checklistStore', 'internal_fullStatusStore', 'internal_fullTestProviderStore', + 'internal_universalChecklistStore', 'internal_universalStatusStore', 'internal_universalTestProviderStore', 'isMacLike', @@ -489,8 +491,10 @@ export default { 'Bar', 'Blockquote', 'Button', + 'Card', 'ClipboardCode', 'Code', + 'Collapsible', 'DL', 'Div', 'DocumentWrapper', @@ -510,6 +514,7 @@ export default { 'LI', 'Link', 'ListItem', + 'Listbox', 'Loader', 'Modal', 'ModalDecorator', diff --git a/code/core/src/manager/manager-stores.mock.ts b/code/core/src/manager/manager-stores.mock.ts index 0c94203bad0e..8196f0a7cd14 100644 --- a/code/core/src/manager/manager-stores.mock.ts +++ b/code/core/src/manager/manager-stores.mock.ts @@ -4,6 +4,12 @@ import { } from 'storybook/manager-api'; import * as testUtils from 'storybook/test'; +import { + type StoreEvent, + type StoreState, + UNIVERSAL_CHECKLIST_STORE_OPTIONS, + createChecklistStore, +} from '../shared/checklist-store'; import { type StatusStoreEvent, type StatusesByStoryIdAndTypeId, @@ -42,3 +48,16 @@ export const { ) as unknown as UniversalStore, useUniversalStore: experimental_useUniversalStore, }); + +export const internal_universalChecklistStore = new experimental_MockUniversalStore< + StoreState, + StoreEvent +>( + { + ...UNIVERSAL_CHECKLIST_STORE_OPTIONS, + leader: globalThis.CONFIG_TYPE === 'PRODUCTION', + }, + testUtils +) as unknown as UniversalStore; + +export const internal_checklistStore = createChecklistStore(internal_universalChecklistStore); diff --git a/code/core/src/manager/manager-stores.ts b/code/core/src/manager/manager-stores.ts index 0a15ddd852bb..7a7fd95b145a 100644 --- a/code/core/src/manager/manager-stores.ts +++ b/code/core/src/manager/manager-stores.ts @@ -5,4 +5,6 @@ export { internal_fullTestProviderStore, experimental_getTestProviderStore, experimental_useTestProviderStore, + internal_checklistStore, + internal_universalChecklistStore, } from 'storybook/manager-api'; diff --git a/code/core/src/manager/settings/Checklist/Checklist.stories.tsx b/code/core/src/manager/settings/Checklist/Checklist.stories.tsx new file mode 100644 index 000000000000..02812912e2ba --- /dev/null +++ b/code/core/src/manager/settings/Checklist/Checklist.stories.tsx @@ -0,0 +1,85 @@ +import React from 'react'; + +import { internal_checklistStore as 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 { checklistData } from '../../../shared/checklist-store/checklistData'; +import type { ChecklistItem } from '../../components/sidebar/useChecklist'; +import { Checklist } from './Checklist'; + +const values: Record = { + controls: 'accepted', + renderComponent: 'done', + whatsNewStorybook10: 'done', + viewports: 'skipped', +}; + +const availableItems = checklistData.sections.flatMap( + ({ id: sectionId, title: sectionTitle, items }, sectionIndex) => + items.map((item, itemIndex) => { + const itemValue = values[item.id]; + const isAccepted = itemValue === 'accepted'; + const isDone = itemValue === 'done'; + const isSkipped = itemValue === 'skipped'; + const isOpen = !isAccepted && !isDone && !isSkipped; + return { + ...item, + itemIndex, + sectionId, + sectionIndex, + sectionTitle, + isAvailable: true, + isLockedBy: undefined, + isImmutable: false, + isCompleted: isAccepted || isDone, + isReady: true, + isOpen, + isAccepted, + isDone, + isSkipped, + isMuted: false, + }; + }) +); + +const Container = styled.div(({ theme }) => ({ + fontSize: theme.typography.size.s2, +})); + +const managerContext: any = { + state: {}, + api: { + getDocsUrl: fn( + ({ asset, subpath }) => + // TODO: Remove hard-coded version. Should be `major.minor` of latest release. + `https://storybook.js.org/${asset ? 'docs-assets/10.0' : 'docs'}/${subpath}` + ).mockName('api::getDocsUrl'), + getData: fn().mockName('api::getData'), + getIndex: fn().mockName('api::getIndex'), + getUrlState: fn().mockName('api::getUrlState'), + navigate: fn().mockName('api::navigate'), + on: fn().mockName('api::on'), + off: fn().mockName('api::off'), + once: fn().mockName('api::once'), + }, +}; + +const meta = preview.meta({ + component: Checklist, + decorators: [ + (Story) => ( + + + + + + ), + ], +}); + +export const Default = meta.story({ + args: { availableItems, ...checklistStore }, +}); diff --git a/code/core/src/manager/settings/Checklist/Checklist.tsx b/code/core/src/manager/settings/Checklist/Checklist.tsx new file mode 100644 index 000000000000..d19423de2069 --- /dev/null +++ b/code/core/src/manager/settings/Checklist/Checklist.tsx @@ -0,0 +1,449 @@ +import React, { createRef, useMemo } from 'react'; + +import { Button, Collapsible, Listbox } from 'storybook/internal/components'; + +import { + CheckIcon, + ChevronSmallDownIcon, + LockIcon, + StatusPassIcon, + UndoIcon, +} from '@storybook/icons'; + +import { useStorybookApi } from 'storybook/manager-api'; +import { styled } from 'storybook/theming'; + +import { Focus } from '../../components/Focus/Focus'; +import type { ChecklistItem, useChecklist } from '../../components/sidebar/useChecklist'; +import { useLocationHash } from '../../components/useLocationHash'; + +type ChecklistSection = { + id: string; + title: string; + itemIds: string[]; + progress: number; +}; + +const Sections = styled.ol(({ theme }) => ({ + listStyle: 'none', + display: 'flex', + flexDirection: 'column', + gap: 20, + margin: 0, + padding: 0, + + '& > li': { + background: theme.background.content, + border: `1px solid ${theme.color.border}`, + borderRadius: 8, + }, +})); + +const Items = styled.ol(({ theme }) => ({ + listStyle: 'none', + display: 'flex', + flexDirection: 'column', + margin: 0, + padding: 0, + + '& > li:not(:last-child)': { + boxShadow: `inset 0 -1px 0 ${theme.color.border}`, + }, + + '& > li:last-child': { + borderBottomLeftRadius: 7, + borderBottomRightRadius: 7, + }, +})); + +const SectionSummary = styled.div<{ progress: number; isCollapsed: boolean }>( + ({ theme, progress, isCollapsed, onClick }) => ({ + position: 'relative', + fontWeight: 'bold', + display: 'flex', + alignItems: 'center', + gap: 10, + padding: '10px 10px 10px 15px', + borderBottom: `5px solid ${theme.base === 'dark' ? theme.color.darker : theme.color.light}`, + borderBottomLeftRadius: isCollapsed ? 7 : 0, + borderBottomRightRadius: isCollapsed ? 7 : 0, + transition: 'border-radius var(--transition-duration, 0.2s)', + cursor: onClick ? 'pointer' : 'default', + '--toggle-button-rotate': isCollapsed ? '0deg' : '180deg', + '--toggle-button-opacity': 0, + + '&:hover, &:focus-visible': { + outline: 'none', + '--toggle-button-opacity': 1, + }, + + '&::after': { + pointerEvents: 'none', + position: 'absolute', + top: 0, + bottom: -5, + left: 0, + right: 0, + content: '""', + display: 'block', + width: `${progress}%`, + borderBottom: `5px solid ${theme.color.positive}`, + borderBottomLeftRadius: 'inherit', + borderBottomRightRadius: progress === 100 ? 'inherit' : 0, + transition: 'width var(--transition-duration, 0.2s)', + }, + }) +); + +const SectionHeading = styled.h2(({ theme }) => ({ + flex: 1, + margin: 0, + fontSize: theme.typography.size.s3, + fontWeight: theme.typography.weight.bold, +})); + +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', + }, + }) +); + +const ItemHeading = styled.h3<{ skipped: boolean }>(({ theme, skipped }) => ({ + flex: 1, + margin: 0, + color: skipped ? theme.textMutedColor : theme.color.defaultText, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: theme.typography.size.s2, + fontWeight: theme.typography.weight.bold, +})); + +const ItemContent = styled.div(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: 8, + padding: '0 15px 15px 41px', + fontSize: theme.typography.size.s2, + + code: { + fontSize: '0.9em', + backgroundColor: theme.background.app, + borderRadius: theme.appBorderRadius, + padding: '1px 3px', + }, + img: { + maxWidth: '100%', + margin: '15px auto', + }, + p: { + margin: 0, + lineHeight: 1.4, + }, + 'ol, ul': { + paddingLeft: 25, + listStyleType: 'disc', + + 'li::marker': { + color: theme.color.medium, + }, + }, +})); + +const StatusIcon = styled.div(({ theme }) => ({ + position: 'relative', + flex: '0 0 auto', + minHeight: 16, + minWidth: 16, + margin: 0, + background: theme.background.app, + borderRadius: 9, + outline: `1px solid ${theme.color.border}`, + outlineOffset: -1, +})); +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, + fontSize: '12px', + fontWeight: 'bold', + overflow: 'hidden', + padding: visible ? '0 10px' : 0, + opacity: visible ? 1 : 0, + width: visible ? 'auto' : 0, + height: visible ? 18 : 16, + transition: 'all var(--transition-duration, 0.2s)', +})); + +const Actions = styled.div({ + alignSelf: 'flex-end', + flexDirection: 'row-reverse', + display: 'flex', + gap: 4, +}); + +const ToggleButton = styled(Button)({ + opacity: 'var(--toggle-button-opacity)', + transition: 'opacity var(--transition-duration, 0.2s)', + + '&:hover, &:focus': { + opacity: 1, + }, + + svg: { + transform: 'rotate(var(--toggle-button-rotate))', + transition: 'transform var(--transition-duration, 0.2s)', + }, +}); + +export const Checklist = ({ + availableItems, + accept, + skip, + reset, +}: Pick, 'availableItems' | 'accept' | 'skip' | 'reset'>) => { + const api = useStorybookApi(); + const locationHash = useLocationHash(); + + const { itemsById, sectionsById } = useMemo( + () => + availableItems.reduce<{ + itemsById: Record; + sectionsById: Record; + }>( + (acc, item) => { + acc.itemsById[item.id] = item; + const { sectionId: id, sectionTitle: title } = item; + acc.sectionsById[id] = acc.sectionsById[id] ?? { id, title, itemIds: [] }; + acc.sectionsById[id].itemIds.push(item.id); + return acc; + }, + { itemsById: {}, sectionsById: {} } + ), + [availableItems] + ); + + const sections = useMemo( + () => + Object.values(sectionsById).map(({ id, title, itemIds }) => { + const items = itemIds.map((id) => itemsById[id]); + const progress = + (items.reduce((acc, item) => (item.isOpen ? acc : acc + 1), 0) / items.length) * 100; + return { id, title, items, progress }; + }), + [itemsById, sectionsById] + ); + + const next = useMemo( + () => + Object.values(sections).findIndex(({ items }) => + items.some((item) => item.isOpen && item.isAvailable) + ), + [sections] + ); + + return ( + + {sections.map(({ id, title, items, progress }, index) => { + const hasTarget = items.some((item) => item.id === locationHash); + const collapsed = !hasTarget && (progress === 0 || progress === 100) && next !== index; + + return ( +
  • + + ( + + + + + {title} + + + + + + + )} + > + + {items.map( + ({ + content, + isOpen, + isAccepted, + isDone, + isLockedBy, + isImmutable, + isSkipped, + ...item + }) => { + const isChecked = isAccepted || isDone; + const isCollapsed = isChecked && item.id !== locationHash; + const isLocked = !!isLockedBy; + const itemContent = content?.({ api }); + + return ( + + + + ( + + + + Skipped + + {item.label} + + {itemContent && ( + + + + )} + {isLocked && ( + + )} + {isOpen && !isLocked && item.action && ( + + )} + {isOpen && !isLocked && !item.action && !item.subscribe && ( + + )} + {isOpen && !isLocked && ( + + )} + {((isAccepted && !isImmutable) || isSkipped) && !isLocked && ( + + )} + + + )} + > + {itemContent && {itemContent}} + + + + + ); + } + )} + + + +
  • + ); + })} +
    + ); +}; 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..b4bfe6f4a6c7 --- /dev/null +++ b/code/core/src/manager/settings/GuidePage.stories.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { ManagerContext } from 'storybook/manager-api'; +import { fn } from 'storybook/test'; + +import preview from '../../../../.storybook/preview'; +import { initialState } from '../../shared/checklist-store/checklistData.state'; +import { internal_universalChecklistStore as mockStore } from '../manager-stores.mock'; +import { GuidePage } from './GuidePage'; + +const managerContext: any = { + state: {}, + api: { + getDocsUrl: fn( + ({ asset, subpath }) => + // TODO: Remove hard-coded version. Should be `major.minor` of latest release. + `https://storybook.js.org/${asset ? 'docs-assets/10.0' : 'docs'}/${subpath}` + ).mockName('api::getDocsUrl'), + getData: fn().mockName('api::getData'), + getIndex: fn().mockName('api::getIndex'), + getUrlState: fn().mockName('api::getUrlState'), + navigate: fn().mockName('api::navigate'), + on: fn().mockName('api::on'), + off: fn().mockName('api::off'), + once: fn().mockName('api::once'), + }, +}; + +const meta = preview.meta({ + component: GuidePage, + decorators: [ + (Story) => ( + + + + ), + ], + beforeEach: async () => { + mockStore.setState({ + loaded: true, + widget: {}, + items: { + ...initialState.items, + controls: { status: 'accepted' }, + renderComponent: { status: 'done' }, + viewports: { status: 'skipped' }, + }, + }); + }, +}); + +export const Default = meta.story({}); diff --git a/code/core/src/manager/settings/GuidePage.tsx b/code/core/src/manager/settings/GuidePage.tsx new file mode 100644 index 000000000000..a81133b412d1 --- /dev/null +++ b/code/core/src/manager/settings/GuidePage.tsx @@ -0,0 +1,67 @@ +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'; + +const Container = styled.div(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + maxWidth: 600, + margin: '0 auto', + padding: '48px 20px', + gap: 32, + fontSize: theme.typography.size.s2, + '--transition-duration': '0.2s', +})); + +const Intro = styled.div(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: 8, + + '& h1': { + fontSize: theme.typography.size.m3, + fontWeight: theme.typography.weight.bold, + margin: 0, + }, + + '& > p': { + margin: 0, + }, +})); + +export const GuidePage = () => { + const checklist = useChecklist(); + + return ( + + +

    Guide

    +

    + Whether you're just getting started or looking for ways to level up, this checklist + will help you make the most of your Storybook. +

    +
    + + {checklist.openItems.length === 0 ? ( +
    Your work here is done!
    + ) : checklist.widget.disable || checklist.openItems.every((item) => item.isMuted) ? ( +
    + Want to see this in the sidebar?{' '} + checklist.disable(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 c91bce32a778..6a75d4aa78c2 100644 --- a/code/core/src/manager/settings/index.tsx +++ b/code/core/src/manager/settings/index.tsx @@ -13,6 +13,7 @@ import { styled } from 'storybook/theming'; import { matchesKeyCode, matchesModifiers } from '../keybinding'; import { AboutPage } from './AboutPage'; +import { GuidePage } from './GuidePage'; import { ShortcutsPage } from './ShortcutsPage'; import { WhatsNewPage } from './whats_new_page'; @@ -63,13 +64,13 @@ const Pages: FC<{ }, ]; - if (enableWhatsNew) { + if (global.CONFIG_TYPE === 'DEVELOPMENT') { tabsToInclude.push({ - id: 'whats-new', - title: "What's new?", + id: 'guide', + title: 'Guide', children: ( - - + + ), }); @@ -85,6 +86,18 @@ const Pages: FC<{ ), }); + if (enableWhatsNew) { + tabsToInclude.push({ + id: 'whats-new', + title: "What's new?", + children: ( + + + + ), + }); + } + return tabsToInclude; }, [enableWhatsNew]); diff --git a/code/core/src/manager/settings/whats_new.tsx b/code/core/src/manager/settings/whats_new.tsx index f4002035dae0..b30573bbf36e 100644 --- a/code/core/src/manager/settings/whats_new.tsx +++ b/code/core/src/manager/settings/whats_new.tsx @@ -34,12 +34,12 @@ const Message = styled.div(({ theme }) => ({ const Container = styled.div(({ theme }) => ({ position: 'absolute', width: '100%', - bottom: '0px', + height: 40, + bottom: 0, background: theme.background.bar, - fontSize: `13px`, - borderTop: '1px solid', - borderColor: theme.appBorderColor, - padding: '8px 12px', + fontSize: theme.typography.size.s2, + borderTop: `1px solid ${theme.color.border}`, + padding: '0 10px 0 15px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', diff --git a/code/core/src/preview-api/modules/preview-web/UrlStore.test.ts b/code/core/src/preview-api/modules/preview-web/UrlStore.test.ts index 3ee48adc2222..baf3c1c90236 100644 --- a/code/core/src/preview-api/modules/preview-web/UrlStore.test.ts +++ b/code/core/src/preview-api/modules/preview-web/UrlStore.test.ts @@ -72,6 +72,24 @@ describe('UrlStore', () => { viewMode: 'story', }); }); + it('should handle viewMode=story', () => { + document.location.search = '?id=story--id&viewMode=story'; + expect(getSelectionSpecifierFromPath()).toEqual({ + storySpecifier: 'story--id', + viewMode: 'story', + }); + }); + it('should handle viewMode=docs', () => { + document.location.search = '?id=story--id&viewMode=docs'; + expect(getSelectionSpecifierFromPath()).toEqual({ + storySpecifier: 'story--id', + viewMode: 'docs', + }); + }); + it('should ignore unsupported viewModes', () => { + document.location.search = '?id=about&viewMode=somethingelse'; + expect(getSelectionSpecifierFromPath()).toEqual(null); + }); it('should handle id queries with *', () => { document.location.search = '?id=*'; expect(getSelectionSpecifierFromPath()).toEqual({ diff --git a/code/core/src/preview-api/modules/preview-web/UrlStore.ts b/code/core/src/preview-api/modules/preview-web/UrlStore.ts index aca027c9561e..1b2c079d1748 100644 --- a/code/core/src/preview-api/modules/preview-web/UrlStore.ts +++ b/code/core/src/preview-api/modules/preview-web/UrlStore.ts @@ -74,8 +74,10 @@ export const getSelectionSpecifierFromPath: () => SelectionSpecifier | null = () const globals = typeof query.globals === 'string' ? parseArgsParam(query.globals) : undefined; let viewMode = getFirstString(query.viewMode) as ViewMode; - if (typeof viewMode !== 'string' || !viewMode.match(/docs|story/)) { + if (typeof viewMode !== 'string' || !viewMode) { viewMode = 'story'; + } else if (!viewMode.match(/docs|story/)) { + return null; } const path = getFirstString(query.path); diff --git a/code/core/src/shared/checklist-store/checklistData.state.ts b/code/core/src/shared/checklist-store/checklistData.state.ts new file mode 100644 index 000000000000..910a218770ee --- /dev/null +++ b/code/core/src/shared/checklist-store/checklistData.state.ts @@ -0,0 +1,29 @@ +import type { StoreState } from '.'; + +export const initialState = { + items: { + accessibilityTests: { status: 'open' }, + autodocs: { status: 'open' }, + ciTests: { status: 'open' }, + controls: { status: 'open' }, + coverage: { status: 'open' }, + guidedTour: { status: 'open' }, + installA11y: { status: 'open' }, + installChromatic: { status: 'open' }, + installDocs: { status: 'open' }, + installVitest: { status: 'open' }, + mdxDocs: { status: 'open' }, + moreComponents: { status: 'open' }, + moreStories: { status: 'open' }, + onboardingSurvey: { status: 'open' }, + organizeStories: { status: 'open' }, + publishStorybook: { status: 'open' }, + renderComponent: { status: 'open' }, + runTests: { status: 'open' }, + viewports: { status: 'open' }, + visualTests: { status: 'open' }, + whatsNewStorybook10: { status: 'open' }, + writeInteractions: { status: 'open' }, + }, + widget: {}, +} as const satisfies StoreState; diff --git a/code/core/src/shared/checklist-store/checklistData.tsx b/code/core/src/shared/checklist-store/checklistData.tsx new file mode 100644 index 000000000000..20c5d23bdf22 --- /dev/null +++ b/code/core/src/shared/checklist-store/checklistData.tsx @@ -0,0 +1,1211 @@ +import type { ComponentProps } from 'react'; +import React from 'react'; + +import { Link, SyntaxHighlighter } from 'storybook/internal/components'; +import { + PREVIEW_INITIALIZED, + STORY_ARGS_UPDATED, + STORY_FINISHED, + STORY_INDEX_INVALIDATED, + UPDATE_GLOBALS, +} from 'storybook/internal/core-events'; +import type { + API_IndexHash, + API_PreparedIndexEntry, + API_StoryEntry, +} from 'storybook/internal/types'; + +import { type API, addons, internal_universalTestProviderStore } from 'storybook/manager-api'; +import { ThemeProvider, convert, styled, themes } from 'storybook/theming'; + +import { ADDON_ID as ADDON_A11Y_ID } from '../../../../addons/a11y/src/constants'; +import { + ADDON_ONBOARDING_CHANNEL, + ADDON_ID as ADDON_ONBOARDING_ID, +} from '../../../../addons/onboarding/src/constants'; +import { + ADDON_ID as ADDON_TEST_ID, + STORYBOOK_ADDON_TEST_CHANNEL, +} from '../../../../addons/vitest/src/constants'; +import { ADDON_ID as ADDON_DOCS_ID } from '../../docs-tools/shared'; +import { TourGuide } from '../../manager/components/TourGuide/TourGuide'; +import type { initialState } from './checklistData.state'; + +const CodeWrapper = styled.div(({ theme }) => ({ + alignSelf: 'stretch', + background: theme.background.content, + borderRadius: theme.appBorderRadius, + margin: '5px 0', + padding: 10, + fontSize: theme.typography.size.s1, + '.linenumber': { + opacity: 0.5, + }, +})); + +const CodeSnippet = (props: ComponentProps) => ( + + + + + +); + +type ItemId = keyof (typeof initialState)['items']; + +export interface ChecklistData { + sections: readonly { + id: string; + title: string; + items: readonly { + /** Unique identifier for persistence. Update when making significant changes. */ + id: ItemId; + + /** Display name. Keep it short and actionable (with a verb). */ + label: string; + + /** Description of the criteria that must be met to complete the item. */ + criteria: string; + + /** Items that must be completed before this item can be completed (locked until then). */ + after?: readonly ItemId[]; + + /** What to do after the item is completed (prevent undo or hide the item). */ + afterCompletion?: 'immutable' | 'unavailable'; + + /** + * Function to check if the item should be available (displayed in the checklist). Called any + * time the index is updated. + */ + available?: (args: { + api: API; + index: API_IndexHash | undefined; + item: ChecklistData['sections'][number]['items'][number]; + }) => boolean; + + /** Function returning content to display in the checklist item's collapsible area. */ + content?: (args: { api: API }) => React.ReactNode; + + /** Action button to be displayed when item is not completed. */ + action?: { + label: string; + onClick: (args: { api: API; accept: () => void }) => void; + }; + + /** + * Function to subscribe to events and update the item's state. May return a function to + * unsubscribe once the item is completed. + */ + subscribe?: (args: { + api: API; + item: ChecklistData['sections'][number]['items'][number]; + + /** + * Call this to complete the item and persist to user-local storage. This is preferred when + * dealing with user-specific criteria (e.g. learning goals). + */ + accept: () => void; + + /** + * Call this to complete the item and persist to project-local storage. This is preferred + * when dealing with project-specific criteria (e.g. component count). + */ + done: () => void; + + /** Call this to skip the item and persist to user-local storage. */ + skip: () => void; + }) => void | (() => void); + }[]; + }[]; +} + +const subscribeToIndex: ( + condition: (entries: Record) => boolean +) => ChecklistData['sections'][number]['items'][number]['subscribe'] = + (condition) => + ({ api, done }) => { + const check = () => condition(api.getIndex()?.entries || {}); + if (check()) { + done(); + } else { + api.once(PREVIEW_INITIALIZED, () => check() && done()); + return api.on(STORY_INDEX_INVALIDATED, () => check() && done()); + } + }; + +export const checklistData = { + sections: [ + { + id: 'basics', + title: 'Storybook basics', + items: [ + { + id: 'guidedTour', + label: 'Take the guided tour', + available: ({ index }) => + !!index && + 'example-button--primary' in index && + addons.experimental_getRegisteredAddons().includes(ADDON_ONBOARDING_ID), + criteria: 'Guided tour is completed', + subscribe: ({ api, accept }) => + api.on(ADDON_ONBOARDING_CHANNEL, ({ step, type }) => { + if (type !== 'dismiss' && ['6:IntentSurvey', '7:FinishedOnboarding'].includes(step)) { + accept(); + } + }), + action: { + label: 'Start', + onClick: ({ api }) => { + const path = api.getUrlState().path || ''; + if (path.startsWith('/story/')) { + document.location.href = `/?path=${path}&onboarding=true`; + } else { + document.location.href = `/?onboarding=true`; + } + }, + }, + }, + { + id: 'onboardingSurvey', + label: 'Complete the onboarding survey', + available: () => addons.experimental_getRegisteredAddons().includes(ADDON_ONBOARDING_ID), + afterCompletion: 'immutable', + criteria: 'Onboarding survey is completed', + subscribe: ({ api, accept }) => + api.on(ADDON_ONBOARDING_CHANNEL, ({ type }) => type === 'survey' && accept()), + action: { + label: 'Open', + onClick: ({ api }) => { + const path = api.getUrlState().path || ''; + document.location.href = `/?path=${path}&onboarding=survey`; + }, + }, + }, + { + id: 'renderComponent', + label: 'Render a component', + criteria: 'A story finished rendering successfully', + subscribe: ({ api, done }) => + api.on(STORY_FINISHED, ({ status }) => status === 'success' && done()), + content: ({ api }) => ( + <> +

    + Storybook renders your components in isolation, using stories. That allows you to + work on the bit of UI you need, without worrying about the rest of the app. +

    +

    + Rendering your components can often require{' '} + + setting up surrounding context in decorators + {' '} + or{' '} + + applying global styles + + . Once you've got it working for one component, you're ready to make + Storybook the home for all of your UI. +

    +

    + Stories are written in CSF, a format specifically designed to help with UI + development. Here's an example: +

    + {/* TODO: Non-React snippets? TS vs. JS? */} + + {`// Button.stories.ts +// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc. +import type { Meta, StoryObj } from '@storybook/your-framework'; + +import { Button } from './Button'; + +const meta = { + // πŸ‘‡ The component you're working on + component: Button, +} satisfies Meta; + +export default meta; +// πŸ‘‡ Type helper to reduce boilerplate +type Story = StoryObj; + +// πŸ‘‡ A story named Primary that renders \`