mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-04-05 18:09:04 +00:00
add storybook
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -17,7 +17,10 @@
|
||||
|
||||
# project-specific build artifacts
|
||||
/src/Website/build/
|
||||
/src/Website/storybook-static/
|
||||
/src/Website/.react-router/
|
||||
/src/Website/playwright-report/
|
||||
/src/Website/test-results/
|
||||
/test-results/
|
||||
|
||||
# misc
|
||||
@@ -495,3 +498,6 @@ FodyWeavers.xsd
|
||||
.env.dev
|
||||
.env.test
|
||||
.env.prod
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
49
src/Website/.storybook/main.ts
Normal file
49
src/Website/.storybook/main.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: [
|
||||
"../stories/Configure.mdx",
|
||||
"../stories/SubmitButton.stories.tsx",
|
||||
"../stories/FormField.stories.tsx",
|
||||
"../stories/Navbar.stories.tsx",
|
||||
"../stories/Themes.stories.tsx",
|
||||
],
|
||||
addons: [
|
||||
"@chromatic-com/storybook",
|
||||
"@storybook/addon-vitest",
|
||||
"@storybook/addon-a11y",
|
||||
"@storybook/addon-docs",
|
||||
"@storybook/addon-onboarding",
|
||||
],
|
||||
framework: "@storybook/react-vite",
|
||||
async viteFinal(config) {
|
||||
config.plugins = (config.plugins ?? []).filter((plugin) => {
|
||||
if (!plugin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pluginName = typeof plugin === "object" && "name" in plugin ? plugin.name : "";
|
||||
return !pluginName.startsWith("react-router");
|
||||
});
|
||||
|
||||
config.build ??= {};
|
||||
config.build.rollupOptions ??= {};
|
||||
|
||||
const previousOnWarn = config.build.rollupOptions.onwarn;
|
||||
config.build.rollupOptions.onwarn = (warning, warn) => {
|
||||
if (warning.code === "MODULE_LEVEL_DIRECTIVE") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof previousOnWarn === "function") {
|
||||
previousOnWarn(warning, warn);
|
||||
return;
|
||||
}
|
||||
|
||||
warn(warning);
|
||||
};
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
6
src/Website/.storybook/preview-head.html
Normal file
6
src/Website/.storybook/preview-head.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..900;1,9..40,100..900&family=Volkhov:ital,wght@0,400;0,700;1,400;1,700&display=swap"
|
||||
/>
|
||||
63
src/Website/.storybook/preview.ts
Normal file
63
src/Website/.storybook/preview.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import { createElement } from "react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import "../app/app.css";
|
||||
import { biergartenThemes, defaultThemeName, isBiergartenTheme } from "../app/lib/themes";
|
||||
|
||||
const preview: Preview = {
|
||||
globalTypes: {
|
||||
theme: {
|
||||
description: "Active Biergarten theme",
|
||||
toolbar: {
|
||||
title: "Theme",
|
||||
icon: "paintbrush",
|
||||
dynamicTitle: true,
|
||||
items: biergartenThemes.map((theme) => ({
|
||||
value: theme.value,
|
||||
title: theme.label,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: {
|
||||
theme: defaultThemeName,
|
||||
},
|
||||
decorators: [
|
||||
(Story, context) => {
|
||||
const theme = isBiergartenTheme(String(context.globals.theme))
|
||||
? context.globals.theme
|
||||
: defaultThemeName;
|
||||
|
||||
return createElement(
|
||||
MemoryRouter,
|
||||
undefined,
|
||||
createElement(
|
||||
"div",
|
||||
{
|
||||
"data-theme": theme,
|
||||
className: "bg-base-200 p-6 text-base-content",
|
||||
},
|
||||
createElement("div", { className: "mx-auto max-w-7xl" }, createElement(Story)),
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
layout: "padded",
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: "todo",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
7
src/Website/.storybook/vitest.setup.ts
Normal file
7
src/Website/.storybook/vitest.setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
|
||||
import { setProjectAnnotations } from '@storybook/react-vite';
|
||||
import * as projectAnnotations from './preview';
|
||||
|
||||
// This is an important step to apply the right configuration when testing your stories.
|
||||
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
|
||||
@@ -124,21 +124,21 @@ h6,
|
||||
color-scheme: "dark";
|
||||
|
||||
/* Base — blackberry-stained barrel, dark purple-black */
|
||||
--color-base-100: oklch(13% 0.022 295);
|
||||
--color-base-200: oklch(17% 0.028 292);
|
||||
--color-base-300: oklch(22% 0.032 290);
|
||||
--color-base-100: oklch(13% 0.01 295);
|
||||
--color-base-200: oklch(17% 0.013 292);
|
||||
--color-base-300: oklch(22% 0.016 290);
|
||||
--color-base-content: oklch(90% 0.014 300);
|
||||
|
||||
/* Primary — cassis berry purple */
|
||||
--color-primary: oklch(52% 0.15 295);
|
||||
--color-primary: oklch(52% 0.07 295);
|
||||
--color-primary-content: oklch(97% 0.008 295);
|
||||
|
||||
/* Secondary — sour cherry */
|
||||
--color-secondary: oklch(46% 0.11 10);
|
||||
--color-secondary: oklch(46% 0.05 10);
|
||||
--color-secondary-content: oklch(97% 0.006 10);
|
||||
|
||||
/* Accent — tart lime zest */
|
||||
--color-accent: oklch(75% 0.1 130);
|
||||
--color-accent: oklch(75% 0.045 130);
|
||||
--color-accent-content: oklch(18% 0.04 130);
|
||||
|
||||
/* Neutral — deep blackened grape */
|
||||
@@ -146,19 +146,19 @@ h6,
|
||||
--color-neutral-content: oklch(88% 0.01 295);
|
||||
|
||||
/* Info — muted indigo */
|
||||
--color-info: oklch(46% 0.065 250);
|
||||
--color-info: oklch(46% 0.035 250);
|
||||
--color-info-content: oklch(97% 0.006 250);
|
||||
|
||||
/* Success — dark elderberry green */
|
||||
--color-success: oklch(50% 0.065 145);
|
||||
--color-success: oklch(50% 0.035 145);
|
||||
--color-success-content: oklch(97% 0.006 145);
|
||||
|
||||
/* Warning — sour apricot */
|
||||
--color-warning: oklch(70% 0.1 65);
|
||||
--color-warning: oklch(70% 0.05 65);
|
||||
--color-warning-content: oklch(18% 0.03 65);
|
||||
|
||||
/* Error — kriek red */
|
||||
--color-error: oklch(50% 0.13 22);
|
||||
--color-error: oklch(50% 0.055 22);
|
||||
--color-error-content: oklch(97% 0.006 22);
|
||||
|
||||
--radius-selector: 0.5rem;
|
||||
|
||||
41
src/Website/app/lib/themes.ts
Normal file
41
src/Website/app/lib/themes.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export type ThemeName =
|
||||
| "biergarten-lager"
|
||||
| "biergarten-stout"
|
||||
| "biergarten-cassis"
|
||||
| "biergarten-weizen";
|
||||
|
||||
export interface ThemeOption {
|
||||
value: ThemeName;
|
||||
label: string;
|
||||
vibe: string;
|
||||
}
|
||||
|
||||
export const defaultThemeName: ThemeName = "biergarten-lager";
|
||||
export const themeStorageKey = "biergarten-theme";
|
||||
|
||||
export const biergartenThemes: ThemeOption[] = [
|
||||
{
|
||||
value: "biergarten-lager",
|
||||
label: "Biergarten Lager",
|
||||
vibe: "Muted parchment, mellow amber, daytime beer garden",
|
||||
},
|
||||
{
|
||||
value: "biergarten-stout",
|
||||
label: "Biergarten Stout",
|
||||
vibe: "Charred barrel, deep roast, cozy evening cellar",
|
||||
},
|
||||
{
|
||||
value: "biergarten-cassis",
|
||||
label: "Biergarten Cassis",
|
||||
vibe: "Blackberry barrel, sour berry dark, vivid night market",
|
||||
},
|
||||
{
|
||||
value: "biergarten-weizen",
|
||||
label: "Biergarten Weizen",
|
||||
vibe: "Ultra-light young barley, green undertone, bright spring afternoon",
|
||||
},
|
||||
];
|
||||
|
||||
export function isBiergartenTheme(value: string | null | undefined): value is ThemeName {
|
||||
return biergartenThemes.some((theme) => theme.value === value);
|
||||
}
|
||||
@@ -1,37 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
biergartenThemes,
|
||||
defaultThemeName,
|
||||
isBiergartenTheme,
|
||||
themeStorageKey,
|
||||
type ThemeName,
|
||||
} from "../lib/themes";
|
||||
import type { Route } from "./+types/theme";
|
||||
|
||||
interface ThemeOption {
|
||||
value: "biergarten-lager" | "biergarten-stout" | "biergarten-cassis" | "biergarten-weizen";
|
||||
label: string;
|
||||
vibe: string;
|
||||
}
|
||||
|
||||
const themeOptions: ThemeOption[] = [
|
||||
{
|
||||
value: "biergarten-lager",
|
||||
label: "Biergarten Lager",
|
||||
vibe: "Warm parchment, golden amber, daytime beer garden",
|
||||
},
|
||||
{
|
||||
value: "biergarten-stout",
|
||||
label: "Biergarten Stout",
|
||||
vibe: "Charred barrel, deep roast, cozy evening cellar",
|
||||
},
|
||||
{
|
||||
value: "biergarten-cassis",
|
||||
label: "Biergarten Cassis",
|
||||
vibe: "Blackberry barrel, sour kriek, dark berry night",
|
||||
},
|
||||
{
|
||||
value: "biergarten-weizen",
|
||||
label: "Biergarten Weizen",
|
||||
vibe: "Hazy straw wheat, banana-clove, sunny afternoon",
|
||||
},
|
||||
];
|
||||
|
||||
const storageKey = "biergarten-theme";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Theme | The Biergarten App" },
|
||||
@@ -42,23 +18,19 @@ export function meta({}: Route.MetaArgs) {
|
||||
];
|
||||
}
|
||||
|
||||
function isValidTheme(value: string | null): value is ThemeOption["value"] {
|
||||
return themeOptions.some((theme) => theme.value === value);
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeOption["value"]) {
|
||||
function applyTheme(theme: ThemeName) {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem(storageKey, theme);
|
||||
localStorage.setItem(themeStorageKey, theme);
|
||||
}
|
||||
|
||||
export default function ThemePage() {
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemeOption["value"]>(() => {
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return "biergarten-lager";
|
||||
return defaultThemeName;
|
||||
}
|
||||
|
||||
const savedTheme = localStorage.getItem(storageKey);
|
||||
return isValidTheme(savedTheme) ? savedTheme : "biergarten-lager";
|
||||
const savedTheme = localStorage.getItem(themeStorageKey);
|
||||
return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,7 +38,7 @@ export default function ThemePage() {
|
||||
}, [selectedTheme]);
|
||||
|
||||
const activeTheme =
|
||||
themeOptions.find((theme) => theme.value === selectedTheme) ?? themeOptions[0];
|
||||
biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-base-200 px-4 py-8 sm:px-6 lg:px-8">
|
||||
@@ -97,7 +69,7 @@ export default function ThemePage() {
|
||||
role="radiogroup"
|
||||
aria-label="Theme selector"
|
||||
>
|
||||
{themeOptions.map((theme) => {
|
||||
{biergartenThemes.map((theme) => {
|
||||
const checked = selectedTheme === theme.value;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,48 +1,47 @@
|
||||
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||
import storybook from "eslint-plugin-storybook";
|
||||
|
||||
import js from "@eslint/js";
|
||||
import prettierConfig from "eslint-config-prettier";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["build/**", "node_modules/**", ".react-router/**", "coverage/**"],
|
||||
export default tseslint.config({
|
||||
ignores: ["build/**", "node_modules/**", ".react-router/**", "coverage/**"],
|
||||
}, {
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.stylistic,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.stylistic,
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"no-empty-pattern": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"no-empty-pattern": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
prettierConfig,
|
||||
);
|
||||
}, prettierConfig, storybook.configs["flat/recommended"]);
|
||||
|
||||
2139
src/Website/package-lock.json
generated
2139
src/Website/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,11 @@
|
||||
"format": "prettier . --write",
|
||||
"format:check": "prettier . --check",
|
||||
"typegen": "react-router typegen",
|
||||
"typecheck": "npm run typegen && tsc -p tsconfig.json"
|
||||
"typecheck": "npm run typegen && tsc -p tsconfig.json",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"test:storybook": "vitest run --project storybook",
|
||||
"test:storybook:playwright": "playwright test -c playwright.storybook.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.9",
|
||||
@@ -28,23 +32,36 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@storybook/addon-a11y": "^10.2.19",
|
||||
"@storybook/addon-docs": "^10.2.19",
|
||||
"@storybook/addon-onboarding": "^10.2.19",
|
||||
"@storybook/addon-vitest": "^10.2.19",
|
||||
"@storybook/react-vite": "^10.2.19",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitest/browser-playwright": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"daisyui": "^5.5.19",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-storybook": "^10.2.19",
|
||||
"globals": "^17.4.0",
|
||||
"playwright": "^1.58.2",
|
||||
"postcss": "^8.5.8",
|
||||
"prettier": "^3.8.1",
|
||||
"storybook": "^10.2.19",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^7.0.0"
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
20
src/Website/playwright.storybook.config.ts
Normal file
20
src/Website/playwright.storybook.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const port = process.env.STORYBOOK_PORT ?? "6006";
|
||||
const baseURL = process.env.STORYBOOK_URL ?? `http://127.0.0.1:${port}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/playwright",
|
||||
timeout: 30_000,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
webServer: {
|
||||
command: `npm run storybook -- --ci --port ${port}`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
41
src/Website/stories/Configure.mdx
Normal file
41
src/Website/stories/Configure.mdx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Meta } from "@storybook/addon-docs/blocks";
|
||||
|
||||
<Meta title="Docs/Storybook" />
|
||||
|
||||
# Biergarten Storybook
|
||||
|
||||
This Storybook is scoped to real app UI only:
|
||||
|
||||
- `SubmitButton`
|
||||
- `FormField`
|
||||
- `Navbar`
|
||||
- `Themes` gallery
|
||||
|
||||
## Theme workflow
|
||||
|
||||
Use the toolbar theme switcher to preview all Biergarten themes:
|
||||
|
||||
- `biergarten-lager`
|
||||
- `biergarten-stout`
|
||||
- `biergarten-cassis`
|
||||
- `biergarten-weizen`
|
||||
|
||||
Stories are rendered inside a decorator that sets `data-theme`, so tokens and components reflect production styling.
|
||||
|
||||
## Tests
|
||||
|
||||
Two layers are enabled:
|
||||
|
||||
1. Story `play` tests (Storybook test runner / Vitest addon)
|
||||
2. Browser checks with Playwright against Storybook iframe routes
|
||||
|
||||
Run:
|
||||
|
||||
- `npm run build-storybook -- --test`
|
||||
- `npm run test:storybook:playwright`
|
||||
|
||||
## Rules
|
||||
|
||||
- Add stories only for reusable app components.
|
||||
- Prefer semantic classes (`bg-primary`, `text-base-content`, etc.).
|
||||
- Keep stories state-focused and minimal.
|
||||
61
src/Website/stories/FormField.stories.tsx
Normal file
61
src/Website/stories/FormField.stories.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, within } from "storybook/test";
|
||||
import FormField from "../app/components/forms/FormField";
|
||||
|
||||
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",
|
||||
},
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
||||
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");
|
||||
},
|
||||
};
|
||||
|
||||
export const PasswordField: Story = {
|
||||
args: {
|
||||
id: "password",
|
||||
name: "password",
|
||||
type: "password",
|
||||
label: "Password",
|
||||
placeholder: "Enter a strong password",
|
||||
hint: "Use 12 or more characters.",
|
||||
},
|
||||
};
|
||||
62
src/Website/stories/Navbar.stories.tsx
Normal file
62
src/Website/stories/Navbar.stories.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import Navbar from "../app/components/Navbar";
|
||||
|
||||
const meta = {
|
||||
title: "Navigation/Navbar",
|
||||
component: Navbar,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
} 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();
|
||||
},
|
||||
};
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
45
src/Website/stories/SubmitButton.stories.tsx
Normal file
45
src/Website/stories/SubmitButton.stories.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, within } from "storybook/test";
|
||||
import SubmitButton from "../app/components/forms/SubmitButton";
|
||||
|
||||
const meta = {
|
||||
title: "Forms/SubmitButton",
|
||||
component: SubmitButton,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
idleText: "Save changes",
|
||||
submittingText: "Saving changes",
|
||||
isSubmitting: false,
|
||||
},
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
} 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();
|
||||
},
|
||||
};
|
||||
|
||||
export const Submitting: Story = {
|
||||
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",
|
||||
},
|
||||
};
|
||||
67
src/Website/stories/Themes.stories.tsx
Normal file
67
src/Website/stories/Themes.stories.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { biergartenThemes } from "../app/lib/themes";
|
||||
|
||||
function ThemeSwatch({ label, className }: { label: string; className: string }) {
|
||||
return <div className={`rounded-box p-3 text-sm font-medium ${className}`}>{label}</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>
|
||||
|
||||
<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 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-info alert-soft">
|
||||
<span>Semantic tokens stay stable while the atmosphere changes.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Themes/Biergarten Themes",
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
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();
|
||||
}
|
||||
},
|
||||
};
|
||||
49
src/Website/tests/playwright/storybook.components.spec.ts
Normal file
49
src/Website/tests/playwright/storybook.components.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const themes = [
|
||||
"biergarten-lager",
|
||||
"biergarten-stout",
|
||||
"biergarten-cassis",
|
||||
"biergarten-weizen",
|
||||
] as const;
|
||||
|
||||
test.describe("storybook component coverage", () => {
|
||||
for (const theme of themes) {
|
||||
test(`SubmitButton idle renders in ${theme}`, async ({ page }) => {
|
||||
await page.goto(`/iframe.html?id=forms-submitbutton--idle&globals=theme:${theme}`);
|
||||
await expect(page.getByRole("button", { name: /save changes/i })).toBeVisible();
|
||||
await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test(`FormField error renders in ${theme}`, async ({ page }) => {
|
||||
await page.goto(`/iframe.html?id=forms-formfield--with-error&globals=theme:${theme}`);
|
||||
await expect(page.getByLabel("Email address")).toBeVisible();
|
||||
await expect(page.getByText(/valid email address/i)).toBeVisible();
|
||||
await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test(`Navbar guest renders in ${theme}`, async ({ page }) => {
|
||||
await page.goto(`/iframe.html?id=navigation-navbar--guest&globals=theme:${theme}`);
|
||||
await expect(page.getByRole("link", { name: /the biergarten app/i })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /^login$/i })).toBeVisible();
|
||||
await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test("Navbar authenticated state renders", async ({ page }) => {
|
||||
await page.goto(
|
||||
`/iframe.html?id=navigation-navbar--authenticated&globals=theme:biergarten-stout`,
|
||||
);
|
||||
await expect(page.getByRole("button", { name: /hans/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Theme gallery shows all themes", async ({ page }) => {
|
||||
await page.goto(
|
||||
`/iframe.html?id=themes-biergarten-themes--gallery&globals=theme:biergarten-lager`,
|
||||
);
|
||||
await expect(page.getByRole("heading", { name: "Biergarten Lager" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Biergarten Stout" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Biergarten Cassis" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Biergarten Weizen" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,47 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
|
||||
import { playwright } from "@vitest/browser-playwright";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
const dirname =
|
||||
typeof __dirname !== "undefined" ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
||||
const isStorybook =
|
||||
process.env.STORYBOOK === "true" || process.argv.some((arg) => arg.includes("storybook"));
|
||||
|
||||
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
|
||||
export default defineConfig({
|
||||
plugins: [reactRouter()],
|
||||
plugins: isStorybook ? [] : [reactRouter()],
|
||||
resolve: {
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
extends: true,
|
||||
plugins: [
|
||||
// The plugin will run tests for the stories defined in your Storybook config
|
||||
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
|
||||
storybookTest({
|
||||
configDir: path.join(dirname, ".storybook"),
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
name: "storybook",
|
||||
browser: {
|
||||
enabled: true,
|
||||
headless: true,
|
||||
provider: playwright({}),
|
||||
instances: [
|
||||
{
|
||||
browser: "chromium",
|
||||
},
|
||||
],
|
||||
},
|
||||
setupFiles: [".storybook/vitest.setup.ts"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
1
src/Website/vitest.shims.d.ts
vendored
Normal file
1
src/Website/vitest.shims.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="@vitest/browser-playwright" />
|
||||
Reference in New Issue
Block a user