mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 10:04:44 +00:00
Squash merge main into feature branch
This commit is contained in:
@@ -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 \`<html>\` 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: (
|
||||
<div className="max-w-md rounded-box bg-base-100 p-4 text-base-content">
|
||||
<ThemeSegmentedControl
|
||||
labels={labels}
|
||||
className={className}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]),
|
||||
[labels, className, onChange, seedTheme],
|
||||
);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
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<typeof ThemeSegmentedControlHarness>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
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 <html> 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);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouteLoaderData } from 'react-router';
|
||||
import type { loader as rootLoader } from '../../../root';
|
||||
import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes';
|
||||
|
||||
// The semantic aliases resolve at :root, so the attribute has to land on <html>
|
||||
// rather than a nested element for the whole document to switch.
|
||||
const applyTheme = (theme: ThemeName) => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.cookie = `${themeCookieName}=${theme}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
|
||||
};
|
||||
|
||||
interface ThemeSegmentedControlProps {
|
||||
/** `short` renders "Lager"; `full` renders "Biergarten Lager". */
|
||||
labels?: 'full' | 'short';
|
||||
className?: string;
|
||||
onChange?: (theme: ThemeName) => void;
|
||||
}
|
||||
|
||||
const ThemeSegmentedControl = ({
|
||||
labels = 'full',
|
||||
className = 'grid grid-cols-2 gap-2',
|
||||
onChange,
|
||||
}: ThemeSegmentedControlProps) => {
|
||||
const rootTheme = useRouteLoaderData<typeof rootLoader>('root')?.theme ?? defaultThemeName;
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(rootTheme);
|
||||
|
||||
return (
|
||||
<div className={className} role="radiogroup" aria-label="Theme selector">
|
||||
{biergartenThemes.map((theme) => {
|
||||
const checked = selectedTheme === theme.value;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={theme.value}
|
||||
className={`group flex min-h-20 cursor-pointer items-center gap-3 rounded-box border p-3 text-left transition-all duration-200 ${
|
||||
checked
|
||||
? 'border-primary bg-primary text-primary-content shadow-md'
|
||||
: 'border-base-content/15 bg-base-200/45 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-base-200'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value={theme.value}
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelectedTheme(theme.value);
|
||||
applyTheme(theme.value);
|
||||
onChange?.(theme.value);
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`size-9 shrink-0 rounded-full border-4 shadow-inner transition-transform duration-200 group-hover:scale-105 ${
|
||||
checked ? 'border-primary-content/70' : 'border-base-100'
|
||||
}`}
|
||||
style={{
|
||||
background:
|
||||
theme.value === 'biergarten-lager'
|
||||
? 'linear-gradient(135deg, #f5c451 0%, #a96728 100%)'
|
||||
: theme.value === 'biergarten-stout'
|
||||
? 'linear-gradient(135deg, #d69b45 0%, #382116 100%)'
|
||||
: theme.value === 'biergarten-cassis'
|
||||
? 'linear-gradient(135deg, #c777b9 0%, #4c214e 100%)'
|
||||
: 'linear-gradient(135deg, #f7e6a5 0%, #82a85d 100%)',
|
||||
}}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-bold">
|
||||
{labels === 'short' ? theme.shortLabel : theme.label}
|
||||
</span>
|
||||
<span
|
||||
className={`mt-0.5 block truncate text-xs ${
|
||||
checked ? 'text-primary-content/75' : 'text-base-content/60'
|
||||
}`}
|
||||
>
|
||||
{theme.shortVibe}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSegmentedControl;
|
||||
@@ -1,12 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouteLoaderData } from 'react-router';
|
||||
import type { loader as rootLoader } from '../../../root';
|
||||
import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes';
|
||||
|
||||
const applyTheme = (theme: ThemeName) => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.cookie = `${themeCookieName}=${theme}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
|
||||
};
|
||||
import { biergartenThemes, defaultThemeName, type ThemeName } from '../themes';
|
||||
import ThemeSegmentedControl from './ThemeSegmentedControl';
|
||||
|
||||
const ThemeSwitcher = () => {
|
||||
const rootTheme = useRouteLoaderData<typeof rootLoader>('root')?.theme ?? defaultThemeName;
|
||||
@@ -39,35 +35,7 @@ const ThemeSwitcher = () => {
|
||||
<h2 className="card-title text-2xl">Theme switcher</h2>
|
||||
<p className="text-base-content/70">Pick a theme and preview it immediately.</p>
|
||||
|
||||
<div
|
||||
className="join join-vertical sm:join-horizontal"
|
||||
role="radiogroup"
|
||||
aria-label="Theme selector"
|
||||
>
|
||||
{biergartenThemes.map((theme) => {
|
||||
const checked = selectedTheme === theme.value;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={theme.value}
|
||||
className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value={theme.value}
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelectedTheme(theme.value);
|
||||
applyTheme(theme.value);
|
||||
}}
|
||||
/>
|
||||
{theme.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ThemeSegmentedControl onChange={setSelectedTheme} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user