diff --git a/.gitignore b/.gitignore index 99e727c21..3ffa5c435 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ file-tree-sprite.svg # output .vercel + +*storybook.log +storybook-static diff --git a/.storybook/README.md b/.storybook/README.md new file mode 100644 index 000000000..3f98d909a --- /dev/null +++ b/.storybook/README.md @@ -0,0 +1,88 @@ +# Why Storybook? + +Storybook is a development environment for UI components that helps catch UI changes and provides integrations for various testing types. For testing, Storybook offers: + +- **Accessibility tests** - Built-in a11y checks +- **Visual tests** - Compare JPG screenshots +- **Vitest tests** - Use stories directly in your unit tests + +## Component Categories + +The plan is to organize components into 3 categories. + +### UI Library Components + +Generic and reusable components used throughout your application. + +- Examples: Button, Input, Modal, Card +- **Testing focus:** Props, variants, accessibility +- **Coverage:** All variants and states + +### Composite Components + +Single-use components that encapsulate one feature. + +- Examples: UserProfile, WeeklyDownloadStats +- **Testing focus:** Integration patterns, user interactions +- **Coverage:** Common usage scenarios + +### Page Components + +**Full-page layouts** should match what the users see. + +- Examples: HomePage, Dashboard, CheckoutPage +- **Testing focus:** Layout, responsive behavior, integration testing +- **Coverage:** Critical user flows and breakpoints + +## Coverage Guidelines + +### Which Components Need Stories? + +TBD + +## Project Conventions + +### Place a `.stories.ts` file next to your component + +``` +components/ +├── Button.vue +└── Button.stories.ts +``` + +### Story Template + +```ts +// *.stories.ts +import type { Meta, StoryObj } from '@nuxtjs/storybook' +import Component from './Button.vue' + +const meta = { + component: Component, + // component scope configuration goes here +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + // story scope configuration goes here +} +``` + +## Configuration + +Stories can be configured at three levels: + +- **Global scope** (`.storybook/preview.ts`) - Applies to all stories +- **Component scope** - Applies to all stories for a specific component +- **Story scope** - Applies to individual stories only + +## Global App Settings + +Global application settings are added to the Storybook toolbar for easy testing and viewing. Configure these in `.storybook/preview.ts` under the `globalTypes` and `decorators` properties. + +## Known Limitations + +- `autodocs` usage is discouraged as it is buggy. +- Changing `i18n` in the toolbar doesn't update the language. A manual story reload is required. 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/Button/Base.stories.ts b/app/components/Button/Base.stories.ts new file mode 100644 index 000000000..3b21c1913 --- /dev/null +++ b/app/components/Button/Base.stories.ts @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from '@nuxtjs/storybook' +import Component from './Base.vue' + +const meta = { + component: Component, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Primary: Story = { + args: { + variant: 'primary', + size: 'medium', + }, + render: args => ({ + components: { Component }, + setup() { + return { args } + }, + template: 'Primary Button', + }), +} + +export const Secondary: Story = { + args: { + variant: 'secondary', + size: 'medium', + }, + render: args => ({ + components: { Component }, + setup() { + return { args } + }, + template: 'Secondary Button', + }), +} + +export const Small: Story = { + args: { + variant: 'secondary', + size: 'small', + }, + render: args => ({ + components: { Component }, + setup() { + return { args } + }, + template: 'Small Button', + }), +} + +export const Disabled: Story = { + args: { + variant: 'primary', + size: 'medium', + disabled: true, + }, + render: args => ({ + components: { Component }, + setup() { + return { args } + }, + template: 'Disabled Button', + }), +} + +export const WithIcon: Story = { + args: { + variant: 'secondary', + size: 'medium', + classicon: 'i-carbon:search', + }, + render: args => ({ + components: { Component }, + setup() { + return { args } + }, + template: 'Search', + }), +} + +export const WithKeyboardShortcut: Story = { + args: { + variant: 'secondary', + size: 'medium', + ariaKeyshortcuts: '/', + }, + render: args => ({ + components: { Component }, + setup() { + return { args } + }, + template: 'Search', + }), +} + +export const Block: Story = { + args: { + variant: 'primary', + size: 'medium', + block: true, + }, + render: args => ({ + components: { Component }, + setup() { + return { args } + }, + template: 'Full Width Button', + }), +} diff --git a/app/components/Button/Base.vue b/app/components/Button/Base.vue index 41f227486..7e4e864c2 100644 --- a/app/components/Button/Base.vue +++ b/app/components/Button/Base.vue @@ -1,13 +1,28 @@