diff --git a/web/frontend/app/features/home/components/ThemeTryOut.stories.tsx b/web/frontend/app/features/home/components/ThemeTryOut.stories.tsx index d8275d3a..c39aca54 100644 --- a/web/frontend/app/features/home/components/ThemeTryOut.stories.tsx +++ b/web/frontend/app/features/home/components/ThemeTryOut.stories.tsx @@ -5,7 +5,7 @@ import { expect, within } from 'storybook/test'; import { biergartenThemes, defaultThemeName } from '../../theme/themes'; import ThemeTryOut from './ThemeTryOut'; -const themeTryOutDescription = `Landing page theme band. Hosts the shared \`ThemeSegmentedControl\` with short labels, so a visitor can switch the whole document theme from the marketing page. The control seeds its selection from the root route's loader data, so this story runs behind a memory data router whose \`root\` route supplies a theme.`; +const themeTryOutDescription = `Landing page theme band. Frames the shared \`ThemeSegmentedControl\` in an elevated card with a "Set the mood" eyebrow and a pour count, and renders it with short labels so the four options fit the marketing page's narrower column. Each option carries its \`shortVibe\` mood line, and picking one switches the whole document theme. The control seeds its selection from the root route's loader data, so this story runs behind a memory data router whose \`root\` route supplies a theme. \`Themes/ThemeSegmentedControl\` covers the control itself.`; const ThemeTryOutHarness = () => { const [router] = useState(() => @@ -48,9 +48,15 @@ export const Default: Story = { canvas.getByRole('heading', { name: 'Pick a pour that suits you' }), ).toBeInTheDocument(); + await expect(canvas.getByText('Set the mood')).toBeVisible(); + await expect(canvas.getByText(`${biergartenThemes.length} pours`)).toBeVisible(); + const group = canvas.getByRole('radiogroup', { name: 'Theme selector' }); + await expect(within(group).getAllByRole('radio')).toHaveLength(biergartenThemes.length); + for (const theme of biergartenThemes) { - await expect(within(group).getByText(theme.shortLabel)).toBeInTheDocument(); + await expect(within(group).getByText(theme.shortLabel)).toBeVisible(); + await expect(within(group).getByText(theme.shortVibe)).toBeVisible(); } }, }; diff --git a/web/frontend/app/features/theme/components/ThemeSegmentedControl.stories.tsx b/web/frontend/app/features/theme/components/ThemeSegmentedControl.stories.tsx new file mode 100644 index 00000000..25ae5bcb --- /dev/null +++ b/web/frontend/app/features/theme/components/ThemeSegmentedControl.stories.tsx @@ -0,0 +1,167 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useMemo } from 'react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes'; +import ThemeSegmentedControl from './ThemeSegmentedControl'; + +const themeSegmentedControlDescription = `The shared theme picker, used by the theme guide and by the landing page's try-out band. Each option pairs a gradient swatch of the theme's palette with its name and \`shortVibe\` mood line, and selection is carried by both color and elevation. Picking an option sets \`data-theme\` on \`\` and writes the \`${themeCookieName}\` cookie, so the choice survives the next request and the server renders the same theme. The initial selection comes from the root route's loader, so these stories run behind a memory data router whose \`root\` route supplies the theme the cookie would have provided.`; + +interface HarnessProps { + labels?: 'full' | 'short'; + className?: string; + onChange?: (theme: ThemeName) => void; + /** Theme the root loader hands the control, standing in for the theme cookie. */ + seedTheme?: ThemeName; +} + +const ThemeSegmentedControlHarness = ({ + labels, + className, + onChange, + seedTheme = defaultThemeName, +}: HarnessProps) => { + // Rebuilt per arg change so the docs controls remount the control rather + // than leaving it seeded from the previous render. + const router = useMemo( + () => + createMemoryRouter([ + { + id: 'root', + path: '/', + loader: () => ({ theme: seedTheme }), + element: ( +
+ +
+ ), + }, + ]), + [labels, className, onChange, seedTheme], + ); + + return ; +}; + +const meta = { + title: 'Themes/ThemeSegmentedControl', + component: ThemeSegmentedControlHarness, + tags: ['autodocs'], + args: { + labels: 'full', + seedTheme: defaultThemeName, + onChange: fn(), + }, + argTypes: { + labels: { control: 'inline-radio', options: ['full', 'short'] }, + seedTheme: { + control: 'select', + options: biergartenThemes.map((theme) => theme.value), + }, + }, + parameters: { + usesDataRouter: true, + docs: { + description: { + component: themeSegmentedControlDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const getOptions = (canvasElement: HTMLElement) => { + const group = within(canvasElement).getByRole('radiogroup', { name: 'Theme selector' }); + return { group, radios: within(group).getAllByRole('radio') as HTMLInputElement[] }; +}; + +/** Full labels, as the theme guide renders it. */ +export const Default: Story = { + play: async ({ canvasElement }) => { + const { group, radios } = getOptions(canvasElement); + + await expect(radios).toHaveLength(biergartenThemes.length); + + for (const theme of biergartenThemes) { + await expect(within(group).getByText(theme.label)).toBeVisible(); + await expect(within(group).getByText(theme.shortVibe)).toBeVisible(); + } + + const [lager] = radios; + await expect(lager.value).toBe(defaultThemeName); + await expect(lager.checked).toBe(true); + }, +}; + +/** Short labels, as the landing page renders it in the narrower try-out card. */ +export const ShortLabels: Story = { + args: { + labels: 'short', + }, + play: async ({ canvasElement }) => { + const { group } = getOptions(canvasElement); + + for (const theme of biergartenThemes) { + await expect(within(group).getByText(theme.shortLabel)).toBeVisible(); + await expect(within(group).getByText(theme.shortVibe)).toBeVisible(); + } + + await expect(within(group).queryByText(biergartenThemes[0].label)).toBeNull(); + }, +}; + +/** A returning visitor whose cookie already names a theme other than the default. */ +export const SeededFromCookie: Story = { + args: { + seedTheme: 'biergarten-cassis', + }, + play: async ({ canvasElement }) => { + const { radios } = getOptions(canvasElement); + + for (const radio of radios) { + await expect(radio.checked).toBe(radio.value === 'biergarten-cassis'); + } + }, +}; + +export const SelectingATheme: Story = { + play: async ({ canvasElement, args }) => { + const previousTheme = document.documentElement.getAttribute('data-theme'); + const { group, radios } = getOptions(canvasElement); + + await userEvent.click(within(group).getByText('Biergarten Stout')); + + for (const radio of radios) { + await expect(radio.checked).toBe(radio.value === 'biergarten-stout'); + } + + await expect(args.onChange).toHaveBeenCalledWith('biergarten-stout'); + await expect(document.documentElement.getAttribute('data-theme')).toBe('biergarten-stout'); + await expect(document.cookie).toContain(`${themeCookieName}=biergarten-stout`); + + // The control themes the whole document, so hand back to the + // toolbar's theme before the next story mounts. + if (previousTheme === null) { + document.documentElement.removeAttribute('data-theme'); + } else { + document.documentElement.setAttribute('data-theme', previousTheme); + } + }, +}; + +/** Single column, for a sidebar or any container too narrow for the paired grid. */ +export const SingleColumn: Story = { + args: { + className: 'grid grid-cols-1 gap-2', + }, + play: async ({ canvasElement }) => { + const { radios } = getOptions(canvasElement); + await expect(radios).toHaveLength(biergartenThemes.length); + }, +};