diff --git a/code/addons/controls/src/SaveStory.tsx b/code/addons/controls/src/SaveStory.tsx index 8f72826c6460..e69a90f65756 100644 --- a/code/addons/controls/src/SaveStory.tsx +++ b/code/addons/controls/src/SaveStory.tsx @@ -137,7 +137,7 @@ export const SaveStory = ({ saveStory, createStory, resetArgs }: SaveStoryProps) }; return ( - + -
+ Create new story diff --git a/code/addons/onboarding/package.json b/code/addons/onboarding/package.json index 2cf534e43820..6209051b32d7 100644 --- a/code/addons/onboarding/package.json +++ b/code/addons/onboarding/package.json @@ -63,7 +63,7 @@ "framer-motion": "^11.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-joyride": "^2.7.2", + "react-joyride": "^2.8.2", "react-use-measure": "^2.1.1", "typescript": "^5.3.2" }, diff --git a/code/addons/onboarding/src/App.tsx b/code/addons/onboarding/src/App.tsx deleted file mode 100644 index e561c55cea1c..000000000000 --- a/code/addons/onboarding/src/App.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { ThemeProvider, convert } from '@storybook/theming'; -import { addons, type API } from '@storybook/manager-api'; - -import { GuidedTour } from './features/GuidedTour/GuidedTour'; -import { WelcomeModal } from './features/WelcomeModal/WelcomeModal'; -import { WriteStoriesModal } from './features/WriteStoriesModal/WriteStoriesModal'; -import { Confetti } from './components/Confetti/Confetti'; -import { STORYBOOK_ADDON_ONBOARDING_CHANNEL } from './constants'; -import { useGetProject } from './features/WriteStoriesModal/hooks/useGetProject'; - -type Step = - | '1:Welcome' - | '2:StorybookTour' - | '3:WriteYourStory' - | '4:VisitNewStory' - | '5:ConfigureYourProject'; - -const theme = convert(); - -export default function App({ api }: { api: API }) { - const [enabled, setEnabled] = useState(true); - const [showConfetti, setShowConfetti] = useState(false); - const [step, setStep] = useState('1:Welcome'); - const { data: codeSnippets } = useGetProject(); - - const skipOnboarding = 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); - }, [setEnabled, api]); - - useEffect(() => { - api.emit(STORYBOOK_ADDON_ONBOARDING_CHANNEL, { - step: '1:Welcome', - type: 'telemetry', - }); - }, []); - - useEffect(() => { - if (step !== '1:Welcome') { - api.emit(STORYBOOK_ADDON_ONBOARDING_CHANNEL, { - step, - type: 'telemetry', - }); - } - }, [api, step]); - - useEffect(() => { - let stepTimeout: number; - if (step === '4:VisitNewStory') { - setShowConfetti(true); - stepTimeout = window.setTimeout(() => { - setStep('5:ConfigureYourProject'); - }, 2000); - } - - return () => { - clearTimeout(stepTimeout); - }; - }, [step]); - - useEffect(() => { - const storyId = api.getCurrentStoryData()?.id; - api.setQueryParams({ onboarding: 'true' }); - // make sure the initial state is set correctly: - // 1. Selected story is primary button - // 2. The addon panel is opened, in the bottom and the controls tab is selected - if (storyId !== 'example-button--primary') { - try { - api.selectStory('example-button--primary', undefined, { - ref: undefined, - }); - } catch (e) { - // - } - } - }, []); - - if (!enabled) { - return null; - } - - return ( - - {enabled && showConfetti && ( - { - confetti?.reset(); - setShowConfetti(false); - }} - /> - )} - {enabled && step === '1:Welcome' && ( - { - setStep('2:StorybookTour'); - }} - skipOnboarding={() => { - skipOnboarding(); - - api.emit(STORYBOOK_ADDON_ONBOARDING_CHANNEL, { - step: 'X:SkippedOnboarding', - where: 'WelcomeModal', - type: 'telemetry', - }); - }} - /> - )} - {enabled && (step === '2:StorybookTour' || step === '5:ConfigureYourProject') && ( - { - setStep('3:WriteYourStory'); - }} - codeSnippets={codeSnippets || undefined} - onLastTourDone={() => { - try { - api.selectStory('configure-your-project--docs'); - } catch (e) { - // - } - api.emit(STORYBOOK_ADDON_ONBOARDING_CHANNEL, { - step: '6:FinishedOnboarding', - type: 'telemetry', - }); - skipOnboarding(); - }} - /> - )} - {enabled && step === '3:WriteYourStory' && codeSnippets && ( - { - api.selectStory('example-button--warning'); - - setStep('4:VisitNewStory'); - }} - skipOnboarding={skipOnboarding} - /> - )} - - ); -} diff --git a/code/addons/onboarding/src/Onboarding.tsx b/code/addons/onboarding/src/Onboarding.tsx new file mode 100644 index 000000000000..6c87566a306b --- /dev/null +++ b/code/addons/onboarding/src/Onboarding.tsx @@ -0,0 +1,276 @@ +import { SyntaxHighlighter } from '@storybook/components'; +import { SAVE_STORY_RESPONSE } from '@storybook/core-events'; +import { type API } from '@storybook/manager-api'; +import { ThemeProvider, convert, styled, themes } from '@storybook/theming'; +import React, { useCallback, useEffect, useState } from 'react'; +import type { Step } from 'react-joyride'; + +import { GuidedTour } from './features/GuidedTour/GuidedTour'; +import { Confetti } from './components/Confetti/Confetti'; +import type { STORYBOOK_ADDON_ONBOARDING_STEPS } from './constants'; +import { STORYBOOK_ADDON_ONBOARDING_CHANNEL } from './constants'; + +import { HighlightElement } from './components/HighlightElement/HighlightElement'; +import { SplashScreen } from './features/SplashScreen/SplashScreen'; + +const SpanHighlight = styled.span(({ theme }) => ({ + display: 'inline-flex', + borderRadius: 3, + padding: '0 5px', + marginBottom: -2, + opacity: 0.8, + fontFamily: theme.typography.fonts.mono, + fontSize: 11, + border: theme.base === 'dark' ? theme.color.darkest : theme.color.lightest, + color: theme.base === 'dark' ? theme.color.lightest : theme.color.darkest, + backgroundColor: theme.base === 'dark' ? 'black' : theme.color.light, + boxSizing: 'border-box', + lineHeight: '17px', +})); + +const CodeWrapper = styled.div(({ theme }) => ({ + background: theme.background.content, + borderRadius: 3, + marginTop: 15, + padding: 10, + fontSize: theme.typography.size.s1, + '.linenumber': { + opacity: 0.5, + }, +})); + +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 }) { + const [enabled, setEnabled] = useState(true); + const [showConfetti, setShowConfetti] = useState(false); + const [step, setStep] = useState('1:Intro'); + + const [primaryControl, setPrimaryControl] = useState(); + const [saveFromControls, setSaveFromControls] = useState(); + const [createNewStoryForm, setCreateNewStoryForm] = useState(); + const [createdStory, setCreatedStory] = useState<{ + newStoryName: string; + sourceFileContent: string; + sourceFileName: string; + } | null>(); + + const selectStory = useCallback( + (storyId: string) => { + try { + const { id, refId } = api.getCurrentStoryData() || {}; + if (id !== storyId || refId !== undefined) api.selectStory(storyId); + } catch (e) {} + }, + [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 completeOnboarding = useCallback(() => { + api.emit(STORYBOOK_ADDON_ONBOARDING_CHANNEL, { + step: '6:FinishedOnboarding' satisfies StepKey, + type: 'telemetry', + }); + selectStory('configure-your-project--docs'); + disableOnboarding(); + }, [api, selectStory, disableOnboarding]); + + useEffect(() => { + api.setQueryParams({ onboarding: 'true' }); + selectStory('example-button--primary'); + api.togglePanel(true); + api.togglePanelPosition('bottom'); + api.setSelectedPanel('addon-controls'); + }, [api, selectStory]); + + useEffect(() => { + const observer = new MutationObserver(() => { + setPrimaryControl(document.getElementById('control-primary')); + setSaveFromControls(document.getElementById('save-from-controls')); + setCreateNewStoryForm(document.getElementById('create-new-story-form')); + }); + + observer.observe(document.body, { childList: true, subtree: true }); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + setStep((current) => { + if (['1:Intro', '5:StoryCreated', '6:FinishedOnboarding'].includes(current)) return current; + if (createNewStoryForm) return '4:CreateStory'; + if (saveFromControls) return '3:SaveFromControls'; + if (primaryControl) return '2:Controls'; + return '1:Intro'; + }); + }, [createNewStoryForm, primaryControl, saveFromControls]); + + useEffect(() => { + return api.on(SAVE_STORY_RESPONSE, ({ payload, success }) => { + if (!success || !payload?.newStoryName) return; + setCreatedStory(payload); + setShowConfetti(true); + setStep('5:StoryCreated'); + setTimeout(() => api.clearNotification('save-story-success')); + }); + }, [api]); + + useEffect( + () => api.emit(STORYBOOK_ADDON_ONBOARDING_CHANNEL, { step, type: 'telemetry' }), + [api, step] + ); + + if (!enabled) { + return null; + } + + const source = createdStory?.sourceFileContent; + const startIndex = source?.lastIndexOf(`export const ${createdStory?.newStoryName}`); + const snippet = source?.slice(startIndex); + const startingLineNumber = source?.slice(0, startIndex).split('\n').length; + + const steps: StepDefinition[] = [ + { + key: '2:Controls', + target: '#control-primary', + title: 'Interactive story playground', + content: ( + <> + See how a story renders with different data and state without touching code. Try it out by + toggling this button. + + + ), + offset: 20, + placement: 'right', + disableBeacon: true, + disableOverlay: true, + spotlightClicks: true, + onNextButtonClick: () => { + const input = document.querySelector('#control-primary') as HTMLInputElement; + input.click(); + }, + }, + { + key: '3:SaveFromControls', + target: 'button[aria-label="Create new story with these settings"]', + title: 'Save your changes as a new story', + content: ( + <> + Great! Storybook stories represent the key states of each of your components. After + modifying a story, you can save your changes from here or reset it. + + + ), + offset: 6, + placement: 'top', + disableBeacon: true, + disableOverlay: true, + spotlightClicks: true, + onNextButtonClick: () => { + const button = document.querySelector( + 'button[aria-label="Create new story with these settings"]' + ) as HTMLButtonElement; + button.click(); + }, + styles: { + tooltip: { + width: 400, + }, + }, + }, + { + key: '5:StoryCreated', + target: '#storybook-explorer-tree [data-selected="true"]', + title: 'You just added your first story!', + content: ( + <> + Well done! You just created your first story from the Storybook manager. This + automatically added a few lines of code in{' '} + {createdStory?.sourceFileName}. + {snippet && ( + + + + {snippet} + + + + )} + + ), + offset: 12, + placement: 'right', + disableBeacon: true, + disableOverlay: true, + styles: { + tooltip: { + width: 400, + }, + }, + }, + ] as const; + + return ( + + {showConfetti && ( + { + confetti?.reset(); + setShowConfetti(false); + }} + /> + )} + {step === '1:Intro' ? ( + setStep('2:Controls')} /> + ) : ( + + )} + + ); +} diff --git a/code/addons/onboarding/src/components/Button/Button.tsx b/code/addons/onboarding/src/components/Button/Button.tsx index 622359c4e31a..699554e1169b 100644 --- a/code/addons/onboarding/src/components/Button/Button.tsx +++ b/code/addons/onboarding/src/components/Button/Button.tsx @@ -5,7 +5,7 @@ import { styled } from '@storybook/theming'; export interface ButtonProps extends ComponentProps<'button'> { children: string; onClick?: (e: React.MouseEvent) => void; - variant?: 'primary' | 'secondary' | 'outline'; + variant?: 'primary' | 'secondary' | 'outline' | 'white'; } const StyledButton = styled.button<{ variant: ButtonProps['variant'] }>` @@ -22,16 +22,17 @@ const StyledButton = styled.button<{ variant: ButtonProps['variant'] }>` if (variant === 'primary') return theme.color.secondary; if (variant === 'secondary') return theme.color.lighter; if (variant === 'outline') return 'transparent'; + if (variant === 'white') return theme.color.lightest; return theme.color.secondary; }}; color: ${({ theme, variant }) => { if (variant === 'primary') return theme.color.lightest; if (variant === 'secondary') return theme.darkest; if (variant === 'outline') return theme.darkest; + if (variant === 'white') return theme.color.secondary; return theme.color.lightest; }}; box-shadow: ${({ variant }) => { - if (variant === 'primary') return 'none'; if (variant === 'secondary') return '#D9E8F2 0 0 0 1px inset'; if (variant === 'outline') return '#D9E8F2 0 0 0 1px inset'; return 'none'; @@ -40,18 +41,26 @@ const StyledButton = styled.button<{ variant: ButtonProps['variant'] }>` font-size: 0.8125rem; font-weight: 700; font-family: ${({ theme }) => theme.typography.fonts.base}; - transition: background-color, box-shadow, opacity; + transition: background-color, box-shadow, color, opacity; transition-duration: 0.16s; transition-timing-function: ease-in-out; text-decoration: none; &:hover { - background-color: ${({ variant }) => { + background-color: ${({ theme, variant }) => { if (variant === 'primary') return '#0b94eb'; if (variant === 'secondary') return '#eef4f9'; if (variant === 'outline') return 'transparent'; + if (variant === 'white') return theme.color.lightest; return '#0b94eb'; }}; + color: ${({ theme, variant }) => { + if (variant === 'primary') return theme.color.lightest; + if (variant === 'secondary') return theme.darkest; + if (variant === 'outline') return theme.darkest; + if (variant === 'white') return theme.color.darkest; + return theme.color.lightest; + }}; } &:focus { @@ -59,6 +68,7 @@ const StyledButton = styled.button<{ variant: ButtonProps['variant'] }>` if (variant === 'primary') return 'inset 0 0 0 1px rgba(0, 0, 0, 0.2)'; if (variant === 'secondary') return 'inset 0 0 0 1px #0b94eb'; if (variant === 'outline') return 'inset 0 0 0 1px #0b94eb'; + if (variant === 'white') return 'none'; return 'inset 0 0 0 2px rgba(0, 0, 0, 0.1)'; }}; } diff --git a/code/addons/onboarding/src/components/PulsatingEffect/PulsatingEffect.stories.tsx b/code/addons/onboarding/src/components/HighlightElement/HighlightElement.stories.tsx similarity index 52% rename from code/addons/onboarding/src/components/PulsatingEffect/PulsatingEffect.stories.tsx rename to code/addons/onboarding/src/components/HighlightElement/HighlightElement.stories.tsx index 6a87a2147c0a..44d894260a20 100644 --- a/code/addons/onboarding/src/components/PulsatingEffect/PulsatingEffect.stories.tsx +++ b/code/addons/onboarding/src/components/HighlightElement/HighlightElement.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { PulsatingEffect } from './PulsatingEffect'; +import { HighlightElement } from './HighlightElement'; import React from 'react'; import { within, expect } from '@storybook/test'; -const meta: Meta = { - component: PulsatingEffect, +const meta: Meta = { + component: HighlightElement, parameters: { layout: 'centered', chromatic: { @@ -20,7 +20,30 @@ type Story = StoryObj; export const Default: Story = { render: () => ( <> - + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement.parentElement!); + const button = canvas.getByRole('button'); + await expect(button).toHaveStyle('box-shadow: rgba(2,156,253,1) 0 0 2px 1px'); + }, +}; + +export const Pulsating: Story = { + render: () => ( + <> + - - - - ); - }, - args: { - data: data, - activeStep: 0, - width: 480, - filename: 'Button.stories.tsx', - }, -}; diff --git a/code/addons/onboarding/src/components/SyntaxHighlighter/SyntaxHighlighter.styled.tsx b/code/addons/onboarding/src/components/SyntaxHighlighter/SyntaxHighlighter.styled.tsx deleted file mode 100644 index 29beed376ed0..000000000000 --- a/code/addons/onboarding/src/components/SyntaxHighlighter/SyntaxHighlighter.styled.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { styled } from '@storybook/theming'; -import { motion } from 'framer-motion'; - -export const Code = styled(motion.div)` - position: relative; - z-index: 2; -`; - -export const SnippetWrapperFirst = styled(motion.div)` - position: relative; - padding-top: 10px; - padding-bottom: 10px; -`; - -export const SnippetWrapper = styled(motion.div)` - position: relative; - padding-top: 12px; - padding-bottom: 12px; -`; - -export const Container = styled.div<{ width: number }>` - position: relative; - box-sizing: border-box; - background: #171c23; - width: ${({ width }) => width}px; - height: 100%; - overflow: hidden; - padding-left: 15px; - padding-right: 15px; - padding-top: 4px; - border-left: ${({ theme }) => (theme.base === 'dark' ? 1 : 0)}px solid #fff2; - border-bottom: ${({ theme }) => (theme.base === 'dark' ? 1 : 0)}px solid #fff2; - border-top: ${({ theme }) => (theme.base === 'dark' ? 1 : 0)}px solid #fff2; - border-radius: 6px 0 0 6px; - overflow: hidden; - - && { - pre { - background: transparent !important; - margin: 0 !important; - padding: 0 !important; - } - } -`; - -export const Backdrop = styled(motion.div)` - background: #143046; - position: absolute; - z-index: 1; - left: 0; - top: 44px; - width: 100%; - height: 81px; -`; diff --git a/code/addons/onboarding/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx b/code/addons/onboarding/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx deleted file mode 100644 index a1038465b926..000000000000 --- a/code/addons/onboarding/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React, { createRef, useCallback, useLayoutEffect, useMemo, useState } from 'react'; -import { Backdrop, Code, Container, SnippetWrapperFirst } from './SyntaxHighlighter.styled'; -import { Snippet } from './Snippet/Snippet'; -import { ThemeProvider, ensure, themes } from '@storybook/theming'; -import { SyntaxHighlighter as StorybookSyntaxHighlighter } from '@storybook/components'; - -type SyntaxHighlighterProps = { - data: { snippet: string; toggle?: boolean }[][]; - activeStep: number; - width: number; - filename: string; -}; - -type StepsProps = { - yPos: number; - backdropHeight: number; - index: number; - open: boolean; -}; - -export const SyntaxHighlighter = ({ - activeStep, - data, - width, - filename, -}: SyntaxHighlighterProps) => { - const [steps, setSteps] = useState([]); - - const refs = useMemo(() => data.map(() => createRef()), [data]); - - const getYPos = (idx: number) => { - let yPos = 0; - for (let i = 0; i < idx; i++) { - yPos -= refs[i].current!.getBoundingClientRect().height; - } - return yPos; - }; - - const setNewSteps = useCallback(() => { - const newSteps = data.flatMap((content, i) => { - const backdropHeight = refs[i].current!.getBoundingClientRect().height; - const finalSteps = [ - { - yPos: getYPos(i), - backdropHeight, - index: i, - open: false, - }, - ]; - - if (content.length > 1) { - finalSteps.push({ - yPos: getYPos(i), - backdropHeight, - index: i, - open: true, - }); - } - - return finalSteps; - }); - - setSteps(newSteps); - }, [data]); - - useLayoutEffect(() => { - // Call setNewSteps every time height of the refs elements changes - const resizeObserver = new ResizeObserver(() => { - setNewSteps(); - }); - - refs.forEach((ref) => { - resizeObserver.observe(ref.current!); - }); - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - const customStyle = { - fontSize: '0.8125rem', - lineHeight: '1.1875rem', - }; - - return ( - - - - - - {'// ' + filename} - - - {data.map((content, idx: number) => ( - idx ? true : steps[activeStep]?.open ?? false} - content={content} - /> - ))} - - - - - ); -}; diff --git a/code/addons/onboarding/src/constants.ts b/code/addons/onboarding/src/constants.ts index f81e55b4cf93..fa3cca4032e8 100644 --- a/code/addons/onboarding/src/constants.ts +++ b/code/addons/onboarding/src/constants.ts @@ -1 +1,10 @@ export const STORYBOOK_ADDON_ONBOARDING_CHANNEL = 'STORYBOOK_ADDON_ONBOARDING_CHANNEL'; + +export const STORYBOOK_ADDON_ONBOARDING_STEPS = [ + '1:Intro', + '2:Controls', + '3:SaveFromControls', + '4:CreateStory', + '5:StoryCreated', + '6:FinishedOnboarding', +] as const; diff --git a/code/addons/onboarding/src/features/GuidedTour/GuidedTour.stories.tsx b/code/addons/onboarding/src/features/GuidedTour/GuidedTour.stories.tsx new file mode 100644 index 000000000000..c35a02830a5e --- /dev/null +++ b/code/addons/onboarding/src/features/GuidedTour/GuidedTour.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react'; +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.styled.tsx b/code/addons/onboarding/src/features/GuidedTour/GuidedTour.styled.tsx deleted file mode 100644 index 5f666ab74aae..000000000000 --- a/code/addons/onboarding/src/features/GuidedTour/GuidedTour.styled.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { type Theme } from '@storybook/theming'; - -export const getStyles = (theme: Theme) => ({ - border: 0, - borderRadius: '0.25rem', - cursor: 'pointer', - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - padding: '0 0.75rem', - background: theme.color.secondary, - color: '#FFF', - height: 32, - fontSize: '0.8125rem', - fontWeight: 700, - fontFamily: theme.typography.fonts.base, - transition: 'all 0.16s ease-in-out', - textDecoration: 'none', - outline: 'none', -}); diff --git a/code/addons/onboarding/src/features/GuidedTour/GuidedTour.tsx b/code/addons/onboarding/src/features/GuidedTour/GuidedTour.tsx index c4fdcc240a2e..926698641efb 100644 --- a/code/addons/onboarding/src/features/GuidedTour/GuidedTour.tsx +++ b/code/addons/onboarding/src/features/GuidedTour/GuidedTour.tsx @@ -1,183 +1,60 @@ -import type { ComponentProps } from 'react'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import type { CallBackProps } from 'react-joyride'; -import Joyride, { STATUS } from 'react-joyride'; -import type { API } from '@storybook/manager-api'; -import { UPDATE_STORY_ARGS } from '@storybook/core-events'; +import Joyride, { ACTIONS } from 'react-joyride'; import { useTheme } from '@storybook/theming'; -import { PulsatingEffect } from '../../components/PulsatingEffect/PulsatingEffect'; -import { Confetti } from '../../components/Confetti/Confetti'; import { Tooltip } from './Tooltip'; -import { SpanHighlight } from '../WriteStoriesModal/WriteStoriesModal.styled'; -import type { CodeSnippets } from '../WriteStoriesModal/code/types'; - -type GuidedTourStep = ComponentProps['step']; +import type { StepDefinition, StepKey } from '../../Onboarding'; export function GuidedTour({ - api, - isFinalStep, - onFirstTourDone, - onLastTourDone, - codeSnippets, + step, + steps, + onClose, + onComplete, }: { - api: API; - isFinalStep?: boolean; - codeSnippets?: CodeSnippets; - onFirstTourDone: () => void; - onLastTourDone: () => void; + step: StepKey; + steps: StepDefinition[]; + onClose: () => void; + onComplete: () => void; }) { - const [stepIndex, setStepIndex] = useState(); + const [stepIndex, setStepIndex] = useState(null); const theme = useTheme(); useEffect(() => { - api.once(UPDATE_STORY_ARGS, () => { - setStepIndex(3); + let timeout: NodeJS.Timeout; + 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; }); - }, []); - - const storyPlaygroundElement = useMemo(() => { - return (document.querySelector('#root div[role=main]') || - document.querySelector('#storybook-panel-root')) as HTMLElement; - }, []); + return () => clearTimeout(timeout); + }, [step, steps]); - const steps: GuidedTourStep[] = isFinalStep - ? [ - { - target: '#example-button--warning', - title: 'Congratulations!', - content: ( - <> - You just created your first story. Continue setting up your project to write stories - for your own components. - - ), - placement: 'right', - disableOverlay: true, - disableBeacon: true, - floaterProps: { - disableAnimation: true, - }, - onNextButtonClick() { - onLastTourDone(); - }, - }, - ] - : [ - { - target: '#storybook-explorer-tree > div', - title: 'Storybook is built from stories', - content: ( - <> - Storybook stories represent the key states of each of your components. -
-
- {codeSnippets?.filename && ( - <> - We automatically added four stories for this Button component in this example - file: -
- {codeSnippets.filename} - - )} - - ), - placement: 'right', - disableBeacon: true, - styles: { - spotlight: { - transform: 'translateY(30px)', - }, - }, - floaterProps: { - disableAnimation: true, - }, - }, - { - target: '#storybook-preview-iframe', - title: 'Storybook previews are interactive', - content: - 'Whenever you modify code or stories, Storybook automatically updates how it previews your components.', - placement: 'bottom', - styles: { - spotlight: { - borderRadius: 0, - }, - }, - }, - { - target: storyPlaygroundElement, - title: 'Interactive story playground', - content: ( - <> - See how a story renders with different data and state without touching code. -
-
- Try it out by pressing this button. - - - ), - placement: 'right', - spotlightClicks: true, - floaterProps: { - target: '#control-primary', - options: { - preventOverflow: { - boundariesElement: 'window', - }, - }, - }, - hideNextButton: true, - }, - { - target: '#control-primary', - title: 'Congratulations!', - content: ( - <> - You learned how to control your stories interactively. Now let's explore how to write - your first story. - - - ), - placement: 'right', - floaterProps: { - options: { - preventOverflow: { - boundariesElement: 'window', - }, - }, - }, - disableOverlay: true, - }, - ]; + if (stepIndex === null) return null; return ( { - if (!isFinalStep && data.status === STATUS.FINISHED) { - onFirstTourDone(); - } + if (data.action === ACTIONS.CLOSE) onClose(); + if (data.action === ACTIONS.NEXT && data.index === data.size - 1) onComplete(); }} floaterProps={{ - options: { - offset: { - offset: '0, 6', - }, - }, + disableAnimation: true, styles: { + arrow: { + length: 20, + spread: 2, + }, floater: { - padding: 0, - paddingLeft: 8, - paddingTop: 8, 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))' @@ -189,17 +66,22 @@ export function GuidedTour({ styles={{ overlay: { mixBlendMode: 'unset', - backgroundColor: 'none', + backgroundColor: steps[stepIndex]?.target === 'body' ? 'rgba(27, 28, 29, 0.2)' : 'none', }, spotlight: { backgroundColor: 'none', border: `solid 2px ${theme.color.secondary}`, - boxShadow: '0px 0px 0px 9999px rgba(0,0,0,0.4)', + boxShadow: '0px 0px 0px 9999px rgba(27, 28, 29, 0.2)', + }, + tooltip: { + width: 280, + color: theme.color.lightest, + background: theme.color.secondary, }, options: { - zIndex: 10000, + zIndex: 9998, primaryColor: theme.color.secondary, - arrowColor: theme.base === 'dark' ? '#292A2C' : theme.color.lightest, + arrowColor: theme.color.secondary, }, }} /> diff --git a/code/addons/onboarding/src/features/GuidedTour/Tooltip.tsx b/code/addons/onboarding/src/features/GuidedTour/Tooltip.tsx index c58fcbe1ad7f..6eca0ec92713 100644 --- a/code/addons/onboarding/src/features/GuidedTour/Tooltip.tsx +++ b/code/addons/onboarding/src/features/GuidedTour/Tooltip.tsx @@ -1,14 +1,13 @@ import type { FC } from 'react'; -import React from 'react'; -import { styled } from '@storybook/theming'; +import React, { useEffect } from 'react'; import type { Step, TooltipRenderProps } from 'react-joyride'; +import { IconButton } from '@storybook/components'; +import { CloseAltIcon } from '@storybook/icons'; +import { styled, color } from '@storybook/theming'; + import { Button } from '../../components/Button/Button'; const TooltipBody = styled.div` - background: ${({ theme }) => { - return theme.base === 'dark' ? '#292A2C' : theme.color.lightest; - }}; - width: 260px; padding: 15px; border-radius: 5px; `; @@ -19,29 +18,44 @@ const Wrapper = styled.div` align-items: flex-start; `; +const TooltipHeader = styled.div` + display: flex; + align-items: center; + align-self: stretch; + justify-content: space-between; + margin: -5px -5px 5px 0; +`; + const TooltipTitle = styled.div` - font-size: 13px; line-height: 18px; font-weight: 700; - color: ${({ theme }) => theme.color.defaultText}; + font-size: 14px; + margin: 5px 5px 5px 0; `; const TooltipContent = styled.p` - font-size: 13px; + font-size: 14px; line-height: 18px; text-align: start; - color: ${({ theme }) => theme.color.defaultText}; + text-wrap: balance; margin: 0; margin-top: 5px; `; const TooltipFooter = styled.div` display: flex; - justify-content: flex-end; + align-items: center; + justify-content: space-between; margin-top: 15px; `; +const Count = styled.span` + font-size: 13px; +`; + type TooltipProps = { + index: number; + size: number; step: Partial< Pick< // this only seems to happen during the check task, nos in vsCode.. @@ -52,6 +66,7 @@ type TooltipProps = { | 'title' | 'content' | 'target' + | 'offset' | 'placement' | 'disableOverlay' | 'disableBeacon' @@ -63,29 +78,71 @@ type TooltipProps = { onNextButtonClick: () => void; } >; + closeProps: TooltipRenderProps['closeProps']; primaryProps: TooltipRenderProps['primaryProps']; tooltipProps: TooltipRenderProps['tooltipProps']; }; -export const Tooltip: FC = ({ step, primaryProps, tooltipProps }) => { +export const Tooltip: FC = ({ + index, + size, + step, + closeProps, + primaryProps, + tooltipProps, +}) => { + useEffect(() => { + const style = document.createElement('style'); + style.id = '#sb-onboarding-arrow-style'; + style.innerHTML = ` + .__floater__arrow { container-type: size; } + .__floater__arrow span { background: ${color.secondary}; } + .__floater__arrow span::before, .__floater__arrow span::after { + content: ''; + display: block; + width: 2px; + height: 2px; + background: ${color.secondary}; + box-shadow: 0 0 0 2px ${color.secondary}; + border-radius: 3px; + flex: 0 0 2px; + } + @container (min-height: 1px) { + .__floater__arrow span { flex-direction: column; } + } + `; + document.head.appendChild(style); + return () => { + const styleElement = document.querySelector('#sb-onboarding-arrow-style'); + if (styleElement) styleElement.remove(); + }; + }, []); + return ( - + - {step.title && {step.title}} + + {step.title && {step.title}} + + + + {step.content} - {!step.hideNextButton && ( - + + + {index + 1} of {size} + + {!step.hideNextButton && ( - - )} + )} + ); }; diff --git a/code/addons/onboarding/src/features/SplashScreen/SplashScreen.stories.tsx b/code/addons/onboarding/src/features/SplashScreen/SplashScreen.stories.tsx new file mode 100644 index 000000000000..02fc3c3bd919 --- /dev/null +++ b/code/addons/onboarding/src/features/SplashScreen/SplashScreen.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { SplashScreen } from './SplashScreen'; + +const meta = { + component: SplashScreen, + args: { + onDismiss: () => {}, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/code/addons/onboarding/src/features/SplashScreen/SplashScreen.tsx b/code/addons/onboarding/src/features/SplashScreen/SplashScreen.tsx new file mode 100644 index 000000000000..a4309f34eeef --- /dev/null +++ b/code/addons/onboarding/src/features/SplashScreen/SplashScreen.tsx @@ -0,0 +1,195 @@ +import { ArrowRightIcon } from '@storybook/icons'; +import { styled, keyframes } from '@storybook/theming'; +import React, { useCallback, useEffect, useState } from 'react'; + +const fadeIn = keyframes({ + from: { + opacity: 0, + }, + to: { + opacity: 1, + }, +}); + +const slideIn = keyframes({ + from: { + transform: 'translate(0, -20px)', + opacity: 0, + }, + to: { + transform: 'translate(0, 0)', + opacity: 1, + }, +}); + +const scaleIn = keyframes({ + from: { + opacity: 0, + transform: 'scale(0.8)', + }, + to: { + opacity: 1, + transform: 'scale(1)', + }, +}); + +const rotate = keyframes({ + '0%': { + transform: 'rotate(0deg)', + }, + '100%': { + transform: 'rotate(360deg)', + }, +}); + +const Wrapper = styled.div<{ visible: boolean }>(({ visible }) => ({ + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + display: 'flex', + opacity: visible ? 1 : 0, + alignItems: 'center', + justifyContent: 'center', + zIndex: 1000, + transition: 'opacity 1s 0.5s', +})); + +const Backdrop = styled.div({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + animation: `${fadeIn} 2s`, + background: ` + radial-gradient(90% 90%, #ff4785 0%, #db5698 30%, #1ea7fdcc 100%), + radial-gradient(circle, #ff4785 0%, transparent 80%), + radial-gradient(circle at 30% 40%, #fc521f99 0%, #fc521f66 20%, transparent 40%), + radial-gradient(circle at 75% 75%, #fc521f99 0%, #fc521f77 18%, transparent 30%)`, + '&::before': { + opacity: 0.5, + background: ` + radial-gradient(circle at 30% 40%, #fc521f99 0%, #fc521f66 10%, transparent 20%), + radial-gradient(circle at 75% 75%, #fc521f99 0%, #fc521f77 8%, transparent 20%)`, + content: '""', + position: 'absolute', + top: '-50vw', + left: '-50vh', + transform: 'translate(-50%, -50%)', + width: 'calc(100vw + 100vh)', + height: 'calc(100vw + 100vh)', + animation: `${rotate} 12s linear infinite`, + }, +}); + +const Content = styled.div<{ visible: boolean }>(({ visible }) => ({ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + color: 'white', + textAlign: 'center', + maxWidth: 400, + opacity: visible ? 1 : 0, + transition: 'opacity 0.5s', + + h1: { + fontSize: 45, + fontWeight: 'bold', + animation: `${slideIn} 1.5s 1s backwards`, + }, +})); + +const RadialButton = styled.button({ + display: 'inline-flex', + position: 'relative', + alignItems: 'center', + justifyContent: 'center', + marginTop: 40, + width: 48, + height: 48, + padding: 0, + borderRadius: '50%', + border: 0, + outline: 'none', + background: 'rgba(255, 255, 255, 0.3)', + cursor: 'pointer', + transition: 'background 0.2s', + animation: `${scaleIn} 1.5s 1.5s backwards`, + + '&:hover, &:focus': { + background: 'rgba(255, 255, 255, 0.4)', + }, +}); + +const ArrowIcon = styled(ArrowRightIcon)({ + width: 30, + color: 'white', +}); + +const ProgressCircle = styled.svg<{ progress?: boolean; spinner?: boolean }>(({ progress }) => ({ + position: 'absolute', + top: -1, + left: -1, + width: `50px!important`, + height: `50px!important`, + transform: 'rotate(-90deg)', + color: 'white', + circle: { + r: '24', + cx: '25', + cy: '25', + fill: 'transparent', + stroke: progress ? 'currentColor' : 'transparent', + strokeWidth: '1', + strokeLinecap: 'round', + strokeDasharray: Math.PI * 48, + }, +})); + +interface SplashScreenProps { + onDismiss: () => void; +} + +export const SplashScreen = ({ onDismiss }: SplashScreenProps) => { + const [progress, setProgress] = useState(-30); + const [visible, setVisible] = useState(true); + const ready = progress >= 100; + + const dismiss = useCallback(() => { + setVisible(false); + const timeout = setTimeout(onDismiss, 1500); + return () => clearTimeout(timeout); + }, [onDismiss]); + + useEffect(() => { + const interval = setInterval(() => setProgress((prev) => prev + 0.5), 30); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + if (ready) dismiss(); + }, [ready, dismiss]); + + return ( + + + +

Meet your new frontend workshop

+ + + + + + + + + +
+
+ ); +}; diff --git a/code/addons/onboarding/src/features/WelcomeModal/StorybookLogo.tsx b/code/addons/onboarding/src/features/WelcomeModal/StorybookLogo.tsx deleted file mode 100644 index bcfbba836ab9..000000000000 --- a/code/addons/onboarding/src/features/WelcomeModal/StorybookLogo.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; - -export function StorybookLogo() { - return ( - - - - - - - - - - - - - ); -} diff --git a/code/addons/onboarding/src/features/WelcomeModal/WelcomeModal.stories.tsx b/code/addons/onboarding/src/features/WelcomeModal/WelcomeModal.stories.tsx deleted file mode 100644 index 791698211dfe..000000000000 --- a/code/addons/onboarding/src/features/WelcomeModal/WelcomeModal.stories.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React, { useState } from 'react'; -import type { Meta, StoryObj } from '@storybook/react'; - -import { WelcomeModal } from './WelcomeModal'; - -const meta: Meta = { - component: WelcomeModal, - // This decorator is used to show the modal in the side by side view - decorators: [ - (Story, context) => { - const [container, setContainer] = useState(undefined); - - if (context.globals.theme === 'side-by-side') { - return ( -
{ - if (element) { - setContainer(element); - } - }} - style={{ - width: '100%', - height: '100%', - minHeight: '600px', - transform: 'translateZ(0)', - }} - > - {Story({ args: { ...context.args, container } })} -
- ); - } - - return Story(); - }, - ], -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/code/addons/onboarding/src/features/WelcomeModal/WelcomeModal.styled.tsx b/code/addons/onboarding/src/features/WelcomeModal/WelcomeModal.styled.tsx deleted file mode 100644 index 8728edfa6045..000000000000 --- a/code/addons/onboarding/src/features/WelcomeModal/WelcomeModal.styled.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { ArrowRightIcon } from '@storybook/icons'; -import { keyframes, styled } from '@storybook/theming'; -import { Modal } from '@storybook/components'; - -export const ModalWrapper = styled(Modal)` - background: white; -`; - -export const ModalContentWrapper = styled.div` - border-radius: 5px; - display: flex; - flex-direction: column; - align-items: center; - height: 100%; - justify-content: space-between; -`; - -export const TopContent = styled.div` - display: flex; - flex: 1; - flex-direction: column; - align-items: center; - justify-content: center; -`; - -export const Title = styled.h1` - margin: 0; - margin-top: 20px; - margin-bottom: 5px; - color: ${({ theme }) => theme.color.darkest}; - font-weight: ${({ theme }) => theme.typography.weight.bold}; - font-size: ${({ theme }) => theme.typography.size.m1}px; - line-height: ${({ theme }) => theme.typography.size.m3}px; -`; - -export const Description = styled.p` - margin: 0; - margin-bottom: 20px; - max-width: 320px; - text-align: center; - font-size: ${({ theme }) => theme.typography.size.s2}px; - font-weight: ${({ theme }) => theme.typography.weight.regular}; - line-height: ${({ theme }) => theme.typography.size.m1}px; - color: ${({ theme }) => theme.color.darker}; -`; - -export const SkipButton = styled.button` - all: unset; - cursor: pointer; - font-size: 13px; - color: #798186; - padding-bottom: 20px; - - &:focus-visible { - outline: auto; - } -`; - -export const StyledIcon = styled(ArrowRightIcon)` - margin-left: 2px; - height: 10px; -`; - -export const Background = styled.div` - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: -1; - overflow: hidden; -`; - -export const circle1Anim = keyframes` - 0% { transform: translate(0px, 0px) } - 50% { transform: translate(-200px, 0px) } - 100% { transform: translate(0px, 0px) } -`; - -export const Circle1 = styled.div` - position: absolute; - width: 1200px; - height: 1200px; - left: -200px; - top: -900px; - background: radial-gradient( - circle at center, - rgba(253, 255, 147, 1) 0%, - rgba(253, 255, 147, 0) 70% - ); - animation: ${circle1Anim} 4s linear infinite; - animation-timing-function: ease-in-out; - z-index: 3; -`; - -export const circle2Anim = keyframes` - 0% { transform: translate(0px, 0px) } - 50% { transform: translate(400px, 0px) } - 100% { transform: translate(0px, 0px) } -`; - -export const Circle2 = styled.div` - position: absolute; - width: 1200px; - height: 1200px; - left: -600px; - top: -840px; - background: radial-gradient( - circle at center, - rgba(255, 119, 119, 1) 0%, - rgba(255, 119, 119, 0) 70% - ); - animation: ${circle2Anim} 6s linear infinite; - animation-timing-function: ease-in-out; - z-index: 2; -`; - -export const circle3Anim = keyframes` - 0% { transform: translate(600px, -40px) } - 50% { transform: translate(600px, -200px) } - 100% { transform: translate(600px, -40px) } -`; - -export const Circle3 = styled.div` - position: absolute; - width: 1200px; - height: 1200px; - left: -600px; - top: -840px; - background: radial-gradient( - circle at center, - rgba(119, 255, 247, 0.8) 0%, - rgba(119, 255, 247, 0) 70% - ); - animation: ${circle3Anim} 4s linear infinite; - animation-timing-function: ease-in-out; - z-index: 4; -`; - -export const StyledTitle = styled.h2` - color: #000; - font-weight: 700; - font-size: 20px; - line-height: 20px; -`; -export const StyledDescription = styled.p` - font-size: 14px; - font-weight: 400; - line-height: 20px; - color: #454e54; -`; diff --git a/code/addons/onboarding/src/features/WelcomeModal/WelcomeModal.tsx b/code/addons/onboarding/src/features/WelcomeModal/WelcomeModal.tsx deleted file mode 100644 index a8000fd6ea63..000000000000 --- a/code/addons/onboarding/src/features/WelcomeModal/WelcomeModal.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { FC } from 'react'; -import React from 'react'; - -import { Button } from '../../components/Button/Button'; -import { StorybookLogo } from './StorybookLogo'; -import { - ModalContentWrapper, - SkipButton, - StyledIcon, - Title, - Description, - Background, - Circle1, - Circle2, - Circle3, - TopContent, - ModalWrapper, -} from './WelcomeModal.styled'; - -interface WelcomeModalProps { - onProceed: () => void; - skipOnboarding: () => void; - container?: HTMLElement; -} - -export const WelcomeModal: FC = ({ onProceed, skipOnboarding, container }) => { - return ( -
- - - - - Welcome to Storybook - - Storybook helps you develop UI components faster. Learn the basics in a few simple - steps. - - - - - Skip tour - - - - - - - - - -
- ); -}; diff --git a/code/addons/onboarding/src/features/WriteStoriesModal/WriteStoriesModal.stories.tsx b/code/addons/onboarding/src/features/WriteStoriesModal/WriteStoriesModal.stories.tsx deleted file mode 100644 index 6902466a8e15..000000000000 --- a/code/addons/onboarding/src/features/WriteStoriesModal/WriteStoriesModal.stories.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React, { useState } from 'react'; -import type { Meta, StoryObj } from '@storybook/react'; - -import { waitFor, within, expect, fn } from '@storybook/test'; -import { STORY_INDEX_INVALIDATED, STORY_RENDERED } from '@storybook/core-events'; -import { WriteStoriesModal } from './WriteStoriesModal'; -import typescriptSnippet from './code/typescript'; - -const getData = fn(); - -const meta: Meta = { - component: WriteStoriesModal, - args: { - codeSnippets: typescriptSnippet, - // @ts-expect-error (bad) - api: { - getData, - }, - addonsStore: { - // @ts-expect-error (bad) - getChannel: () => ({ - once: (type: string, cb: () => void) => { - if (type === STORY_RENDERED) { - cb(); - } - }, - on: (type: string, cb: () => void) => { - if (type === STORY_INDEX_INVALIDATED) { - storyIndexInvalidatedCb = cb; - } - }, - off: () => {}, - }), - }, - }, - - decorators: [ - (storyFn, context) => { - (context.args.api.getData as typeof getData) - // do not respond to the first call, this would only return the data correctly if the story already exists - // which is not the case in this story, it only makes sense in the real scenario - .mockReturnValueOnce(null) - .mockReturnValueOnce({ some: 'data' }); - return
{storyFn()}
; - }, - (Story, context) => { - const [container, setContainer] = useState(undefined); - - if (context.globals.theme === 'side-by-side') { - return ( -
{ - if (element) { - setContainer(element); - } - }} - style={{ - width: '100%', - height: '100%', - minHeight: '600px', - transform: 'translateZ(0)', - }} - > - {Story({ args: { ...context.args, container } })} -
- ); - } - - return Story(); - }, - ], -}; - -export default meta; - -type Story = StoryObj; - -let storyIndexInvalidatedCb: () => void; - -export const Default: Story = {}; - -export const DefaultPlayed: Story = { - args: { - ...Default.args, - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement.parentElement!); - const importsText = await canvas.findByText('Imports'); - await step('Wait for modal to be visible', async () => { - const modal = await canvas.findByRole('dialog'); - await waitFor(async () => expect(modal).toBeVisible()); - }); - await expect(importsText).toBeVisible(); - await canvas.getByRole('button', { name: /Next/i }).click(); - const metaText = await canvas.findAllByText('Meta'); - await expect(metaText?.[0]).toBeVisible(); - await canvas.getByRole('button', { name: /Next/i }).click(); - const storyText = await canvas.findAllByText('Story'); - await expect(storyText?.[0]).toBeVisible(); - await canvas.getByRole('button', { name: /Next/i }).click(); - const argsText = await canvas.findAllByText('Args'); - await expect(argsText?.[0]).toBeVisible(); - await canvas.getByRole('button', { name: /Next/i }).click(); - (await canvas.findByRole('button', { name: /Copy code/i })).click(); - storyIndexInvalidatedCb(); - await waitFor(() => expect(canvas.getAllByLabelText('complete')).toHaveLength(3)); - }, -}; diff --git a/code/addons/onboarding/src/features/WriteStoriesModal/WriteStoriesModal.styled.tsx b/code/addons/onboarding/src/features/WriteStoriesModal/WriteStoriesModal.styled.tsx deleted file mode 100644 index eea2675c814d..000000000000 --- a/code/addons/onboarding/src/features/WriteStoriesModal/WriteStoriesModal.styled.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { keyframes, styled } from '@storybook/theming'; -import { Modal } from '@storybook/components'; - -export const ModalWrapper = styled(Modal)``; - -export const ModalContent = styled.div` - display: flex; - flex-direction: row; - height: 100%; - max-height: 85vh; - - &:focus-visible { - outline: none; - } -`; - -export const Main = styled.div` - position: relative; - flex: 1; - display: flex; - flex-direction: column; - background: white; - font-family: ${({ theme }) => theme.typography.fonts.base}; -`; - -export const Header = styled.div` - position: relative; - z-index: 1; - box-sizing: border-box; - display: flex; - justify-content: space-between; - align-items: center; - padding: 0 15px; - border-bottom: 1px solid rgba(0, 0, 0, 0.1); - height: 44px; -`; - -export const ModalTitle = styled.div` - display: flex; - align-items: center; - gap: 5px; - font-size: 13px; - line-height: 18px; - font-weight: bold; - color: ${({ theme }) => theme.color.darkest}; -`; - -export const Content = styled.div` - font-size: 13px; - line-height: 18px; - padding: 15px; - flex: 1; - display: flex; - flex-direction: column; - align-items: flex-end; - justify-content: space-between; - color: ${({ theme }) => theme.color.darker}; - - h3 { - font-size: 13px; - line-height: 18px; - font-weight: bold; - padding: 0; - margin: 0; - } -`; - -export const SpanHighlight = styled.span(({ theme }) => ({ - display: 'inline-flex', - borderRadius: 3, - padding: '0 5px', - marginBottom: -2, - opacity: 0.8, - fontFamily: theme.typography.fonts.mono, - fontSize: 11, - border: theme.base === 'dark' ? theme.color.darkest : theme.color.lightest, - color: theme.base === 'dark' ? theme.color.lightest : theme.color.darkest, - backgroundColor: theme.base === 'dark' ? 'black' : theme.color.light, - boxSizing: 'border-box', - lineHeight: '17px', -})); - -export const Image = styled.img` - max-width: 100%; - margin-top: 1em; -`; - -export const Background = styled.div` - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - overflow: hidden; - z-index: 0; - pointer-events: none; -`; - -export const circle1Anim = keyframes` - 0% { transform: translate(0px, 0px) } - 50% { transform: translate(120px, 0px) } - 100% { transform: translate(0px, 0px) } -`; - -export const Circle1 = styled.div` - position: absolute; - width: 350px; - height: 350px; - left: -160px; - top: -260px; - background: radial-gradient( - circle at center, - rgba(255, 119, 119, 1) 0%, - rgba(255, 119, 119, 0) 70% - ); - animation: ${circle1Anim} 8s linear infinite; - animation-timing-function: ease-in-out; - z-index: 2; -`; - -export const circle2Anim = keyframes` - 0% { transform: translate(0px, 0px) } - 33% { transform: translate(-64px, 0px) } - 66% { transform: translate(120px, 0px) } - 100% { transform: translate(0px, 0px) } -`; - -export const Circle2 = styled.div` - position: absolute; - width: 350px; - height: 350px; - left: -54px; - top: -250px; - background: radial-gradient( - circle at center, - rgba(253, 255, 147, 1) 0%, - rgba(253, 255, 147, 0) 70% - ); - animation: ${circle2Anim} 12s linear infinite; - animation-timing-function: ease-in-out; - z-index: 3; -`; - -export const circle3Anim = keyframes` - 0% { transform: translate(0px, 0px) } - 50% { transform: translate(-120px, 0px) } - 100% { transform: translate(0px, 0px) } -`; - -export const Circle3 = styled.div` - position: absolute; - width: 350px; - height: 350px; - left: 150px; - top: -220px; - background: radial-gradient( - circle at center, - rgba(119, 255, 247, 0.8) 0%, - rgba(119, 255, 247, 0) 70% - ); - animation: ${circle3Anim} 4s linear infinite; - animation-timing-function: ease-in-out; - z-index: 4; -`; - -export const ButtonsWrapper = styled.div` - box-sizing: border-box; - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; - margin-top: 4px; -`; - -export const Step2Text = styled.div` - margin-bottom: 4px; -`; diff --git a/code/addons/onboarding/src/features/WriteStoriesModal/WriteStoriesModal.tsx b/code/addons/onboarding/src/features/WriteStoriesModal/WriteStoriesModal.tsx deleted file mode 100644 index 38b44f00a037..000000000000 --- a/code/addons/onboarding/src/features/WriteStoriesModal/WriteStoriesModal.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import React, { useCallback, useState, type FC } from 'react'; -import useMeasure from 'react-use-measure'; -import { - Background, - ButtonsWrapper, - Circle1, - Circle2, - Circle3, - Content, - Header, - Image, - Main, - ModalContent, - ModalTitle, - ModalWrapper, - SpanHighlight, - Step2Text, -} from './WriteStoriesModal.styled'; -import { Button } from '../../components/Button/Button'; -import { SyntaxHighlighter } from '../../components/SyntaxHighlighter/SyntaxHighlighter'; -import { List } from '../../components/List/List'; -import { ListItem } from '../../components/List/ListItem/ListItem'; -import { useGetButtonPath } from './hooks/useGetButtonPath'; -import { useGetWarningButtonStatus } from './hooks/useGetWarningButtonStatus'; -import { useGetBackdropBoundary } from './hooks/useGetBackdropBoundary'; -import titleSidebarImg from './assets/01-title-sidebar.png'; -import storyNameSidebarImg from './assets/02-story-name-sidebar.png'; -import argsImg from './assets/03-args.png'; -import type { API, AddonStore } from '@storybook/manager-api'; -import { STORYBOOK_ADDON_ONBOARDING_CHANNEL } from '../../constants'; -import { useTheme } from '@storybook/theming'; -import type { CodeSnippets } from './code/types'; -import { BookmarkHollowIcon, CrossIcon } from '@storybook/icons'; -import { Modal } from '@storybook/components'; - -// TODO: Add warning if backdropBoundary && !warningButtonStatus?.data is not true. -// backdropBoundary && !warningButtonStatus?.data - -interface WriteStoriesModalProps { - onFinish: () => void; - api: API; - addonsStore: AddonStore; - codeSnippets: CodeSnippets; - skipOnboarding: () => void; - container?: HTMLElement; -} - -export const WriteStoriesModal: FC = ({ - onFinish, - api, - addonsStore, - skipOnboarding, - codeSnippets, - container, -}) => { - const [step, setStep] = useState<'imports' | 'meta' | 'story' | 'args' | 'customStory'>( - 'imports' - ); - const theme = useTheme(); - - const stepIndex = { - imports: 0, - meta: 1, - story: 2, - args: 3, - customStory: 4, - }; - - const [isWarningStoryCopied, setWarningStoryCopied] = useState(false); - - const [clipboardButtonRef, clipboardButtonBounds] = useMeasure(); - - const buttonPath = useGetButtonPath(); - const warningButtonStatus = useGetWarningButtonStatus(step === 'customStory', api, addonsStore); - const backdropBoundary = useGetBackdropBoundary( - 'syntax-highlighter-backdrop', - step === 'customStory' - ); - - const isJavascript = codeSnippets?.language === 'javascript'; - - const copyWarningStory = () => { - const warningContent = codeSnippets.code[3][0].snippet; - navigator.clipboard.writeText(warningContent.replace('// Copy the code below', '')); - setWarningStoryCopied(true); - }; - - const onModalClose = useCallback(() => { - api.emit(STORYBOOK_ADDON_ONBOARDING_CHANNEL, { - step: 'X:SkippedOnboarding', - where: `HowToWriteAStoryModal:${step}`, - type: 'telemetry', - }); - }, [api, step]); - - return ( - - - {codeSnippets ? ( - - ) : null} - {step === 'customStory' && backdropBoundary && !warningButtonStatus?.data && ( - - )} -
- - - - - - -
- - - - How to write a story - - - - - -
- - - {step === 'imports' && ( - <> -
-

Imports

- {isJavascript ? ( -

Import a component. In this case, the Button component.

- ) : ( - <> -

- First, import Meta and{' '} - StoryObj for type safety and autocompletion - in TypeScript stories. -

-

Next, import a component. In this case, the Button component.

- - )} -
- - - )} - {step === 'meta' && ( - <> -
-

Meta

-

- The default export, Meta, contains metadata about this component's stories. - The title field (optional) controls where stories appear in the sidebar. -

- Title property pointing to Storybook's sidebar -
- - - - - - )} - {step === 'story' && ( - <> -
-

Story

-

- Each named export is a story. Its contents specify how the story is rendered - in addition to other configuration options. -

- Story export pointing to the sidebar entry of the story -
- - - - - - )} - {step === 'args' && ( - <> -
-

Args

-

- Args are inputs that are passed to the component, which Storybook uses to - render the component in different states. In React, args = props. They also - specify the initial control values for the story. -

- Args mapped to their controls in Storybook -
- - - - - - )} - {step === 'customStory' && - (!warningButtonStatus?.error ? ( - <> -
-

Create your first story

-

- Now it's your turn. See how easy it is to create your first story by - following these steps below. -

- - - Copy the Warning story. - - - - Open the Button story in your current working directory. - - {buttonPath?.data && ( - // Replace '/' by '/' to properly break line - - {buttonPath.data.replaceAll('/', '/​').replaceAll('\\', '\\​')} - - )} - - - Paste it at the bottom of the file and save. - - -
- - - {warningButtonStatus?.data ? ( - - ) : null} - - - ) : null)} -
-
-
-
-
- ); -}; diff --git a/code/addons/onboarding/src/features/WriteStoriesModal/assets/01-title-sidebar.png b/code/addons/onboarding/src/features/WriteStoriesModal/assets/01-title-sidebar.png deleted file mode 100644 index 064d9995bf1d..000000000000 Binary files a/code/addons/onboarding/src/features/WriteStoriesModal/assets/01-title-sidebar.png and /dev/null differ diff --git a/code/addons/onboarding/src/features/WriteStoriesModal/assets/02-story-name-sidebar.png b/code/addons/onboarding/src/features/WriteStoriesModal/assets/02-story-name-sidebar.png deleted file mode 100644 index 1d562959c156..000000000000 Binary files a/code/addons/onboarding/src/features/WriteStoriesModal/assets/02-story-name-sidebar.png and /dev/null differ diff --git a/code/addons/onboarding/src/features/WriteStoriesModal/assets/03-args.png b/code/addons/onboarding/src/features/WriteStoriesModal/assets/03-args.png deleted file mode 100644 index 3312453658ae..000000000000 Binary files a/code/addons/onboarding/src/features/WriteStoriesModal/assets/03-args.png and /dev/null differ diff --git a/code/addons/onboarding/src/features/WriteStoriesModal/code/javascript.tsx b/code/addons/onboarding/src/features/WriteStoriesModal/code/javascript.tsx deleted file mode 100644 index 2a91a7972e14..000000000000 --- a/code/addons/onboarding/src/features/WriteStoriesModal/code/javascript.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { CodeSnippets } from './types'; - -const data: CodeSnippets = { - filename: 'Button.stories.js', - language: 'typescript', - code: [ - [ - { - snippet: `import { Button } from './Button';`, - }, - ], - [ - { - snippet: `export default { - title: 'Example/Button', - component: Button, - // ... - };`, - }, - ], - [ - { snippet: `export const Primary = {` }, - { - snippet: `args: { - primary: true, - label: 'Click', - background: 'red' - }`, - toggle: true, - }, - { snippet: `};` }, - ], - [ - { - snippet: `// Copy the code below -export const Warning = { - args: { - primary: true, - label: 'Delete now', - backgroundColor: 'red', - } -};`, - }, - ], - ], -}; - -export default data; diff --git a/code/addons/onboarding/src/features/WriteStoriesModal/code/types.ts b/code/addons/onboarding/src/features/WriteStoriesModal/code/types.ts deleted file mode 100644 index 130b4428f390..000000000000 --- a/code/addons/onboarding/src/features/WriteStoriesModal/code/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type CodeSnippets = { - framework?: string; - language: 'javascript' | 'typescript'; - filename: string; - code: { snippet: string; toggle?: boolean }[][]; -}; diff --git a/code/addons/onboarding/src/features/WriteStoriesModal/code/typescript.tsx b/code/addons/onboarding/src/features/WriteStoriesModal/code/typescript.tsx deleted file mode 100644 index 45a578d8c6bd..000000000000 --- a/code/addons/onboarding/src/features/WriteStoriesModal/code/typescript.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import type { CodeSnippets } from './types'; - -const data: CodeSnippets = { - filename: 'Button.stories.ts', - language: 'typescript', - code: [ - [ - { - snippet: `import type { Meta, StoryObj } from '@storybook/react'; - - import { Button } from './Button';`, - }, - ], - [ - { - snippet: `const meta: Meta = { - title: 'Example/Button', - component: Button, - // ... - }; - - export default meta;`, - }, - ], - [ - { - snippet: `type Story = StoryObj