{
"name": "design-system-nuxt3",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:ci": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "playwright test --debug",
"test:visual": "test-storybook",
"test:a11y": "vitest --run --testPathPattern=a11y",
"test:all": "npm run test:ci && npm run test:e2e && npm run test:visual",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"chromatic": "chromatic --exit-zero-on-changes",
"bundlesize": "nuxi analyze",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "nuxt typecheck"
},
"devDependencies": {
"@nuxt/devtools": "latest",
"@nuxt/eslint-config": "^0.2.0",
"@nuxt/test-utils": "^3.9.0",
"@nuxt/ui": "^2.11.1",
"@playwright/test": "^1.40.0",
"@storybook/addon-a11y": "^7.6.6",
"@storybook/addon-essentials": "^7.6.6",
"@storybook/addon-interactions": "^7.6.6",
"@storybook/addon-links": "^7.6.6",
"@storybook/blocks": "^7.6.6",
"@storybook/test-runner": "^0.16.0",
"@storybook/testing-library": "^0.2.2",
"@storybook/vue3": "^7.6.6",
"@storybook/vue3-vite": "^7.6.6",
"@vue/test-utils": "^2.4.3",
"axe-playwright": "^1.2.3",
"chromatic": "^10.1.0",
"eslint": "^8.55.0",
"happy-dom": "^12.10.3",
"nuxt": "^3.9.0",
"storybook": "^7.6.6",
"typescript": "^5.3.3",
"vitest": "^1.1.0",
"vitest-axe": "^0.1.0",
"@vitest/coverage-v8": "^1.1.0",
"@vitest/ui": "^1.1.0"
},
"dependencies": {
"@headlessui/vue": "^1.7.16",
"@nuxtjs/tailwindcss": "^6.8.4",
"@phosphor-icons/vue": "^2.1.6",
"vue": "^3.3.13"
}
}
export default defineNuxtConfig({
devtools: { enabled: true },
modules: [
'@nuxtjs/tailwindcss',
'@nuxt/ui',
'@nuxt/test-utils/module'
],
css: ['~/assets/css/main.css'],
components: [
{
path: '~/components',
pathPrefix: false,
}
],
typescript: {
strict: true,
typeCheck: true
},
// Testing configuration
testUtils: {
startOnBoot: false
},
// Build optimization for testing
build: {
analyze: {
analyzerMode: 'static',
openAnalyzer: false,
generateStatsFile: true,
statsFilename: 'stats.json'
}
},
// Runtime config for testing
runtimeConfig: {
public: {
testEnvironment: process.env.NODE_ENV === 'test'
}
},
// Tailwind CSS configuration
tailwindcss: {
configPath: 'tailwind.config.ts',
exposeConfig: true,
viewer: true
}
})
import vue from '@vitejs/plugin-vue'
import { resolve } from 'node:path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
setupFiles: ['./tests/setup.ts'],
include: [
'components/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'composables/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'utils/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'tests/unit/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: [
'components/**/*.{vue,js,ts}',
'composables/**/*.{js,ts}',
'utils/**/*.{js,ts}'
],
exclude: [
'node_modules/',
'.nuxt/',
'tests/',
'**/*.d.ts',
'**/*.stories.{js,ts,vue}',
'**/index.{js,ts}'
],
thresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
},
globals: true,
transformMode: {
web: [/\.[jt]sx?$/],
ssr: [/\.vue$/]
}
},
resolve: {
alias: {
'~': resolve(__dirname, '.'),
'@': resolve(__dirname, '.'),
'~~': resolve(__dirname, '.'),
'@@': resolve(__dirname, '.')
}
},
define: {
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false
}
})
import { config } from '@vue/test-utils'
import { createNuxtApp } from '#app'
import 'vitest-axe/extend-expect'
// Mock Nuxt composables
vi.mock('#app', () => ({
useNuxtApp: () => ({
$router: {
push: vi.fn(),
replace: vi.fn(),
go: vi.fn(),
back: vi.fn(),
forward: vi.fn()
}
}),
navigateTo: vi.fn(),
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
go: vi.fn(),
back: vi.fn(),
forward: vi.fn()
}),
useRoute: () => ({
path: '/',
params: {},
query: {},
hash: '',
name: 'index'
}),
useHead: vi.fn(),
useSeoMeta: vi.fn(),
useState: vi.fn(),
useRuntimeConfig: () => ({
public: {
testEnvironment: true
}
})
}))
// Mock Phosphor Icons
vi.mock('@phosphor-icons/vue', () => ({
PhCheckCircle: { template: '<div data-testid="check-circle-icon" />' },
PhWarning: { template: '<div data-testid="warning-icon" />' },
PhXCircle: { template: '<div data-testid="x-circle-icon" />' },
PhInfo: { template: '<div data-testid="info-icon" />' },
PhSpinner: { template: '<div data-testid="spinner-icon" />' },
PhCursorClick: { template: '<div data-testid="cursor-click-icon" />' }
}))
// Global test configuration
config.global.mocks = {
$router: {
push: vi.fn(),
replace: vi.fn()
},
$route: {
path: '/',
params: {},
query: {}
}
}
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
observe() { return null }
disconnect() { return null }
unobserve() { return null }
}
// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
constructor() {}
observe() { return null }
disconnect() { return null }
unobserve() { return null }
}
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
<script setup lang="ts">
import { PhSpinner } from '@phosphor-icons/vue'
import { computed } from 'vue'
export interface ButtonProps {
variant?: 'primary' | 'secondary' | 'accent' | 'ghost' | 'outline'
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
disabled?: boolean
loading?: boolean
icon?: any
type?: 'button' | 'submit' | 'reset'
ariaLabel?: string
testId?: string
}
interface ButtonEmits {
click: [event: MouseEvent]
}
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'primary',
size: 'md',
disabled: false,
loading: false,
type: 'button'
})
const emit = defineEmits<ButtonEmits>()
const buttonClasses = computed(() => {
const baseClasses = 'btn'
const variantClasses = `btn-${props.variant}`
const sizeClasses = `btn-${props.size}`
const stateClasses = [
props.loading && 'btn-loading',
props.disabled && 'btn-disabled'
].filter(Boolean).join(' ')
return [baseClasses, variantClasses, sizeClasses, stateClasses]
.filter(Boolean)
.join(' ')
})
const iconSize = computed(() => {
const sizeMap = {
xs: 12,
sm: 14,
md: 16,
lg: 18,
xl: 20
}
return sizeMap[props.size]
})
function handleClick(event: MouseEvent) {
if (!props.disabled && !props.loading) {
emit('click', event)
}
}
</script>
<template>
<button
:type="type"
:class="buttonClasses"
:disabled="disabled || loading"
:aria-label="ariaLabel"
:data-testid="testId"
@click="handleClick"
>
<PhSpinner
v-if="loading"
class="btn-icon animate-spin"
:size="iconSize"
/>
<component
:is="icon"
v-else-if="icon"
class="btn-icon"
:size="iconSize"
/>
<span class="btn-text">
<slot />
</span>
</button>
</template>
<style scoped>
/* Button base styles */
.btn {
@apply inline-flex items-center justify-center gap-2 px-4 py-2
text-sm font-medium rounded-md transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
}
/* Variant styles */
.btn-primary {
@apply bg-primary text-white hover:bg-primary/90
focus:ring-primary/50 active:bg-primary/95;
}
.btn-secondary {
@apply bg-secondary text-white hover:bg-secondary/90
focus:ring-secondary/50 active:bg-secondary/95;
}
.btn-accent {
@apply bg-accent text-white hover:bg-accent/90
focus:ring-accent/50 active:bg-accent/95;
}
.btn-ghost {
@apply bg-transparent text-gray-700 hover:bg-gray-100
focus:ring-gray-500/50 active:bg-gray-200;
}
.btn-outline {
@apply bg-transparent border border-primary text-primary
hover:bg-primary hover:text-white focus:ring-primary/50;
}
/* Size variants */
.btn-xs { @apply text-xs px-2 py-1; }
.btn-sm { @apply text-sm px-3 py-1.5; }
.btn-md { @apply text-sm px-4 py-2; }
.btn-lg { @apply text-base px-6 py-3; }
.btn-xl { @apply text-lg px-8 py-4; }
/* State styles */
.btn-loading {
@apply cursor-wait;
}
.btn-icon {
@apply flex-shrink-0;
}
.btn-text {
@apply whitespace-nowrap;
}
</style>
import { PhCheckCircle } from '@phosphor-icons/vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { axe } from 'vitest-axe'
import Button from './Button.vue'
describe('Button Component', () => {
describe('Rendering', () => {
it('renders with default props', () => {
const wrapper = mount(Button, {
slots: {
default: 'Click me'
}
})
expect(wrapper.find('button').exists()).toBe(true)
expect(wrapper.text()).toBe('Click me')
expect(wrapper.classes()).toContain('btn')
expect(wrapper.classes()).toContain('btn-primary')
expect(wrapper.classes()).toContain('btn-md')
})
it('renders with custom variant', () => {
const wrapper = mount(Button, {
props: { variant: 'secondary' },
slots: { default: 'Secondary' }
})
expect(wrapper.classes()).toContain('btn-secondary')
})
it('renders with custom size', () => {
const wrapper = mount(Button, {
props: { size: 'lg' },
slots: { default: 'Large' }
})
expect(wrapper.classes()).toContain('btn-lg')
})
it('renders with icon', () => {
const wrapper = mount(Button, {
props: { icon: PhCheckCircle },
slots: { default: 'With Icon' }
})
expect(wrapper.findComponent(PhCheckCircle).exists()).toBe(true)
})
})
describe('States', () => {
it('handles disabled state', () => {
const wrapper = mount(Button, {
props: { disabled: true },
slots: { default: 'Disabled' }
})
const button = wrapper.find('button')
expect(button.attributes('disabled')).toBeDefined()
expect(wrapper.classes()).toContain('btn-disabled')
})
it('handles loading state', () => {
const wrapper = mount(Button, {
props: { loading: true },
slots: { default: 'Loading' }
})
const button = wrapper.find('button')
expect(button.attributes('disabled')).toBeDefined()
expect(wrapper.classes()).toContain('btn-loading')
})
it('shows spinner in loading state', () => {
const wrapper = mount(Button, {
props: { loading: true },
slots: { default: 'Loading' }
})
expect(wrapper.find('[data-testid="spinner-icon"]').exists()).toBe(true)
})
})
describe('Interactions', () => {
it('emits click events', async () => {
const wrapper = mount(Button, {
slots: { default: 'Click me' }
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted().click).toBeTruthy()
expect(wrapper.emitted().click).toHaveLength(1)
})
it('does not emit click when disabled', async () => {
const wrapper = mount(Button, {
props: { disabled: true },
slots: { default: 'Disabled' }
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted().click).toBeFalsy()
})
it('does not emit click when loading', async () => {
const wrapper = mount(Button, {
props: { loading: true },
slots: { default: 'Loading' }
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted().click).toBeFalsy()
})
it('handles keyboard events', async () => {
const wrapper = mount(Button, {
slots: { default: 'Press Enter' }
})
await wrapper.find('button').trigger('keydown.enter')
// Note: Button doesn't emit on keydown, but we test the element receives the event
expect(wrapper.find('button').exists()).toBe(true)
})
})
describe('Accessibility', () => {
it('should not have accessibility violations', async () => {
const wrapper = mount(Button, {
slots: { default: 'Accessible Button' }
})
const results = await axe(wrapper.element)
expect(results).toHaveNoViolations()
})
it('supports custom aria-label', () => {
const wrapper = mount(Button, {
props: { ariaLabel: 'Custom label' },
slots: { default: 'Icon only' }
})
expect(wrapper.find('button').attributes('aria-label')).toBe('Custom label')
})
it('supports test id', () => {
const wrapper = mount(Button, {
props: { testId: 'test-button' },
slots: { default: 'Test Button' }
})
expect(wrapper.find('button').attributes('data-testid')).toBe('test-button')
})
})
describe('Props validation', () => {
it('accepts all valid variants', () => {
const variants = ['primary', 'secondary', 'accent', 'ghost', 'outline']
variants.forEach((variant) => {
const wrapper = mount(Button, {
props: { variant: variant as any },
slots: { default: `${variant} button` }
})
expect(wrapper.classes()).toContain(`btn-${variant}`)
})
})
it('accepts all valid sizes', () => {
const sizes = ['xs', 'sm', 'md', 'lg', 'xl']
sizes.forEach((size) => {
const wrapper = mount(Button, {
props: { size: size as any },
slots: { default: `${size} button` }
})
expect(wrapper.classes()).toContain(`btn-${size}`)
})
})
it('accepts valid button types', () => {
const types = ['button', 'submit', 'reset']
types.forEach((type) => {
const wrapper = mount(Button, {
props: { type: type as any },
slots: { default: `${type} button` }
})
expect(wrapper.find('button').attributes('type')).toBe(type)
})
})
})
describe('Performance', () => {
it('renders quickly', () => {
const startTime = performance.now()
mount(Button, {
slots: { default: 'Performance Test' }
})
const endTime = performance.now()
const renderTime = endTime - startTime
// Should render in under 16ms (60fps)
expect(renderTime).toBeLessThan(16)
})
it('handles multiple instances efficiently', () => {
const startTime = performance.now()
// Mount multiple button instances
for (let i = 0; i < 100; i++) {
const wrapper = mount(Button, {
slots: { default: `Button ${i}` }
})
wrapper.unmount()
}
const endTime = performance.now()
const totalTime = endTime - startTime
// Should handle 100 instances in under 100ms
expect(totalTime).toBeLessThan(100)
})
})
})
import type { StorybookConfig } from '@storybook/vue3-vite'
import { mergeConfig } from 'vite'
const config: StorybookConfig = {
stories: [
'../components/**/*.stories.@(js|jsx|mjs|ts|tsx|vue)',
'../pages/**/*.stories.@(js|jsx|mjs|ts|tsx|vue)',
'../stories/**/*.stories.@(js|jsx|mjs|ts|tsx|vue)'
],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y'
],
framework: {
name: '@storybook/vue3-vite',
options: {}
},
docs: {
autodocs: 'tag'
},
typescript: {
check: false,
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
propFilter: prop => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
},
},
viteFinal: async (config) => {
return mergeConfig(config, {
define: {
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false
},
resolve: {
alias: {
'~': new URL('../', import.meta.url).pathname,
'@': new URL('../', import.meta.url).pathname,
'~~': new URL('../', import.meta.url).pathname,
'@@': new URL('../', import.meta.url).pathname
}
}
})
}
}
export default config
import type { Preview } from '@storybook/vue3'
import '../assets/css/main.css'
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
docs: {
toc: true,
},
a11y: {
config: {
rules: [
{
id: 'color-contrast',
enabled: true,
},
{
id: 'focus-trap',
enabled: true,
},
],
},
},
backgrounds: {
default: 'light',
values: [
{
name: 'light',
value: '#ffffff',
},
{
name: 'dark',
value: '#1a1a1a',
},
{
name: 'gray',
value: '#f5f5f5',
},
],
},
viewport: {
viewports: {
mobile: {
name: 'Mobile',
styles: { width: '375px', height: '667px' },
},
tablet: {
name: 'Tablet',
styles: { width: '768px', height: '1024px' },
},
desktop: {
name: 'Desktop',
styles: { width: '1200px', height: '800px' },
},
},
},
},
tags: ['autodocs'],
}
export default preview
import type { Meta, StoryObj } from '@storybook/vue3'
import { PhArrowRight, PhCheckCircle, PhDownload } from '@phosphor-icons/vue'
import Button from './Button.vue'
const meta: Meta<typeof Button> = {
title: 'Elements/Button',
component: Button,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Interactive button element built with Vue 3 and Nuxt 3.',
},
},
},
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'accent', 'ghost', 'outline'],
},
size: {
control: 'select',
options: ['xs', 'sm', 'md', 'lg', 'xl'],
},
disabled: {
control: 'boolean',
},
loading: {
control: 'boolean',
},
onClick: { action: 'clicked' },
},
args: {
onClick: () => console.log('Button clicked!'),
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {
variant: 'primary',
},
render: args => ({
components: { Button },
setup() {
return { args }
},
template: '<Button v-bind="args">Primary Button</Button>',
}),
}
export const Secondary: Story = {
args: {
variant: 'secondary',
},
render: args => ({
components: { Button },
setup() {
return { args }
},
template: '<Button v-bind="args">Secondary Button</Button>',
}),
}
export const WithIcon: Story = {
args: {
variant: 'primary',
icon: PhCheckCircle,
},
render: args => ({
components: { Button },
setup() {
return { args }
},
template: '<Button v-bind="args">With Icon</Button>',
}),
}
export const Loading: Story = {
args: {
variant: 'primary',
loading: true,
},
render: args => ({
components: { Button },
setup() {
return { args }
},
template: '<Button v-bind="args">Loading...</Button>',
}),
}
export const Disabled: Story = {
args: {
variant: 'primary',
disabled: true,
},
render: args => ({
components: { Button },
setup() {
return { args }
},
template: '<Button v-bind="args">Disabled Button</Button>',
}),
}
export const Sizes: Story = {
render: () => ({
components: { Button },
template: `
<div class="flex items-center gap-4">
<Button size="xs">Extra Small</Button>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
<Button size="xl">Extra Large</Button>
</div>
`,
}),
}
export const Variants: Story = {
render: () => ({
components: { Button },
template: `
<div class="flex items-center gap-4 flex-wrap">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="accent">Accent</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="outline">Outline</Button>
</div>
`,
}),
}
export const Interactive: Story = {
render: () => ({
components: { Button, PhDownload, PhArrowRight },
setup() {
const handleDownload = () => alert('Download started!')
const handleLearnMore = () => alert('Navigation triggered!')
return { handleDownload, handleLearnMore, PhDownload, PhArrowRight }
},
template: `
<div class="space-y-4">
<div class="flex gap-4">
<Button
variant="primary"
:icon="PhDownload"
@click="handleDownload"
>
Download File
</Button>
<Button
variant="outline"
:icon="PhArrowRight"
@click="handleLearnMore"
>
Learn More
</Button>
</div>
</div>
`,
}),
}
export const AccessibilityTest: Story = {
args: {
variant: 'primary',
ariaLabel: 'This button demonstrates accessibility features',
},
render: args => ({
components: { Button },
setup() {
return { args }
},
template: '<Button v-bind="args">Accessible Button</Button>',
}),
parameters: {
a11y: {
config: {
rules: [
{
id: 'color-contrast',
enabled: true,
},
],
},
},
},
}
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html'],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/junit.xml' }],
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 12'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
})
import { expect, test } from '@playwright/test'
import { checkA11y, injectAxe } from 'axe-playwright'
test.describe('Button Component E2E (Nuxt 3)', () => {
test.beforeEach(async ({ page }) => {
// Navigate to a test page with buttons or Storybook
await page.goto('/storybook-static/iframe.html?id=elements-button--primary')
await injectAxe(page)
})
test('should render button correctly', async ({ page }) => {
const button = page.getByRole('button', { name: 'Primary Button' })
await expect(button).toBeVisible()
await expect(button).toHaveClass(/btn-primary/)
})
test('should handle click interactions', async ({ page }) => {
const button = page.getByRole('button')
await button.click()
// Verify click was registered
await expect(button).toBeFocused()
})
test('should be keyboard accessible', async ({ page }) => {
await page.keyboard.press('Tab')
const button = page.getByRole('button')
await expect(button).toBeFocused()
await page.keyboard.press('Enter')
// Verify keyboard activation
})
test('should handle loading state in Nuxt', async ({ page }) => {
await page.goto('/storybook-static/iframe.html?id=elements-button--loading')
const button = page.getByRole('button')
await expect(button).toBeDisabled()
await expect(button).toHaveClass(/btn-loading/)
})
test('should pass accessibility tests', async ({ page }) => {
await checkA11y(page)
})
test('should work with Nuxt navigation', async ({ page }) => {
// Test actual Nuxt page if you have one
await page.goto('/')
const button = page.getByRole('button').first()
if (await button.isVisible()) {
await button.click()
// Test navigation or state changes specific to Nuxt
}
})
test('should handle Vue 3 reactivity', async ({ page }) => {
await page.goto('/storybook-static/iframe.html?id=elements-button--interactive')
const downloadButton = page.getByRole('button', { name: /download/i })
await downloadButton.click()
// Test for Vue 3 reactive updates
// You might check for state changes, emit events, etc.
})
test('should maintain performance with Nuxt hydration', async ({ page }) => {
const startTime = Date.now()
await page.goto('/')
// Wait for Nuxt hydration to complete
await page.waitForFunction(() => window.nuxt?.isHydrating === false, undefined, { timeout: 5000 })
const endTime = Date.now()
const hydrationTime = endTime - startTime
// Hydration should complete quickly
expect(hydrationTime).toBeLessThan(3000)
})
})
import type { ComponentMountingOptions } from '@vue/test-utils'
import { mount, VueWrapper } from '@vue/test-utils'
import { createApp } from 'vue'
// Mock Nuxt app context
export function createNuxtTestApp() {
return createApp({})
}
// Enhanced mount function with Nuxt context
export function mountWithNuxt<T>(component: T, options: ComponentMountingOptions<T> = {}): VueWrapper<any> {
const defaultOptions = {
global: {
mocks: {
$router: {
push: vi.fn(),
replace: vi.fn(),
go: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
currentRoute: {
value: {
path: '/',
params: {},
query: {},
hash: '',
name: 'index'
}
}
},
$route: {
path: '/',
params: {},
query: {},
hash: '',
name: 'index'
},
$nuxt: {
isHydrating: false,
payload: {},
ssrContext: {},
hook: vi.fn(),
callHook: vi.fn()
}
},
provide: {
nuxtApp: {
$router: {
push: vi.fn(),
replace: vi.fn()
}
}
},
stubs: {
NuxtLink: {
template: '<a><slot /></a>'
},
ClientOnly: {
template: '<div><slot /></div>'
}
}
}
}
return mount(component, {
...defaultOptions,
...options,
global: {
...defaultOptions.global,
...options.global
}
})
}
// Nuxt composables mocking utilities
export function mockUseRoute(route = {}) {
return {
path: '/',
params: {},
query: {},
hash: '',
name: 'index',
...route
}
}
export function mockUseRouter() {
return {
push: vi.fn(),
replace: vi.fn(),
go: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
beforeEach: vi.fn(),
afterEach: vi.fn()
}
}
export function mockUseNuxtApp() {
return {
$router: mockUseRouter(),
hook: vi.fn(),
callHook: vi.fn(),
provide: vi.fn(),
payload: {},
ssrContext: {},
isHydrating: false
}
}
import { PhCheckCircle } from '@phosphor-icons/vue'
import { describe, expect, it } from 'vitest'
import { axe } from 'vitest-axe'
import Button from '../../components/Button.vue'
import { mountWithNuxt } from '../utils/nuxt-test-utils'
describe('Button Accessibility Tests (Nuxt 3)', () => {
it('should not have accessibility violations - default', async () => {
const wrapper = mountWithNuxt(Button, {
slots: { default: 'Default Button' }
})
const results = await axe(wrapper.element)
expect(results).toHaveNoViolations()
})
it('should not have accessibility violations - all variants', async () => {
const variants = ['primary', 'secondary', 'accent', 'ghost', 'outline']
for (const variant of variants) {
const wrapper = mountWithNuxt(Button, {
props: { variant: variant as any },
slots: { default: `${variant} Button` }
})
const results = await axe(wrapper.element)
expect(results).toHaveNoViolations()
}
})
it('should not have accessibility violations - disabled state', async () => {
const wrapper = mountWithNuxt(Button, {
props: { disabled: true },
slots: { default: 'Disabled Button' }
})
const results = await axe(wrapper.element)
expect(results).toHaveNoViolations()
})
it('should not have accessibility violations - loading state', async () => {
const wrapper = mountWithNuxt(Button, {
props: { loading: true },
slots: { default: 'Loading Button' }
})
const results = await axe(wrapper.element)
expect(results).toHaveNoViolations()
})
it('should not have accessibility violations - with Phosphor icon', async () => {
const wrapper = mountWithNuxt(Button, {
props: { icon: PhCheckCircle },
slots: { default: 'With Icon' }
})
const results = await axe(wrapper.element)
expect(results).toHaveNoViolations()
})
it('should handle focus properly in Nuxt context', async () => {
const wrapper = mountWithNuxt(Button, {
slots: { default: 'Focus Test' }
})
const results = await axe(wrapper.element, {
rules: {
'focus-order-semantics': { enabled: true },
'focusable-content': { enabled: true },
},
})
expect(results).toHaveNoViolations()
})
it('should work with Nuxt navigation accessibility', async () => {
const wrapper = mountWithNuxt(Button, {
props: {
ariaLabel: 'Navigate to next page',
onClick: () => {} // Would use navigateTo in real component
},
slots: { default: 'Next Page' }
})
const results = await axe(wrapper.element)
expect(results).toHaveNoViolations()
})
})
import { PhCheckCircle } from '@phosphor-icons/vue'
import { describe, expect, it } from 'vitest'
import Button from '../../components/Button.vue'
import { mountWithNuxt } from '../utils/nuxt-test-utils'
describe('Button Performance Tests (Nuxt 3)', () => {
it('should render quickly with Nuxt context', () => {
const startTime = performance.now()
mountWithNuxt(Button, {
slots: { default: 'Performance Test' }
})
const endTime = performance.now()
const renderTime = endTime - startTime
// Should render in under 16ms (60fps) even with Nuxt context
expect(renderTime).toBeLessThan(16)
})
it('should handle Vue 3 reactivity efficiently', async () => {
const wrapper = mountWithNuxt(Button, {
props: { variant: 'primary' },
slots: { default: 'Reactive Test' }
})
const startTime = performance.now()
// Test reactive prop changes
await wrapper.setProps({ variant: 'secondary' })
await wrapper.setProps({ size: 'lg' })
await wrapper.setProps({ loading: true })
const endTime = performance.now()
const updateTime = endTime - startTime
// Reactive updates should be fast
expect(updateTime).toBeLessThan(10)
})
it('should handle multiple button instances with Nuxt efficiently', () => {
const startTime = performance.now()
// Mount multiple button instances with Nuxt context
const wrappers = []
for (let i = 0; i < 50; i++) {
const wrapper = mountWithNuxt(Button, {
slots: { default: `Button ${i}` }
})
wrappers.push(wrapper)
}
// Cleanup
wrappers.forEach(wrapper => wrapper.unmount())
const endTime = performance.now()
const totalTime = endTime - startTime
// Should handle 50 instances in under 50ms
expect(totalTime).toBeLessThan(50)
})
it('should handle Phosphor icons efficiently', () => {
const startTime = performance.now()
// Mount buttons with different icons
const iconButtons = []
for (let i = 0; i < 20; i++) {
const wrapper = mountWithNuxt(Button, {
props: { icon: PhCheckCircle },
slots: { default: `Icon Button ${i}` }
})
iconButtons.push(wrapper)
}
// Cleanup
iconButtons.forEach(wrapper => wrapper.unmount())
const endTime = performance.now()
const renderTime = endTime - startTime
// Should render 20 icon buttons in under 30ms
expect(renderTime).toBeLessThan(30)
})
it('should not cause memory leaks with Nuxt context', () => {
const initialMemory = (performance as any).memory?.usedJSHeapSize || 0
// Create and destroy many components with Nuxt context
for (let i = 0; i < 500; i++) {
const wrapper = mountWithNuxt(Button, {
slots: { default: `Memory Test ${i}` }
})
wrapper.unmount()
}
// Force garbage collection if available
if (global.gc) {
global.gc()
}
const finalMemory = (performance as any).memory?.usedJSHeapSize || 0
const memoryIncrease = finalMemory - initialMemory
// Memory increase should be minimal (less than 2MB for Nuxt overhead)
expect(memoryIncrease).toBeLessThan(2 * 1024 * 1024)
})
})
// Example composable for button logic
import { describe, expect, it } from 'vitest'
import { nextTick, ref } from 'vue'
// Example composable (you would create this)
export function useButton(props: any) {
const isLoading = ref(props.loading || false)
const isDisabled = ref(props.disabled || false)
const buttonClasses = computed(() => {
const baseClasses = 'btn'
const variantClasses = `btn-${props.variant || 'primary'}`
const sizeClasses = `btn-${props.size || 'md'}`
const stateClasses = [
isLoading.value && 'btn-loading',
isDisabled.value && 'btn-disabled'
].filter(Boolean).join(' ')
return [baseClasses, variantClasses, sizeClasses, stateClasses]
.filter(Boolean)
.join(' ')
})
const handleClick = (event: MouseEvent, emit: any) => {
if (!isDisabled.value && !isLoading.value) {
emit('click', event)
}
}
return {
isLoading,
isDisabled,
buttonClasses,
handleClick
}
}
describe('useButton Composable', () => {
it('should generate correct button classes', () => {
const props = { variant: 'primary', size: 'md' }
const { buttonClasses } = useButton(props)
expect(buttonClasses.value).toBe('btn btn-primary btn-md')
})
it('should handle loading state', async () => {
const props = { loading: true }
const { buttonClasses, isLoading } = useButton(props)
expect(isLoading.value).toBe(true)
expect(buttonClasses.value).toContain('btn-loading')
})
it('should handle disabled state', async () => {
const props = { disabled: true }
const { buttonClasses, isDisabled } = useButton(props)
expect(isDisabled.value).toBe(true)
expect(buttonClasses.value).toContain('btn-disabled')
})
it('should prevent clicks when disabled', () => {
const props = { disabled: true }
const { handleClick } = useButton(props)
const mockEmit = vi.fn()
const mockEvent = new MouseEvent('click')
handleClick(mockEvent, mockEmit)
expect(mockEmit).not.toHaveBeenCalled()
})
it('should allow clicks when enabled', () => {
const props = {}
const { handleClick } = useButton(props)
const mockEmit = vi.fn()
const mockEvent = new MouseEvent('click')
handleClick(mockEvent, mockEmit)
expect(mockEmit).toHaveBeenCalledWith('click', mockEvent)
})
})
name: Nuxt 3 Design System Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Run Nuxt typecheck
run: npm run typecheck
- name: Run unit tests
run: npm run test:ci
- name: Upload coverage reports
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: npm
- name: Install dependencies
run: npm ci
- name: Build Nuxt application
run: npm run build
- name: Test build artifacts
run: |
ls -la .output/
test -f .output/nitro.json
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: npm
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Build Storybook
run: npm run build-storybook
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test results
uses: actions/upload-artifact@v3
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
visual-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: npm
- name: Install dependencies
run: npm ci
- name: Build Storybook
run: npm run build-storybook
- name: Run visual tests
run: npm run test:visual
- name: Publish to Chromatic
uses: chromaui/action@v1
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
accessibility-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: npm
- name: Install dependencies
run: npm ci
- name: Run accessibility tests
run: npm run test:a11y
- name: Upload a11y results
uses: actions/upload-artifact@v3
with:
name: accessibility-results
path: a11y-results/
bundle-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: npm
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build
- name: Analyze bundle
run: npm run bundlesize
- name: Upload bundle analysis
uses: actions/upload-artifact@v3
with:
name: bundle-analysis
path: .nuxt/analyze/
import type { Config } from 'tailwindcss'
export default <Config>{
content: [
'./components/**/*.{js,vue,ts}',
'./layouts/**/*.vue',
'./pages/**/*.vue',
'./plugins/**/*.{js,ts}',
'./app.vue',
'./error.vue',
'./nuxt.config.{js,ts}',
'./.storybook/**/*.{js,ts}',
'./stories/**/*.{js,ts,vue}'
],
theme: {
extend: {
colors: {
// Brand colors
primary: {
DEFAULT: '#3b82f6',
50: '#eff6ff',
100: '#dbeafe',
500: '#3b82f6',
600: '#2563eb',
900: '#1e3a8a',
},
secondary: {
DEFAULT: '#6b7280',
500: '#6b7280',
600: '#4b5563',
900: '#111827',
},
accent: {
DEFAULT: '#8b5cf6',
500: '#8b5cf6',
600: '#7c3aed',
900: '#4c1d95',
},
// Semantic colors
success: {
DEFAULT: '#10b981',
500: '#10b981',
600: '#059669',
900: '#064e3b',
},
warning: {
DEFAULT: '#f59e0b',
500: '#f59e0b',
600: '#d97706',
900: '#78350f',
},
error: {
DEFAULT: '#ef4444',
500: '#ef4444',
600: '#dc2626',
900: '#7f1d1d',
},
info: {
DEFAULT: '#06b6d4',
500: '#06b6d4',
600: '#0891b2',
900: '#164e63',
},
},
},
},
plugins: [],
}
{
"typescript.preferences.includePackageJsonAutoImports": "auto",
"typescript.suggest.autoImports": true,
"vue.inlayHints.missingProps": true,
"vue.inlayHints.inlineHandlerLeading": true,
"vue.inlayHints.optionsWrapper": true,
"nuxt.isNuxtApp": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"vitest.enable": true,
"vitest.commandLine": "npm run test",
"testing.automaticallyOpenPeekView": "never",
"files.associations": {
"*.test.ts": "typescript",
"*.spec.ts": "typescript",
"*.stories.ts": "typescript"
},
"emmet.includeLanguages": {
"vue": "html"
},
"editor.quickSuggestions": {
"strings": true
}
}