{
"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 { defineConfig } from 'vitest/config'
import { resolve } from 'path'
import vue from '@vitejs/plugin-vue'
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(),
})),
})
<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
v-else-if="icon"
:is="icon"
class="btn-icon"
:size="iconSize"
/>
<span class="btn-text">
<slot />
</span>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { PhSpinner } from '@phosphor-icons/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]
})
const handleClick = (event: MouseEvent) => {
if (!props.disabled && !props.loading) {
emit('click', event)
}
}
</script>
<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 { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { axe } from 'vitest-axe'
import { PhCheckCircle } from '@phosphor-icons/vue'
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 { PhCheckCircle, PhDownload, PhArrowRight } 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