Refactor authentication and account management with modular UI components (#242)

This commit is contained in:
2026-08-23 01:30:58 -04:00
committed by GitHub
parent c514eb9814
commit 1621313daa
109 changed files with 14743 additions and 10792 deletions

View File

@@ -8,6 +8,7 @@ This Storybook is scoped to real app UI only:
- `SubmitButton`
- `FormField`
- `SectionCard`
- `Navbar`
- `Themes` gallery
@@ -34,6 +35,13 @@ Run:
- `npm run build-storybook -- --test`
- `npm run test:storybook:playwright`
### Running with a visible browser
Both layers default to headless. To watch the browser while debugging:
- Playwright layer: `npx playwright test -c playwright.storybook.config.ts --headed`, or `--ui` for the interactive step-through runner.
- Vitest/story `play` layer: `npx vitest run --project storybook --browser.headless=false` (drop `run` and add `--ui` for Vitest's own UI). The default is set in `vite.config.ts`.
## Rules
- Add stories only for reusable app components.

View File

@@ -1,68 +1,71 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import FormField from '../app/components/forms/FormField';
import FormField from '../app/components/ui/forms/FormField';
const formFieldDescription = `Reusable labeled input for Biergarten forms. This page shows guided, error, and password states so you can review label spacing, helper text, validation messaging, and ARIA behavior in the same card layout used across the app.`;
const meta = {
title: 'Forms/FormField',
component: FormField,
tags: ['autodocs'],
args: {
id: 'email',
name: 'email',
type: 'email',
label: 'Email address',
placeholder: 'you@example.com',
hint: 'We only use this to manage your account.',
},
parameters: {
layout: 'centered',
docs: {
description: {
component: formFieldDescription,
},
},
},
render: (args) => (
<div className="w-full max-w-md rounded-box bg-base-100 p-6 shadow-lg">
<FormField {...args} />
</div>
),
title: 'Forms/FormField',
component: FormField,
tags: ['autodocs'],
args: {
id: 'email',
name: 'email',
type: 'email',
label: 'Email address',
placeholder: 'you@example.com',
hint: 'We only use this to manage your account.',
},
parameters: {
layout: 'centered',
docs: {
description: {
component: formFieldDescription,
},
},
},
render: (args) => (
<div className="w-full max-w-md rounded-box bg-base-100 p-6 shadow-lg">
<FormField {...args} />
</div>
),
} satisfies Meta<typeof FormField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const WithHint: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByLabelText(/email address/i)).toBeInTheDocument();
await expect(canvas.getByText(/manage your account/i)).toBeInTheDocument();
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByLabelText(/email address/i)).toBeInTheDocument();
await expect(canvas.getByText(/manage your account/i)).toBeInTheDocument();
},
};
export const WithError: Story = {
args: {
error: 'Please enter a valid email address.',
hint: undefined,
'aria-invalid': true,
defaultValue: 'not-an-email',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText(/valid email address/i)).toBeInTheDocument();
await expect(canvas.getByLabelText(/email address/i)).toHaveAttribute('aria-invalid', 'true');
},
args: {
error: 'Please enter a valid email address.',
hint: undefined,
'aria-invalid': true,
defaultValue: 'not-an-email',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText(/valid email address/i)).toBeInTheDocument();
await expect(canvas.getByLabelText(/email address/i)).toHaveAttribute(
'aria-invalid',
'true',
);
},
};
export const PasswordField: Story = {
args: {
id: 'password',
name: 'password',
type: 'password',
label: 'Password',
placeholder: 'Enter a strong password',
hint: 'Use 12 or more characters.',
},
args: {
id: 'password',
name: 'password',
type: 'password',
label: 'Password',
placeholder: 'Enter a strong password',
hint: 'Use 12 or more characters.',
},
};

View File

@@ -0,0 +1,67 @@
import { zodResolver } from '@hookform/resolvers/zod';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { expect, userEvent, within } from 'storybook/test';
import LoginForm from '../app/features/auth/components/LoginForm';
import { loginSchema, type LoginSchema } from '../app/features/auth/schemas';
const loginFormDescription = `Renders the real \`LoginForm\` component wired to the real \`loginSchema\` (zod) through the same react-hook-form setup used on the login page, so these stories validate actual client-side validation behavior rather than a re-implemented form.`;
const LoginFormHarness = () => {
const [submitted, setSubmitted] = useState<LoginSchema | null>(null);
const form = useForm<LoginSchema>({ resolver: zodResolver(loginSchema) });
return (
<div className="w-full max-w-md space-y-3 rounded-box bg-base-100 p-6 shadow-lg">
<LoginForm
onSubmit={form.handleSubmit((values) => setSubmitted(values))}
formState={form.formState}
register={form.register}
submitting={false}
/>
{submitted ? (
<p data-testid="submit-result">Signed in as {submitted.username}</p>
) : null}
</div>
);
};
const meta = {
title: 'Forms/LoginForm',
component: LoginFormHarness,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component: loginFormDescription,
},
},
},
} satisfies Meta<typeof LoginFormHarness>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = {};
export const RejectsEmptySubmission: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
await expect(canvas.getByText(/username is required/i)).toBeInTheDocument();
await expect(canvas.getByText(/password is required/i)).toBeInTheDocument();
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
},
};
export const AcceptsValidLogin: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText(/username/i), 'hans');
await userEvent.type(canvas.getByLabelText(/password/i), 'correct-horse-battery');
await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
await expect(canvas.getByTestId('submit-result')).toHaveTextContent('hans');
},
};

View File

@@ -1,69 +1,69 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, within } from 'storybook/test';
import Navbar from '../app/components/Navbar';
import Navbar from '../app/components/ui/navbar/Navbar';
const navbarDescription = `Top-level navigation for the Biergarten website. These stories cover guest, authenticated, and mobile states so you can review branding, route visibility, account menu behavior, and responsive collapse without leaving Storybook.`;
const meta = {
title: 'Navigation/Navbar',
component: Navbar,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: navbarDescription,
},
},
},
title: 'Navigation/Navbar',
component: Navbar,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: navbarDescription,
},
},
},
} satisfies Meta<typeof Navbar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Guest: Story = {
args: {
auth: null,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('link', { name: /the biergarten app/i })).toBeInTheDocument();
await expect(canvas.getByRole('link', { name: /login/i })).toBeInTheDocument();
await expect(canvas.getByRole('link', { name: /register user/i })).toBeInTheDocument();
},
args: {
auth: null,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('link', { name: /the biergarten app/i })).toBeInTheDocument();
await expect(canvas.getByRole('link', { name: /login/i })).toBeInTheDocument();
await expect(canvas.getByRole('link', { name: /register user/i })).toBeInTheDocument();
},
};
export const Authenticated: Story = {
args: {
auth: {
username: 'Hans',
accessToken: 'access-token',
refreshToken: 'refresh-token',
userAccountId: 'user-1',
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const userButton = canvas.getByRole('button', { name: /hans/i });
await expect(userButton).toBeInTheDocument();
await userEvent.click(userButton);
await expect(canvas.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument();
await expect(canvas.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument();
},
args: {
auth: {
username: 'Hans',
accessToken: 'access-token',
refreshToken: 'refresh-token',
userAccountId: 'user-1',
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const userButton = canvas.getByRole('button', { name: /hans/i });
await expect(userButton).toBeInTheDocument();
await userEvent.click(userButton);
await expect(canvas.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument();
await expect(canvas.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument();
},
};
export const MobileMenu: Story = {
args: {
auth: null,
},
parameters: {
viewport: {
defaultViewport: 'mobile1',
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /toggle navigation/i }));
await expect(canvas.getByRole('link', { name: /beer styles/i })).toBeInTheDocument();
},
args: {
auth: null,
},
parameters: {
viewport: {
defaultViewport: 'mobile1',
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /toggle navigation/i }));
await expect(canvas.getByRole('link', { name: /beer styles/i })).toBeInTheDocument();
},
};

View File

@@ -0,0 +1,132 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { createMemoryRouter, redirect, RouterProvider, useActionData } from 'react-router';
import { expect, userEvent, within } from 'storybook/test';
import PasswordSection from '../app/features/account/components/PasswordSection';
import { updatePasswordSchema } from '../app/features/account/schemas';
import type { ActionResult } from '../app/features/account/types';
const passwordSectionDescription = `Renders the real \`PasswordSection\` component (the account page's collapsible password-change card) behind a memory router so its \`useSubmit\`/\`useNavigation\` wiring runs against a fake action, exercising the real \`updatePasswordSchema\` (zod) cross-field rules. On success the real action redirects back to the account page with a toast message instead of returning data - here it redirects to a marker route so the redirect itself is the observable outcome, matching how the real route behaves.`;
const PasswordSectionRoute = () => {
const [open, setOpen] = useState(true);
const result = useActionData() as ActionResult | undefined;
return (
<div className="w-full max-w-md">
<PasswordSection open={open} onToggle={() => setOpen((value) => !value)} result={result} />
</div>
);
};
const PasswordSectionHarness = () => {
const [router] = useState(() =>
createMemoryRouter([
{
path: '/',
element: <PasswordSectionRoute />,
action: async ({ request }) => {
const formData = await request.formData();
const parsed = updatePasswordSchema.safeParse({
currentPassword: formData.get('currentPassword'),
newPassword: formData.get('newPassword'),
confirmNewPassword: formData.get('confirmNewPassword'),
});
if (!parsed.success) {
return {
intent: 'password',
error: parsed.error.issues[0].message,
} satisfies ActionResult;
}
return redirect('/success');
},
},
{
path: '/success',
element: <p data-testid="redirect-success">Password changed successfully.</p>,
},
]),
);
return <RouterProvider router={router} />;
};
const meta = {
title: 'Account/PasswordSection',
component: PasswordSectionHarness,
tags: ['autodocs'],
parameters: {
layout: 'centered',
usesDataRouter: true,
docs: {
description: {
component: passwordSectionDescription,
},
},
},
} satisfies Meta<typeof PasswordSectionHarness>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Open: Story = {};
const fillPasswordForm = async (
canvas: ReturnType<typeof within>,
values: { current: string; next: string; confirm: string },
) => {
await userEvent.type(canvas.getByLabelText(/current password/i), values.current);
await userEvent.type(canvas.getByLabelText('New Password'), values.next);
await userEvent.type(canvas.getByLabelText(/confirm new password/i), values.confirm);
await userEvent.click(canvas.getByRole('button', { name: /change password/i }));
};
export const RejectsShortPassword: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillPasswordForm(canvas, {
current: 'OldPassword1!',
next: 'short',
confirm: 'short',
});
await expect(canvas.getByText(/at least 8 characters/i)).toBeInTheDocument();
},
};
export const RejectsMismatchedConfirmation: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillPasswordForm(canvas, {
current: 'OldPassword1!',
next: 'NewPassword2!',
confirm: 'NewPassword3!',
});
await expect(canvas.getByText(/passwords must match/i)).toBeInTheDocument();
},
};
export const RejectsReusingCurrentPassword: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillPasswordForm(canvas, {
current: 'SamePassword1!',
next: 'SamePassword1!',
confirm: 'SamePassword1!',
});
await expect(canvas.getByText(/different from the current password/i)).toBeInTheDocument();
},
};
export const AcceptsValidPasswordChange: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillPasswordForm(canvas, {
current: 'OldPassword1!',
next: 'NewPassword2!',
confirm: 'NewPassword2!',
});
await expect(canvas.getByTestId('redirect-success')).toBeInTheDocument();
},
};

View File

@@ -0,0 +1,116 @@
import { zodResolver } from '@hookform/resolvers/zod';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { expect, fireEvent, userEvent, within } from 'storybook/test';
import RegisterForm from '../app/features/auth/components/RegisterForm';
import { registerSchema, type RegisterSchema } from '../app/features/auth/schemas';
const registerFormDescription = `Renders the real \`RegisterForm\` component wired to the real \`registerSchema\` (zod) through the same react-hook-form setup used on the register page, including its cross-field rule that the confirmation must match the password, so these stories validate actual client-side validation behavior rather than a re-implemented form.`;
const RegisterFormHarness = () => {
const [submitted, setSubmitted] = useState<RegisterSchema | null>(null);
const form = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
return (
<div className="w-full max-w-lg space-y-3 rounded-box bg-base-100 p-6 shadow-lg">
<RegisterForm
onSubmit={form.handleSubmit((values) => setSubmitted(values))}
formState={form.formState}
register={form.register}
submitting={false}
/>
{submitted ? <p data-testid="submit-result">Registered {submitted.username}</p> : null}
</div>
);
};
const meta = {
title: 'Forms/RegisterForm',
component: RegisterFormHarness,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component: registerFormDescription,
},
},
},
} satisfies Meta<typeof RegisterFormHarness>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = {};
const fillValidRegistration = async (canvas: ReturnType<typeof within>) => {
await userEvent.type(canvas.getByLabelText(/^username$/i), 'jane_doe');
await userEvent.type(canvas.getByLabelText(/first name/i), 'Jane');
await userEvent.type(canvas.getByLabelText(/last name/i), 'Doe');
await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example.com');
await fireEvent.change(canvas.getByLabelText(/date of birth/i), {
target: { value: '2000-01-15' },
});
await userEvent.type(canvas.getByLabelText('Password'), 'StrongPass1!');
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'StrongPass1!');
};
export const RejectsEmptySubmission: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
await expect(
canvas.getByText(/username must be at least 3 characters/i),
).toBeInTheDocument();
await expect(canvas.getByText(/first name is required/i)).toBeInTheDocument();
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
},
};
export const RejectsInvalidEmail: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillValidRegistration(canvas);
await userEvent.clear(canvas.getByLabelText(/^email$/i));
await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example');
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
await expect(canvas.getByText(/invalid email address/i)).toBeInTheDocument();
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
},
};
export const RejectsWeakPassword: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillValidRegistration(canvas);
await userEvent.clear(canvas.getByLabelText('Password'));
await userEvent.type(canvas.getByLabelText('Password'), 'weakpassword');
await userEvent.clear(canvas.getByLabelText(/confirm password/i));
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'weakpassword');
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
await expect(canvas.getByText(/uppercase letter/i)).toBeInTheDocument();
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
},
};
export const RejectsMismatchedConfirmPassword: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillValidRegistration(canvas);
await userEvent.clear(canvas.getByLabelText(/confirm password/i));
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'Different1!');
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
await expect(canvas.getByText(/passwords must match/i)).toBeInTheDocument();
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
},
};
export const AcceptsValidRegistration: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillValidRegistration(canvas);
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
await expect(canvas.getByTestId('submit-result')).toHaveTextContent('jane_doe');
},
};

View File

@@ -0,0 +1,75 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Lock } from 'iconoir-react';
import { useState } from 'react';
import { expect, userEvent, within } from 'storybook/test';
import SectionCard from '../app/features/account/components/SectionCard';
const sectionCardDescription = `Collapsible card used on the account settings page to group a labeled form behind a toggle. These stories cover the closed and open states, plus the toggle interaction, so you can review spacing, icon alignment, and disclosure behavior in isolation.`;
const SectionCardDemo = (props: React.ComponentProps<typeof SectionCard>) => {
const [open, setOpen] = useState(props.open);
return (
<div className="w-full max-w-md">
<SectionCard {...props} open={open} onToggle={() => setOpen((value) => !value)} />
</div>
);
};
const meta = {
title: 'Account/SectionCard',
component: SectionCard,
tags: ['autodocs'],
args: {
icon: <Lock className="size-5" aria-hidden="true" />,
title: 'Password',
description: 'Change the password used to sign in.',
open: false,
onToggle: () => undefined,
children: <p className="text-sm">Form fields go here.</p>,
},
parameters: {
layout: 'centered',
docs: {
description: {
component: sectionCardDescription,
},
},
},
render: (args) => <SectionCardDemo {...args} />,
} satisfies Meta<typeof SectionCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Closed: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText('Password')).toBeInTheDocument();
await expect(canvas.queryByText('Form fields go here.')).not.toBeInTheDocument();
},
};
export const Open: Story = {
args: {
open: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText('Form fields go here.')).toBeInTheDocument();
},
};
export const ToggleInteraction: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = canvas.getByLabelText(/toggle password form/i);
await expect(canvas.queryByText('Form fields go here.')).not.toBeInTheDocument();
await userEvent.click(toggle);
await expect(canvas.getByText('Form fields go here.')).toBeInTheDocument();
await userEvent.click(toggle);
await expect(canvas.queryByText('Form fields go here.')).not.toBeInTheDocument();
},
};

View File

@@ -1,52 +1,52 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import SubmitButton from '../app/components/forms/SubmitButton';
import SubmitButton from '../app/components/ui/forms/SubmitButton';
const submitButtonDescription = `Shared submit action for Biergarten forms. These stories cover the idle, loading, and custom-width states so you can verify button copy, disabled behavior during submission, and theme styling without wiring up a full form flow.`;
const meta = {
title: 'Forms/SubmitButton',
component: SubmitButton,
tags: ['autodocs'],
args: {
idleText: 'Save changes',
submittingText: 'Saving changes',
isSubmitting: false,
},
parameters: {
layout: 'centered',
docs: {
description: {
component: submitButtonDescription,
},
},
},
title: 'Forms/SubmitButton',
component: SubmitButton,
tags: ['autodocs'],
args: {
idleText: 'Save changes',
submittingText: 'Saving changes',
isSubmitting: false,
},
parameters: {
layout: 'centered',
docs: {
description: {
component: submitButtonDescription,
},
},
},
} satisfies Meta<typeof SubmitButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Idle: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('button', { name: /save changes/i })).toBeEnabled();
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('button', { name: /save changes/i })).toBeEnabled();
},
};
export const Submitting: Story = {
args: {
isSubmitting: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('button', { name: /saving changes/i })).toBeDisabled();
},
args: {
isSubmitting: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('button', { name: /saving changes/i })).toBeDisabled();
},
};
export const CustomWidth: Story = {
args: {
className: 'btn btn-secondary min-w-64',
idleText: 'Register user',
submittingText: 'Registering user',
},
args: {
className: 'btn btn-secondary min-w-64',
idleText: 'Register user',
submittingText: 'Registering user',
},
};

View File

@@ -1,157 +1,160 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import { biergartenThemes } from '../app/lib/themes';
import { biergartenThemes } from '../app/features/theme/themes';
const themesDescription = `Palette reference for all Biergarten themes. Each panel shows the main semantic color pairs, status tokens, and custom content tokens so you can catch contrast issues, pairing mistakes, and mood drift before they show up in real components.`;
function ThemeSwatch({ label, className }: { label: string; className: string }) {
return <div className={`rounded-box p-3 text-sm font-medium ${className}`}>{label}</div>;
}
const ThemeSwatch = ({ label, className }: { label: string; className: string }) => {
return <div className={`rounded-box p-3 text-sm font-medium ${className}`}>{label}</div>;
};
/** For custom tokens not covered by Tailwind utilities (surface, muted, highlight). */
function CssVarSwatch({ label, bg, color }: { label: string; bg: string; color: string }) {
return (
<div
className="rounded-box p-3 text-sm font-medium"
style={{ backgroundColor: `var(${bg})`, color: `var(${color})` }}
>
{label}
</div>
);
}
function TextTokenSample({
label,
background,
text,
}: {
label: string;
background: string;
text: string;
}) {
return (
<div className="rounded-box p-3" style={{ backgroundColor: `var(${background})` }}>
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/50">
const CssVarSwatch = ({ label, bg, color }: { label: string; bg: string; color: string }) => {
return (
<div
className="rounded-box p-3 text-sm font-medium"
style={{ backgroundColor: `var(${bg})`, color: `var(${color})` }}
>
{label}
</p>
<p className="mt-1 text-sm font-medium" style={{ color: `var(${text})` }}>
Secondary copy, placeholders, and helper text.
</p>
</div>
);
}
</div>
);
};
function ThemePanel({ label, value, vibe }: { label: string; value: string; vibe: string }) {
return (
<section
data-theme={value}
className="rounded-box border border-base-300 bg-base-100 shadow-lg"
>
<div className="space-y-4 p-5">
<div className="space-y-1">
<h2 className="text-2xl font-bold">{label}</h2>
<p className="text-sm text-base-content/70">{vibe}</p>
</div>
const TextTokenSample = ({
label,
background,
text,
}: {
label: string;
background: string;
text: string;
}) => {
return (
<div className="rounded-box p-3" style={{ backgroundColor: `var(${background})` }}>
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/50">
{label}
</p>
<p className="mt-1 text-sm font-medium" style={{ color: `var(${text})` }}>
Secondary copy, placeholders, and helper text.
</p>
</div>
);
};
{/* Core palette */}
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Core
</p>
<div className="grid gap-2 sm:grid-cols-2">
<ThemeSwatch label="Primary" className="bg-primary text-primary-content" />
<ThemeSwatch label="Secondary" className="bg-secondary text-secondary-content" />
<ThemeSwatch label="Accent" className="bg-accent text-accent-content" />
<ThemeSwatch label="Neutral" className="bg-neutral text-neutral-content" />
</div>
</div>
const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe: string }) => {
return (
<section
data-theme={value}
className="rounded-box border border-base-300 bg-base-100 shadow-lg"
>
<div className="space-y-4 p-5">
<div className="space-y-1">
<h2 className="text-2xl font-bold">{label}</h2>
<p className="text-sm text-base-content/70">{vibe}</p>
</div>
{/* Status tokens */}
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Status
</p>
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4">
<ThemeSwatch label="Info" className="bg-info text-info-content" />
<ThemeSwatch label="Success" className="bg-success text-success-content" />
<ThemeSwatch label="Warning" className="bg-warning text-warning-content" />
<ThemeSwatch label="Error" className="bg-error text-error-content" />
</div>
</div>
{/* Core palette */}
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Core
</p>
<div className="grid gap-2 sm:grid-cols-2">
<ThemeSwatch label="Primary" className="bg-primary text-primary-content" />
<ThemeSwatch
label="Secondary"
className="bg-secondary text-secondary-content"
/>
<ThemeSwatch label="Accent" className="bg-accent text-accent-content" />
<ThemeSwatch label="Neutral" className="bg-neutral text-neutral-content" />
</div>
</div>
{/* Content tokens (custom) */}
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Content
</p>
<div className="grid gap-2 sm:grid-cols-3">
<CssVarSwatch
label="Surface"
bg="--color-surface"
color="--color-surface-content"
/>
<TextTokenSample
label="Muted on Base"
background="--color-base-100"
text="--color-muted"
/>
<CssVarSwatch
label="Highlight"
bg="--color-highlight"
color="--color-highlight-content"
/>
</div>
<div className="mt-2">
<TextTokenSample
label="Muted on Surface"
background="--color-surface"
text="--color-muted"
/>
</div>
</div>
{/* Status tokens */}
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Status
</p>
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4">
<ThemeSwatch label="Info" className="bg-info text-info-content" />
<ThemeSwatch label="Success" className="bg-success text-success-content" />
<ThemeSwatch label="Warning" className="bg-warning text-warning-content" />
<ThemeSwatch label="Error" className="bg-error text-error-content" />
</div>
</div>
<div className="flex flex-wrap gap-2">
<button className="btn btn-primary btn-sm">Primary</button>
<button className="btn btn-secondary btn-sm">Secondary</button>
<button className="btn btn-outline btn-sm">Outline</button>
</div>
{/* Content tokens (custom) */}
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Content
</p>
<div className="grid gap-2 sm:grid-cols-3">
<CssVarSwatch
label="Surface"
bg="--color-surface"
color="--color-surface-content"
/>
<TextTokenSample
label="Muted on Base"
background="--color-base-100"
text="--color-muted"
/>
<CssVarSwatch
label="Highlight"
bg="--color-highlight"
color="--color-highlight-content"
/>
</div>
<div className="mt-2">
<TextTokenSample
label="Muted on Surface"
background="--color-surface"
text="--color-muted"
/>
</div>
</div>
<div role="alert" className="alert alert-success alert-soft">
<span>Semantic tokens stay stable while the atmosphere changes.</span>
<div className="flex flex-wrap gap-2">
<button className="btn btn-primary btn-sm">Primary</button>
<button className="btn btn-secondary btn-sm">Secondary</button>
<button className="btn btn-outline btn-sm">Outline</button>
</div>
<div role="alert" className="alert alert-success alert-soft">
<span>Semantic tokens stay stable while the atmosphere changes.</span>
</div>
</div>
</div>
</section>
);
}
</section>
);
};
const meta = {
title: 'Themes/Biergarten Themes',
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: themesDescription,
},
},
},
tags: ['autodocs'],
render: () => (
<div className="grid gap-6 p-6 lg:grid-cols-2">
{biergartenThemes.map((theme) => (
<ThemePanel key={theme.value} {...theme} />
))}
</div>
),
title: 'Themes/Biergarten Themes',
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: themesDescription,
},
},
},
tags: ['autodocs'],
render: () => (
<div className="grid gap-6 p-6 lg:grid-cols-2">
{biergartenThemes.map((theme) => (
<ThemePanel key={theme.value} {...theme} />
))}
</div>
),
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
export const Gallery: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
for (const theme of biergartenThemes) {
await expect(canvas.getByRole('heading', { name: theme.label })).toBeInTheDocument();
}
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
for (const theme of biergartenThemes) {
await expect(canvas.getByRole('heading', { name: theme.label })).toBeInTheDocument();
}
},
};

View File

@@ -1,74 +1,74 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, within } from 'storybook/test';
import ToastProvider from '../app/components/toast/ToastProvider';
import ToastProvider from '../app/components/ui/toast/ToastProvider';
import {
dismissToasts,
showErrorToast,
showInfoToast,
showSuccessToast,
} from '../app/components/toast/toast';
dismissToasts,
showErrorToast,
showInfoToast,
showSuccessToast,
} from '../app/components/ui/toast/toast';
const toastDescription = `Theme-aware toast feedback built on react-hot-toast. Use this page to trigger success, error, and info messages, check icon contrast and surface styling, and confirm notifications feel consistent across Biergarten themes.`;
function ToastDemo() {
return (
<div className="space-y-4">
<ToastProvider />
<div className="card border border-base-300 bg-base-100 shadow-md">
<div className="card-body">
<h2 className="card-title">Toast demo</h2>
<p className="text-sm text-base-content/70">
Use these actions to preview toast styles.
</p>
<div className="flex flex-wrap gap-2">
<button
className="btn btn-success btn-sm"
onClick={() => showSuccessToast('Saved successfully')}
>
Success
</button>
<button
className="btn btn-error btn-sm"
onClick={() => showErrorToast('Something went wrong')}
>
Error
</button>
<button
className="btn btn-info btn-sm"
onClick={() => showInfoToast('Heads up: check your email')}
>
Info
</button>
<button className="btn btn-ghost btn-sm" onClick={dismissToasts}>
Dismiss all
</button>
</div>
const ToastDemo = () => {
return (
<div className="space-y-4">
<ToastProvider />
<div className="card border border-base-300 bg-base-100 shadow-md">
<div className="card-body">
<h2 className="card-title">Toast demo</h2>
<p className="text-sm text-base-content/70">
Use these actions to preview toast styles.
</p>
<div className="flex flex-wrap gap-2">
<button
className="btn btn-success btn-sm"
onClick={() => showSuccessToast('Saved successfully')}
>
Success
</button>
<button
className="btn btn-error btn-sm"
onClick={() => showErrorToast('Something went wrong')}
>
Error
</button>
<button
className="btn btn-info btn-sm"
onClick={() => showInfoToast('Heads up: check your email')}
>
Info
</button>
<button className="btn btn-ghost btn-sm" onClick={dismissToasts}>
Dismiss all
</button>
</div>
</div>
</div>
</div>
</div>
);
}
</div>
);
};
const meta = {
title: 'Feedback/Toast',
component: ToastDemo,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: toastDescription,
},
},
},
title: 'Feedback/Toast',
component: ToastDemo,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: toastDescription,
},
},
},
} satisfies Meta<typeof ToastDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /success/i }));
await expect(screen.getByText(/saved successfully/i)).toBeInTheDocument();
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /success/i }));
await expect(screen.getByText(/saved successfully/i)).toBeInTheDocument();
},
};

View File

@@ -0,0 +1,107 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { createMemoryRouter, redirect, RouterProvider, useActionData } from 'react-router';
import { expect, userEvent, within } from 'storybook/test';
import UsernameSection from '../app/features/account/components/UsernameSection';
import { updateUsernameSchema } from '../app/features/account/schemas';
import type { ActionResult } from '../app/features/account/types';
const usernameSectionDescription = `Renders the real \`UsernameSection\` component (the account page's collapsible username-change card) behind a memory router so its \`useSubmit\`/\`useNavigation\` wiring runs against a fake action, exercising the real \`updateUsernameSchema\` (zod) validation. On success the real action redirects back to the account page with a toast message instead of returning data - here it redirects to a marker route so the redirect itself is the observable outcome, matching how the real route behaves.`;
const UsernameSectionRoute = () => {
const [open, setOpen] = useState(true);
const result = useActionData() as ActionResult | undefined;
return (
<div className="w-full max-w-md">
<UsernameSection
username="jane_doe"
open={open}
onToggle={() => setOpen((value) => !value)}
result={result}
/>
</div>
);
};
const UsernameSectionHarness = () => {
const [router] = useState(() =>
createMemoryRouter([
{
path: '/',
element: <UsernameSectionRoute />,
action: async ({ request }) => {
const formData = await request.formData();
const parsed = updateUsernameSchema.safeParse({
newUsername: formData.get('newUsername'),
});
if (!parsed.success) {
return {
intent: 'username',
error: parsed.error.issues[0].message,
} satisfies ActionResult;
}
return redirect('/success');
},
},
{
path: '/success',
element: <p data-testid="redirect-success">Username updated successfully.</p>,
},
]),
);
return <RouterProvider router={router} />;
};
const meta = {
title: 'Account/UsernameSection',
component: UsernameSectionHarness,
tags: ['autodocs'],
parameters: {
layout: 'centered',
usesDataRouter: true,
docs: {
description: {
component: usernameSectionDescription,
},
},
},
} satisfies Meta<typeof UsernameSectionHarness>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Open: Story = {};
export const RejectsShortUsername: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.clear(canvas.getByLabelText(/new username/i));
await userEvent.type(canvas.getByLabelText(/new username/i), 'ab');
await userEvent.click(canvas.getByRole('button', { name: /update username/i }));
await expect(canvas.getByText(/at least 3 characters/i)).toBeInTheDocument();
},
};
export const RejectsInvalidCharacters: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.clear(canvas.getByLabelText(/new username/i));
await userEvent.type(canvas.getByLabelText(/new username/i), 'bad user!');
await userEvent.click(canvas.getByRole('button', { name: /update username/i }));
await expect(canvas.getByText(/letters, numbers, dots, underscores/i)).toBeInTheDocument();
},
};
export const AcceptsValidUsername: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.clear(canvas.getByLabelText(/new username/i));
await userEvent.type(canvas.getByLabelText(/new username/i), 'valid.user-99');
await userEvent.click(canvas.getByRole('button', { name: /update username/i }));
await expect(canvas.getByTestId('redirect-success')).toBeInTheDocument();
},
};