diff --git a/.gitignore b/.gitignore index 0b1d74f52..9e21577ed 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ shared/types/lexicons # output .vercel + +*storybook.log +storybook-static diff --git a/.storybook/README.md b/.storybook/README.md new file mode 100644 index 000000000..1b58418a8 --- /dev/null +++ b/.storybook/README.md @@ -0,0 +1,135 @@ +# Why are we using Storybook? + +Storybook is a development environment for UI components that helps catch changes in UI while also having integrations for different kinds of tests. For testing, Storybook provides: + +- **Accessibility tests** - Built-in a11y checks (link to example) +- **Visual tests** - Compare JPG screenshots (link to example) +- **Snapshot tests** - Compare HTML output (link to example) +- **Vitest tests** - Use stories directly in your unit tests (link to example) + +## Component Categories + +We organize components into 3 categories. + +### UI Library Components + +**Generic, reusable components** used throughout your application. + +- Examples: Button, Input, Modal, Card +- **Testing focus:** Props, variants, accessibility +- **Coverage:** All variants and states + +### Composite Components + +**Domain-specific components** built from UI library components. + +- Examples: UserProfile, ProductCard, SearchForm +- **Testing focus:** Integration patterns, user interactions +- **Coverage:** Common usage scenarios + +### Page Components + +**Full-page layouts** shown to end users. + +- Examples: HomePage, Dashboard, CheckoutPage +- **Testing focus:** Layout, responsive behavior, integration testing +- **Coverage:** Critical user flows and breakpoints + +## Coverage Guidelines + +### Which Components Need Stories? + +TBD + +### Convention + +- An edge case per story +- Do not use `autodocs` + +# How to Use + +## Writing Stories + +1. **Create your first story** - Place a `.stories.ts` file next to your component: + + ``` + components/ + ├── Button.vue + └── Button.stories.ts + ``` + +2. **Add the story code** - Each story file follows this pattern: + +```ts +// Button.stories.ts +import type { Meta, StoryObj } from '@nuxtjs/storybook' +import Component from './Button.vue' + +const meta = { + component: Component, + // component configuration goes here +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + // story configuration goes here +} +``` + +3. **Run Storybook locally:** + + ```sh + pnpm storybook + ``` + +4. **Find your story** - Storybook URLs mirror your project structure. + +For a component at `app/components/Button/Button.stories.ts`, the story will be available at `http://localhost:6006/?path=/story/components-button--default` + +## Configuration + +### Global Configuration (`.storybook/preview.ts`) + +Affects all stories across the project: + +```ts +export default { + globals: { + locale: 'en-US', + }, +} +``` + +### Component Configuration (meta) + +Overrides settings for a specific component: + +```ts +const meta = { + component: Button, + parameters: { + layout: 'centered', + }, + globals: { + locale: 'ja-JP', + }, +} +``` + +### Story Configuration + +Overrides settings for individual stories: + +```ts +export const SpecialCase: Story = { + globals: { + locale: 'fr-FR', + }, +} +``` + +## Global App Settings + +Global application settings are added to the Storybook toolbar for easy testing and viewing. Configure these in `.storybook/preview.ts` using the `globals` property with toolbar definitions. diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 000000000..ed786222a --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,11 @@ +import type { StorybookConfig } from '@nuxtjs/storybook' + +const config = { + stories: ['../app/**/*.stories.@(js|ts|mdx)'], + addons: ['@storybook/addon-a11y', '@storybook/addon-docs'], + framework: '@storybook-vue/nuxt', + features: { + backgrounds: false, + }, +} satisfies StorybookConfig +export default config diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 000000000..5f32b7da2 --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,111 @@ +import type { Preview } from '@nuxtjs/storybook' +import { currentLocales } from '../config/i18n' +import { fn } from 'storybook/test' +import { ACCENT_COLORS } from '../shared/utils/constants' + +// related: https://github.com/npmx-dev/npmx.dev/blob/1431d24be555bca5e1ae6264434d49ca15173c43/test/nuxt/setup.ts#L12-L26 +// Stub Nuxt specific globals +// @ts-expect-error - dynamic global name +globalThis['__NUXT_COLOR_MODE__'] ??= { + preference: 'system', + value: 'dark', + getColorScheme: fn(() => 'dark'), + addColorScheme: fn(), + removeColorScheme: fn(), +} +// @ts-expect-error - dynamic global name +globalThis.defineOgImageComponent = fn() + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, + // Provides toolbars to switch things like theming and language + globalTypes: { + locale: { + name: 'Locale', + description: 'UI language', + defaultValue: 'en-US', + toolbar: { + icon: 'globe', + dynamicTitle: true, + items: [ + // English is at the top so it's easier to reset to it + { value: 'en-US', title: 'English (US)' }, + ...currentLocales + .filter(locale => locale.code !== 'en-US') + .map(locale => ({ value: locale.code, title: locale.name })), + ], + }, + }, + accentColor: { + name: 'Accent Color', + description: 'Accent color', + toolbar: { + icon: 'paintbrush', + dynamicTitle: true, + items: [ + ...Object.keys(ACCENT_COLORS.light).map(color => ({ + value: color, + title: color.charAt(0).toUpperCase() + color.slice(1), + })), + { value: undefined, title: 'No Accent' }, + ], + }, + }, + theme: { + name: 'Theme', + description: 'Color mode', + defaultValue: 'dark', + toolbar: { + icon: 'moon', + dynamicTitle: true, + items: [ + { value: 'light', icon: 'sun', title: 'Light' }, + { value: 'dark', icon: 'moon', title: 'Dark' }, + ], + }, + }, + }, + decorators: [ + (story, context) => { + const { locale, theme, accentColor } = context.globals as { + locale: string + theme: string + accentColor?: string + } + + // Set theme from globals + document.documentElement.setAttribute('data-theme', theme) + + // Set accent color from globals + if (accentColor) { + document.documentElement.style.setProperty('--accent-color', `var(--swatch-${accentColor})`) + } else { + document.documentElement.style.removeProperty('--accent-color') + } + + return { + template: '', + // Set locale from globals + created() { + if (this.$i18n) { + this.$i18n.setLocale(locale) + } + }, + updated() { + if (this.$i18n) { + this.$i18n.setLocale(locale) + } + }, + } + }, + ], +} + +export default preview diff --git a/.vscode/settings.json b/.vscode/settings.json index 6ffa1b9bd..2b0dd5963 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,9 @@ "editor.formatOnSave": true, "i18n-ally.keystyle": "nested", "i18n-ally.localesPaths": ["./i18n/locales"], - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "explorer.fileNesting.enabled": true, + "explorer.fileNesting.patterns": { + "*.vue": "${capture}.stories.ts" + } } diff --git a/app/components/AppFooter.stories.ts b/app/components/AppFooter.stories.ts new file mode 100644 index 000000000..608098ea2 --- /dev/null +++ b/app/components/AppFooter.stories.ts @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from '@nuxtjs/storybook' +import AppFooter from './AppFooter.vue' + +const meta = { + component: AppFooter, + parameters: { + layout: 'fullscreen', + }, + globals: { + viewport: { value: undefined }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const InContext: Story = { + render: () => ({ + components: { AppFooter }, + template: ` +
+
+

Some page content

+ See footer at the bottom +
+ +
+ `, + }), +} + +export const InLongPage: Story = { + render: () => ({ + components: { AppFooter }, + template: ` +
+
+

Footer is all the way at the bottom!


+ ${Array.from({ length: 50 }, (_, i) => `

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

`).join('')} +
+ +
+ `, + }), +} + +export const MobileView: Story = { + ...InContext, + globals: { + viewport: { value: 'mobile1' }, + }, +} + +export const TabletView: Story = { + ...InContext, + globals: { + viewport: { value: 'tablet' }, + }, +} diff --git a/app/components/AppHeader.stories.ts b/app/components/AppHeader.stories.ts new file mode 100644 index 000000000..e5efee6fc --- /dev/null +++ b/app/components/AppHeader.stories.ts @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@nuxtjs/storybook' +import AppHeader from './AppHeader.vue' + +const meta = { + component: AppHeader, + parameters: { + layout: 'fullscreen', + }, + globals: { + viewport: { value: undefined }, + }, +} satisfies Meta + +export default meta + +export const Default: StoryObj = {} + +export const Mobile: StoryObj = { + globals: { + viewport: { value: 'mobile1' }, + }, +} diff --git a/app/components/Button/Base.stories.ts b/app/components/Button/Base.stories.ts new file mode 100644 index 000000000..22e2feca2 --- /dev/null +++ b/app/components/Button/Base.stories.ts @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from '@nuxtjs/storybook' +import ButtonBase from './Base.vue' + +const meta = { + component: ButtonBase, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Primary: Story = { + args: { + variant: 'primary', + size: 'medium', + }, + render: args => ({ + components: { ButtonBase }, + setup() { + return { args } + }, + template: 'Primary Button', + }), +} + +export const Secondary: Story = { + args: { + variant: 'secondary', + size: 'medium', + }, + render: args => ({ + components: { ButtonBase }, + setup() { + return { args } + }, + template: 'Secondary Button', + }), +} + +export const Small: Story = { + args: { + variant: 'secondary', + size: 'small', + }, + render: args => ({ + components: { ButtonBase }, + setup() { + return { args } + }, + template: 'Small Button', + }), +} + +export const Disabled: Story = { + args: { + variant: 'primary', + size: 'medium', + disabled: true, + }, + render: args => ({ + components: { ButtonBase }, + setup() { + return { args } + }, + template: 'Disabled Button', + }), +} + +export const WithIcon: Story = { + args: { + variant: 'secondary', + size: 'medium', + classicon: 'i-carbon:search', + }, + render: args => ({ + components: { ButtonBase }, + setup() { + return { args } + }, + template: 'Search', + }), +} + +export const WithKeyboardShortcut: Story = { + args: { + variant: 'secondary', + size: 'medium', + ariaKeyshortcuts: '/', + }, + render: args => ({ + components: { ButtonBase }, + setup() { + return { args } + }, + template: 'Search', + }), +} + +export const Block: Story = { + args: { + variant: 'primary', + size: 'medium', + block: true, + }, + render: args => ({ + components: { ButtonBase }, + setup() { + return { args } + }, + template: 'Full Width Button', + }), +} diff --git a/app/components/Button/Base.vue b/app/components/Button/Base.vue index 41f227486..5d668a1f3 100644 --- a/app/components/Button/Base.vue +++ b/app/components/Button/Base.vue @@ -1,13 +1,36 @@