<template>
<div
:class="calloutClasses"
:role="semanticRole"
:aria-live="ariaLive"
:data-testid="testId"
>
<component
v-if="iconComponent"
:is="iconComponent"
class="callout-icon"
:size="20"
/>
<div class="callout-content">
<h4 v-if="title" class="callout-title">
{{ title }}
</h4>
<div class="callout-message">
<slot />
</div>
<div v-if="$slots.actions" class="callout-actions mt-3">
<slot name="actions" />
</div>
</div>
<button
v-if="dismissible"
class="callout-dismiss"
type="button"
:aria-label="dismissLabel"
@click="handleDismiss"
>
<PhX :size="16" />
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
PhInfo,
PhCheckCircle,
PhWarning,
PhXCircle,
PhX
} from '@phosphor-icons/vue'
export interface CalloutProps {
type?: 'info' | 'success' | 'warning' | 'error'
title?: string
dismissible?: boolean
dismissLabel?: string
testId?: string
}
interface CalloutEmits {
dismiss: []
}
const props = withDefaults(defineProps<CalloutProps>(), {
type: 'info',
dismissLabel: 'Dismiss notification',
})
const emit = defineEmits<CalloutEmits>()
// Computed properties using Vue 3 reactivity
const calloutClasses = computed(() => [
'callout',
`callout-${props.type}`
])
const iconComponent = computed(() => {
const iconMap = {
info: PhInfo,
success: PhCheckCircle,
warning: PhWarning,
error: PhXCircle
}
return iconMap[props.type]
})
const semanticRole = computed(() => {
return props.type === 'error' ? 'alert' : 'status'
})
const ariaLive = computed(() => {
return props.type === 'error' ? 'assertive' : 'polite'
})
// Event handlers
const handleDismiss = () => {
emit('dismiss')
}
// Nuxt composables for enhanced functionality
const { $router } = useNuxtApp()
// Example: Track callout interactions
const trackCallout = (action: string) => {
// You could use Nuxt analytics modules here
console.log(`Callout ${props.type} ${action}`)
}
// Lifecycle for tracking
onMounted(() => {
trackCallout('viewed')
})
</script>
<style scoped>
.callout {
@apply rounded-lg border p-4 flex gap-3 relative;
}
.callout-icon {
@apply flex-shrink-0 mt-0.5;
}
.callout-content {
@apply flex-1 min-w-0;
}
.callout-title {
@apply font-semibold text-sm mb-1;
}
.callout-message {
@apply text-sm leading-relaxed;
}
.callout-dismiss {
@apply absolute top-2 right-2 p-1 rounded hover:bg-black/10
transition-colors duration-200 focus:outline-none
focus:ring-2 focus:ring-offset-1;
}
.callout-actions {
@apply flex gap-2;
}
/* Variant styles */
.callout-info {
@apply bg-info/10 border-info/20 text-info-900;
}
.callout-info .callout-icon,
.callout-info .callout-dismiss {
@apply text-info focus:ring-info/50;
}
.callout-success {
@apply bg-success/10 border-success/20 text-success-900;
}
.callout-success .callout-icon,
.callout-success .callout-dismiss {
@apply text-success focus:ring-success/50;
}
.callout-warning {
@apply bg-warning/10 border-warning/20 text-warning-900;
}
.callout-warning .callout-icon,
.callout-warning .callout-dismiss {
@apply text-warning focus:ring-warning/50;
}
.callout-error {
@apply bg-error/10 border-error/20 text-error-900;
}
.callout-error .callout-icon,
.callout-error .callout-dismiss {
@apply text-error focus:ring-error/50;
}
/* Dark mode support */
.dark .callout-info {
@apply bg-info/20 border-info/30 text-info-300;
}
.dark .callout-success {
@apply bg-success/20 border-success/30 text-success-300;
}
.dark .callout-warning {
@apply bg-warning/20 border-warning/30 text-warning-300;
}
.dark .callout-error {
@apply bg-error/20 border-error/30 text-error-300;
}
</style>
import { describe, it, expect, vi } from 'vitest'
import { nextTick } from 'vue'
import { axe } from 'vitest-axe'
import { mountWithNuxt } from '../tests/utils/nuxt-test-utils'
import Callout from './Callout.vue'
import Button from './Button.vue'
describe('Callout Component (Nuxt 3)', () => {
describe('Rendering & Props', () => {
it('renders with default props', () => {
const wrapper = mountWithNuxt(Callout, {
slots: { default: 'Default callout message' }
})
expect(wrapper.find('.callout').exists()).toBe(true)
expect(wrapper.text()).toContain('Default callout message')
expect(wrapper.classes()).toContain('callout-info') // default type
})
it('renders all callout types correctly', () => {
const types = ['info', 'success', 'warning', 'error'] as const
types.forEach(type => {
const wrapper = mountWithNuxt(Callout, {
props: { type },
slots: { default: `${type} message` }
})
expect(wrapper.classes()).toContain(`callout-${type}`)
expect(wrapper.find('.callout-icon').exists()).toBe(true)
})
})
it('renders with title', () => {
const wrapper = mountWithNuxt(Callout, {
props: { title: 'Important Notice' },
slots: { default: 'Message content' }
})
expect(wrapper.find('.callout-title').text()).toBe('Important Notice')
})
it('renders dismiss button when dismissible', () => {
const wrapper = mountWithNuxt(Callout, {
props: { dismissible: true },
slots: { default: 'Dismissible message' }
})
expect(wrapper.find('.callout-dismiss').exists()).toBe(true)
})
})
describe('Vue 3 Reactivity', () => {
it('updates classes when type changes', async () => {
const wrapper = mountWithNuxt(Callout, {
props: { type: 'info' },
slots: { default: 'Test message' }
})
expect(wrapper.classes()).toContain('callout-info')
await wrapper.setProps({ type: 'error' })
expect(wrapper.classes()).toContain('callout-error')
expect(wrapper.classes()).not.toContain('callout-info')
})
it('updates icon when type changes', async () => {
const wrapper = mountWithNuxt(Callout, {
props: { type: 'info' },
slots: { default: 'Test message' }
})
// Icon should be present
expect(wrapper.find('.callout-icon').exists()).toBe(true)
await wrapper.setProps({ type: 'success' })
// Icon should still be present but different component
expect(wrapper.find('.callout-icon').exists()).toBe(true)
})
it('reactively shows/hides dismiss button', async () => {
const wrapper = mountWithNuxt(Callout, {
props: { dismissible: false },
slots: { default: 'Test message' }
})
expect(wrapper.find('.callout-dismiss').exists()).toBe(false)
await wrapper.setProps({ dismissible: true })
expect(wrapper.find('.callout-dismiss').exists()).toBe(true)
})
})
describe('Events & Interactions', () => {
it('emits dismiss event when dismiss button clicked', async () => {
const wrapper = mountWithNuxt(Callout, {
props: { dismissible: true },
slots: { default: 'Dismissible message' }
})
await wrapper.find('.callout-dismiss').trigger('click')
expect(wrapper.emitted().dismiss).toBeTruthy()
expect(wrapper.emitted().dismiss).toHaveLength(1)
})
it('does not emit dismiss when not dismissible', () => {
const wrapper = mountWithNuxt(Callout, {
props: { dismissible: false },
slots: { default: 'Non-dismissible message' }
})
expect(wrapper.find('.callout-dismiss').exists()).toBe(false)
expect(wrapper.emitted().dismiss).toBeFalsy()
})
})
describe('Slots & Composition', () => {
it('renders default slot content', () => {
const wrapper = mountWithNuxt(Callout, {
slots: {
default: '<p>Rich <strong>HTML</strong> content</p>'
}
})
expect(wrapper.html()).toContain('<p>Rich <strong>HTML</strong> content</p>')
})
it('renders actions slot', () => {
const wrapper = mountWithNuxt(Callout, {
slots: {
default: 'Message with actions',
actions: '<button class="test-action">Take Action</button>'
}
})
expect(wrapper.find('.callout-actions').exists()).toBe(true)
expect(wrapper.find('.test-action').text()).toBe('Take Action')
})
it('integrates with Button component', () => {
const wrapper = mountWithNuxt(Callout, {
slots: {
default: 'Message with button',
actions: '<Button variant="primary">Action Button</Button>'
},
global: {
components: { Button }
}
})
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
})
describe('Accessibility (ARIA)', () => {
it('has correct ARIA role for different types', () => {
// Error callouts should have alert role
const errorWrapper = mountWithNuxt(Callout, {
props: { type: 'error' },
slots: { default: 'Error message' }
})
expect(errorWrapper.attributes('role')).toBe('alert')
expect(errorWrapper.attributes('aria-live')).toBe('assertive')
// Other types should have status role
const infoWrapper = mountWithNuxt(Callout, {
props: { type: 'info' },
slots: { default: 'Info message' }
})
expect(infoWrapper.attributes('role')).toBe('status')
expect(infoWrapper.attributes('aria-live')).toBe('polite')
})
it('has accessible dismiss button', () => {
const wrapper = mountWithNuxt(Callout, {
props: {
dismissible: true,
dismissLabel: 'Close notification'
},
slots: { default: 'Dismissible message' }
})
const dismissButton = wrapper.find('.callout-dismiss')
expect(dismissButton.attributes('aria-label')).toBe('Close notification')
expect(dismissButton.attributes('type')).toBe('button')
})
it('passes accessibility tests', async () => {
const wrapper = mountWithNuxt(Callout, {
props: {
type: 'warning',
title: 'Warning Title',
dismissible: true
},
slots: {
default: 'Warning message content',
actions: '<button>Take Action</button>'
}
})
const results = await axe(wrapper.element)
expect(results).toHaveNoViolations()
})
})
describe('Nuxt Integration', () => {
it('works with Nuxt composables', () => {
const wrapper = mountWithNuxt(Callout, {
slots: { default: 'Test message' }
})
// Component should mount without errors when using Nuxt composables
expect(wrapper.exists()).toBe(true)
})
it('integrates with Nuxt routing', async () => {
const mockPush = vi.fn()
const wrapper = mountWithNuxt(Callout, {
slots: {
default: 'Navigation callout',
actions: '<button @click="$router.push(\'/test\')">Navigate</button>'
},
global: {
mocks: {
$router: { push: mockPush }
}
}
})
// This would test navigation integration if you had it
expect(wrapper.exists()).toBe(true)
})
})
describe('Performance', () => {
it('renders efficiently with computed properties', () => {
const startTime = performance.now()
const wrapper = mountWithNuxt(Callout, {
props: { type: 'success', title: 'Success!' },
slots: { default: 'Performance test' }
})
const endTime = performance.now()
const renderTime = endTime - startTime
expect(renderTime).toBeLessThan(16) // 60fps
expect(wrapper.exists()).toBe(true)
})
it('handles reactive updates efficiently', async () => {
const wrapper = mountWithNuxt(Callout, {
props: { type: 'info' },
slots: { default: 'Reactive test' }
})
const startTime = performance.now()
// Multiple rapid updates
await wrapper.setProps({ type: 'success' })
await wrapper.setProps({ type: 'warning' })
await wrapper.setProps({ type: 'error' })
await wrapper.setProps({ dismissible: true })
const endTime = performance.now()
const updateTime = endTime - startTime
expect(updateTime).toBeLessThan(10)
})
})
})
import { ref, computed, nextTick } from 'vue'
export interface Notification {
id: string
type: 'info' | 'success' | 'warning' | 'error'
title?: string
message: string
duration?: number
dismissible?: boolean
}
export const useNotifications = () => {
const notifications = ref<Notification[]>([])
const maxNotifications = ref(5)
const activeNotifications = computed(() =>
notifications.value.slice(0, maxNotifications.value)
)
const notificationCount = computed(() =>
notifications.value.length
)
const add = (notification: Omit<Notification, 'id'>) => {
const id = Date.now().toString() + Math.random().toString(36).substr(2, 9)
const newNotification: Notification = {
id,
dismissible: true,
duration: 5000,
...notification
}
notifications.value.unshift(newNotification)
// Auto-dismiss if duration is set
if (newNotification.duration && newNotification.duration > 0) {
setTimeout(() => {
remove(id)
}, newNotification.duration)
}
return id
}
const remove = (id: string) => {
const index = notifications.value.findIndex(n => n.id === id)
if (index > -1) {
notifications.value.splice(index, 1)
}
}
const clear = () => {
notifications.value = []
}
const success = (message: string, options?: Partial<Notification>) => {
return add({ ...options, type: 'success', message })
}
const error = (message: string, options?: Partial<Notification>) => {
return add({ ...options, type: 'error', message })
}
const warning = (message: string, options?: Partial<Notification>) => {
return add({ ...options, type: 'warning', message })
}
const info = (message: string, options?: Partial<Notification>) => {
return add({ ...options, type: 'info', message })
}
return {
notifications: readonly(notifications),
activeNotifications,
notificationCount,
add,
remove,
clear,
success,
error,
warning,
info
}
}
// Global instance for app-wide notifications
let globalNotifications: ReturnType<typeof useNotifications> | null = null
export const useGlobalNotifications = () => {
if (!globalNotifications) {
globalNotifications = useNotifications()
}
return globalNotifications
}
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { nextTick } from 'vue'
import { useNotifications, useGlobalNotifications } from './useNotifications'
describe('useNotifications Composable', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('Basic Functionality', () => {
it('initializes with empty notifications', () => {
const { notifications, notificationCount } = useNotifications()
expect(notifications.value).toEqual([])
expect(notificationCount.value).toBe(0)
})
it('adds notifications correctly', () => {
const { add, notifications, notificationCount } = useNotifications()
const id = add({
type: 'info',
message: 'Test notification'
})
expect(notifications.value).toHaveLength(1)
expect(notificationCount.value).toBe(1)
expect(notifications.value[0]).toMatchObject({
id,
type: 'info',
message: 'Test notification',
dismissible: true,
duration: 5000
})
})
it('removes notifications by id', () => {
const { add, remove, notifications } = useNotifications()
const id1 = add({ type: 'info', message: 'First' })
const id2 = add({ type: 'success', message: 'Second' })
expect(notifications.value).toHaveLength(2)
remove(id1)
expect(notifications.value).toHaveLength(1)
expect(notifications.value[0].message).toBe('Second')
})
it('clears all notifications', () => {
const { add, clear, notifications } = useNotifications()
add({ type: 'info', message: 'First' })
add({ type: 'success', message: 'Second' })
expect(notifications.value).toHaveLength(2)
clear()
expect(notifications.value).toHaveLength(0)
})
})
describe('Convenience Methods', () => {
it('success method creates success notification', () => {
const { success, notifications } = useNotifications()
success('Operation completed!')
expect(notifications.value[0]).toMatchObject({
type: 'success',
message: 'Operation completed!'
})
})
it('error method creates error notification', () => {
const { error, notifications } = useNotifications()
error('Something went wrong!', { title: 'Error' })
expect(notifications.value[0]).toMatchObject({
type: 'error',
message: 'Something went wrong!',
title: 'Error'
})
})
it('warning method creates warning notification', () => {
const { warning, notifications } = useNotifications()
warning('Please be careful')
expect(notifications.value[0]).toMatchObject({
type: 'warning',
message: 'Please be careful'
})
})
it('info method creates info notification', () => {
const { info, notifications } = useNotifications()
info('Here is some information')
expect(notifications.value[0]).toMatchObject({
type: 'info',
message: 'Here is some information'
})
})
})
describe('Auto-dismiss Functionality', () => {
it('auto-dismisses notifications after duration', async () => {
const { add, notifications } = useNotifications()
add({
type: 'info',
message: 'Auto dismiss test',
duration: 1000
})
expect(notifications.value).toHaveLength(1)
// Fast-forward time
vi.advanceTimersByTime(1000)
// Wait for next tick to allow removal
await nextTick()
expect(notifications.value).toHaveLength(0)
})
it('does not auto-dismiss when duration is 0', async () => {
const { add, notifications } = useNotifications()
add({
type: 'info',
message: 'Persistent notification',
duration: 0
})
expect(notifications.value).toHaveLength(1)
vi.advanceTimersByTime(10000)
await nextTick()
expect(notifications.value).toHaveLength(1)
})
})
describe('Computed Properties', () => {
it('activeNotifications respects max limit', () => {
const { add, activeNotifications } = useNotifications()
// Add more than max (5) notifications
for (let i = 0; i < 7; i++) {
add({ type: 'info', message: `Notification ${i}` })
}
expect(activeNotifications.value).toHaveLength(5)
})
it('notificationCount reflects total count', () => {
const { add, notificationCount } = useNotifications()
for (let i = 0; i < 7; i++) {
add({ type: 'info', message: `Notification ${i}` })
}
expect(notificationCount.value).toBe(7)
})
})
describe('Reactivity', () => {
it('maintains reactivity when adding/removing', async () => {
const { add, remove, notifications } = useNotifications()
const id = add({ type: 'info', message: 'Test' })
await nextTick()
expect(notifications.value).toHaveLength(1)
remove(id)
await nextTick()
expect(notifications.value).toHaveLength(0)
})
it('computed properties update reactively', async () => {
const { add, activeNotifications, notificationCount } = useNotifications()
expect(activeNotifications.value).toHaveLength(0)
expect(notificationCount.value).toBe(0)
add({ type: 'info', message: 'Test' })
await nextTick()
expect(activeNotifications.value).toHaveLength(1)
expect(notificationCount.value).toBe(1)
})
})
describe('Global Notifications', () => {
it('returns same instance across calls', () => {
const instance1 = useGlobalNotifications()
const instance2 = useGlobalNotifications()
expect(instance1).toBe(instance2)
})
it('maintains state across component instances', () => {
const global1 = useGlobalNotifications()
global1.add({ type: 'info', message: 'Global test' })
const global2 = useGlobalNotifications()
expect(global2.notifications.value).toHaveLength(1)
expect(global2.notifications.value[0].message).toBe('Global test')
})
})
describe('Performance', () => {
it('handles large numbers of notifications efficiently', () => {
const { add, notificationCount } = useNotifications()
const startTime = performance.now()
// Add many notifications
for (let i = 0; i < 1000; i++) {
add({ type: 'info', message: `Notification ${i}` })
}
const endTime = performance.now()
const duration = endTime - startTime
expect(notificationCount.value).toBe(1000)
expect(duration).toBeLessThan(100) // Should be fast
})
})
})
<template>
<div class="design-system-page">
<Head>
<Title>Design System - {{ runtimeConfig.public.siteName }}</Title>
<Meta name="description" :content="pageDescription" />
</Head>
<div class="container-fixed py-12">
<header class="text-center mb-12">
<h1 class="text-display-lg text-gray-900 mb-4">
Design System
</h1>
<p class="text-body-lg text-gray-600 max-w-2xl mx-auto">
{{ pageDescription }}
</p>
</header>
<section class="mb-16">
<h2 class="text-heading-xl text-gray-900 mb-8">Components</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="component in components"
:key="component.name"
class="bg-white rounded-card p-6 border shadow-card hover:shadow-card-elevated transition-shadow"
>
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<component :is="component.icon" class="text-2xl text-primary" />
<h3 class="text-heading-md">{{ component.name }}</h3>
</div>
<span class="badge" :class="`badge-${component.status}`">
{{ component.status }}
</span>
</div>
<p class="text-body-sm text-gray-600 mb-4">
{{ component.description }}
</p>
<div class="flex items-center justify-between">
<span class="text-caption text-gray-500">v{{ component.version }}</span>
<Button
variant="outline"
size="sm"
@click="navigateTo(`/components/${component.slug}`)"
>
View Docs
</Button>
</div>
</div>
</div>
</section>
<section class="mb-16">
<h2 class="text-heading-xl text-gray-900 mb-8">Live Examples</h2>
<div class="space-y-8">
<div class="bg-gray-50 rounded-lg p-6">
<h3 class="text-heading-md mb-4">Buttons</h3>
<div class="flex 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>
</div>
<div class="bg-gray-50 rounded-lg p-6">
<h3 class="text-heading-md mb-4">Callouts</h3>
<div class="space-y-4">
<Callout type="info" title="Information">
This is an informational message with some helpful details.
</Callout>
<Callout type="success" title="Success!" dismissible>
Your changes have been saved successfully.
</Callout>
<Callout type="warning" title="Warning" dismissible>
Please review your settings before continuing.
</Callout>
<Callout type="error" title="Error">
Something went wrong. Please try again.
</Callout>
</div>
</div>
</div>
</section>
<section>
<h2 class="text-heading-xl text-gray-900 mb-8">Notifications Demo</h2>
<div class="flex gap-4 flex-wrap">
<Button @click="showSuccessNotification">Show Success</Button>
<Button @click="showErrorNotification" variant="outline">Show Error</Button>
<Button @click="showWarningNotification" variant="ghost">Show Warning</Button>
<Button @click="clearNotifications" variant="secondary">Clear All</Button>
</div>
</section>
</div>
<!-- Global notifications -->
<div class="fixed top-4 right-4 space-y-2 z-50">
<TransitionGroup name="notification" tag="div">
<Callout
v-for="notification in activeNotifications"
:key="notification.id"
:type="notification.type"
:title="notification.title"
dismissible
class="min-w-80 max-w-md"
@dismiss="removeNotification(notification.id)"
>
{{ notification.message }}
</Callout>
</TransitionGroup>
</div>
</div>
</template>
<script