feat: migrate Message to shadcn-vue Alert

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Matthew McPeak 2026-07-11 14:45:45 -04:00
parent fed19667f7
commit 5d840fcc16
12 changed files with 253 additions and 102 deletions

View file

@ -10,3 +10,4 @@ export { Checkbox } from './ui/checkbox'
export { CheckboxGroup } from './ui/checkbox-group' export { CheckboxGroup } from './ui/checkbox-group'
export { RadioGroup, RadioGroupItem } from './ui/radio-group' export { RadioGroup, RadioGroupItem } from './ui/radio-group'
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction } from './ui/card' export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction } from './ui/card'
export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants } from './ui/alert'

View file

@ -0,0 +1,32 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { XIcon } from '@lucide/vue'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { alertVariants } from '.'
import AlertAction from './AlertAction.vue'
import type { AlertVariants } from '.'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
variant?: AlertVariants['variant']
closable?: boolean
}>(), {
variant: 'info',
closable: false,
})
const emit = defineEmits<{ close: [] }>()
</script>
<template>
<div data-slot="alert" role="alert" :class="cn(alertVariants({ variant }), props.class)">
<slot />
<AlertAction v-if="closable">
<Button variant="secondary" size="icon" class="size-6 bg-transparent hover:bg-black/10" @click="emit('close')">
<XIcon class="size-3.5" />
<span class="sr-only">Dismiss</span>
</Button>
</AlertAction>
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-action"
:class="cn('absolute top-2 right-2', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-description"
:class="cn('text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-title"
:class="cn('font-medium group-has-[>svg]/alert:col-start-2 cn-font-heading [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,26 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Alert } from './Alert.vue'
export { default as AlertAction } from './AlertAction.vue'
export { default as AlertDescription } from './AlertDescription.vue'
export { default as AlertTitle } from './AlertTitle.vue'
export const alertVariants = cva(
'relative grid w-full gap-1 rounded-lg border px-3 py-2.5 text-sm has-data-[slot=alert-action]:pr-10',
{
variants: {
variant: {
info: 'bg-info/15 border-info/30 text-foreground [&_[data-slot=alert-title]]:text-info',
success: 'bg-success/15 border-success/30 text-foreground [&_[data-slot=alert-title]]:text-success',
warning: 'bg-warning/15 border-warning/30 text-foreground [&_[data-slot=alert-title]]:text-warning',
danger: 'bg-destructive/15 border-destructive/30 text-foreground [&_[data-slot=alert-title]]:text-destructive',
secondary: 'bg-secondary/40 border-border text-foreground',
},
},
defaultVariants: {
variant: 'info',
},
},
)
export type AlertVariants = VariantProps<typeof alertVariants>

View file

@ -26,6 +26,9 @@ export {
CardFooter, CardFooter,
CardAction, CardAction,
Dialog, Dialog,
Message, Alert,
AlertTitle,
AlertDescription,
AlertAction,
Badge, Badge,
} from './lib' } from './lib'

View file

@ -2,7 +2,6 @@ import type { App } from 'vue'
import { import {
Select as PrimeSelect, Select as PrimeSelect,
Dialog as PrimeDialog, Dialog as PrimeDialog,
Message as PrimeMessage,
} from 'primevue' } from 'primevue'
import { ThemeLibrary, type Themeable, ThemeEngine, type LibComponent, type Key } from '../engine' import { ThemeLibrary, type Themeable, ThemeEngine, type LibComponent, type Key } from '../engine'
import { import {
@ -25,6 +24,11 @@ import {
CardContent, CardContent,
CardFooter, CardFooter,
CardAction, CardAction,
Alert,
AlertTitle,
AlertDescription,
AlertAction,
alertVariants,
} from '../components' } from '../components'
export { export {
@ -46,6 +50,11 @@ export {
CardContent, CardContent,
CardFooter, CardFooter,
CardAction, CardAction,
Alert,
AlertTitle,
AlertDescription,
AlertAction,
alertVariants,
} }
/** /**
@ -110,14 +119,6 @@ const nychthemeron: ThemeLibrary = {
'content', 'content',
'footer', 'footer',
]), ]),
Message: ptThemeable(PrimeMessage, 'message', [
'contentWrapper',
'content',
'icon',
'text',
'closeButton',
'closeIcon',
]),
}, },
} }
@ -126,7 +127,6 @@ const _engine = new ThemeEngine(nychthemeron)
export const LoadingIcon = _engine.getComponent<typeof NychLoadingIcon>('LoadingIcon') export const LoadingIcon = _engine.getComponent<typeof NychLoadingIcon>('LoadingIcon')
export const Select: typeof PrimeSelect = _engine.getComponent<typeof PrimeSelect>('Select') export const Select: typeof PrimeSelect = _engine.getComponent<typeof PrimeSelect>('Select')
export const Dialog: typeof PrimeDialog = _engine.getComponent<typeof PrimeDialog>('Dialog') export const Dialog: typeof PrimeDialog = _engine.getComponent<typeof PrimeDialog>('Dialog')
export const Message: typeof PrimeMessage = _engine.getComponent<typeof PrimeMessage>('Message')
export const createNychthemeron = (): { export const createNychthemeron = (): {
Button: typeof Button Button: typeof Button
@ -147,7 +147,10 @@ export const createNychthemeron = (): {
CardFooter: typeof CardFooter CardFooter: typeof CardFooter
CardAction: typeof CardAction CardAction: typeof CardAction
Dialog: typeof PrimeDialog Dialog: typeof PrimeDialog
Message: typeof PrimeMessage Alert: typeof Alert
AlertTitle: typeof AlertTitle
AlertDescription: typeof AlertDescription
AlertAction: typeof AlertAction
Badge: typeof Badge Badge: typeof Badge
install: (app: App) => void install: (app: App) => void
} => ({ } => ({
@ -169,7 +172,10 @@ export const createNychthemeron = (): {
CardFooter, CardFooter,
CardAction, CardAction,
Dialog, Dialog,
Message, Alert,
AlertTitle,
AlertDescription,
AlertAction,
Badge, Badge,
install(app: App) { install(app: App) {
app.component('NychButton', Button) app.component('NychButton', Button)
@ -190,7 +196,10 @@ export const createNychthemeron = (): {
app.component('NychCardFooter', CardFooter) app.component('NychCardFooter', CardFooter)
app.component('NychCardAction', CardAction) app.component('NychCardAction', CardAction)
app.component('NychDialog', Dialog) app.component('NychDialog', Dialog)
app.component('NychMessage', Message) app.component('NychAlert', Alert)
app.component('NychAlertTitle', AlertTitle)
app.component('NychAlertDescription', AlertDescription)
app.component('NychAlertAction', AlertAction)
app.component('NychBadge', Badge) app.component('NychBadge', Badge)
}, },
}) })

View file

@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { h } from 'vue'
import { mount } from '@vue/test-utils'
import { Alert, AlertTitle, AlertDescription } from '../src/lib'
describe('Alert', () => {
it('defaults to the info variant', () => {
const wrapper = mount(Alert)
expect(wrapper.classes().join(' ')).toContain('bg-info/15')
})
it('applies the danger variant class', () => {
const wrapper = mount(Alert, { props: { variant: 'danger' } })
expect(wrapper.classes().join(' ')).toContain('bg-destructive/15')
})
it('renders title and description slots', () => {
const wrapper = mount(Alert, {
slots: {
default: () => [
h(AlertTitle, undefined, { default: () => 'The Oracle has spoken.' }),
h(AlertDescription, undefined, { default: () => 'Heed the omen.' }),
],
},
})
expect(wrapper.text()).toContain('The Oracle has spoken.')
expect(wrapper.text()).toContain('Heed the omen.')
})
it('does not render a close button unless closable is set', () => {
const wrapper = mount(Alert)
expect(wrapper.find('button').exists()).toBe(false)
})
it('emits close when the close button is clicked', async () => {
const wrapper = mount(Alert, { props: { closable: true } })
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
})

View file

@ -5,7 +5,6 @@ import {
LoadingIcon, LoadingIcon,
Select, Select,
Dialog, Dialog,
Message,
} from '../src/index' } from '../src/index'
//eslint-disable-next-line //eslint-disable-next-line
@ -64,7 +63,6 @@ describe('themed pt components', () => {
const cases = [ const cases = [
{ name: 'Select', component: Select, base: 'nych-select' }, { name: 'Select', component: Select, base: 'nych-select' },
{ name: 'Dialog', component: Dialog, base: 'nych-dialog' }, { name: 'Dialog', component: Dialog, base: 'nych-dialog' },
{ name: 'Message', component: Message, base: 'nych-message' },
] ]
it.each(cases)('$name is a valid Vue component', ({ component }) => { it.each(cases)('$name is a valid Vue component', ({ component }) => {

View file

@ -0,0 +1,77 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Alert as NychAlert, AlertTitle as NychAlertTitle, AlertDescription as NychAlertDescription } from '@nychthemeron/library'
const meta = {
title: 'Components/Alert',
component: NychAlert,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
variant: {
control: { type: 'select' },
options: ['info', 'success', 'warning', 'danger', 'secondary'],
},
closable: {
control: { type: 'boolean' },
},
},
} satisfies Meta<typeof NychAlert>
export default meta
type Story = StoryObj<typeof meta>
export const Info: Story = {
args: { variant: 'info' },
render: (args) => ({
components: { NychAlert, NychAlertTitle },
setup: () => ({ args }),
template: `
<NychAlert v-bind="args" style="width: 24rem;">
<NychAlertTitle>The Oracle has spoken.</NychAlertTitle>
</NychAlert>
`,
}),
}
export const Closable: Story = {
args: { variant: 'info', closable: true },
render: (args) => ({
components: { NychAlert, NychAlertTitle },
setup: () => ({ args }),
template: `
<NychAlert v-bind="args" style="width: 24rem;">
<NychAlertTitle>This omen may be dismissed.</NychAlertTitle>
</NychAlert>
`,
}),
}
export const WithDescription: Story = {
render: () => ({
components: { NychAlert, NychAlertTitle, NychAlertDescription },
template: `
<NychAlert variant="warning" style="width: 24rem;">
<NychAlertTitle>The ferryman grows impatient.</NychAlertTitle>
<NychAlertDescription>One obol is required to cross the Styx.</NychAlertDescription>
</NychAlert>
`,
}),
}
export const AllVariants: Story = {
render: () => ({
components: { NychAlert, NychAlertTitle },
template: `
<div style="display: flex; flex-direction: column; gap: 0.75rem; width: 24rem;">
<NychAlert variant="info"><NychAlertTitle>The Oracle has spoken.</NychAlertTitle></NychAlert>
<NychAlert variant="success"><NychAlertTitle>The offering was accepted.</NychAlertTitle></NychAlert>
<NychAlert variant="warning"><NychAlertTitle>The ferryman grows impatient.</NychAlertTitle></NychAlert>
<NychAlert variant="danger"><NychAlertTitle>The gods have refused your plea.</NychAlertTitle></NychAlert>
<NychAlert variant="secondary"><NychAlertTitle>A neutral notice from the keeper.</NychAlertTitle></NychAlert>
</div>
`,
}),
}

View file

@ -1,86 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Message as NychMessage } from '@nychthemeron/library'
const meta = {
title: 'Components/Message',
component: NychMessage,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
severity: {
control: { type: 'select' },
options: ['info', 'success', 'warn', 'error', 'secondary'],
description: 'Message severity',
},
closable: {
control: { type: 'boolean' },
description: 'Show a close button',
},
},
} satisfies Meta<typeof NychMessage>
export default meta
type Story = StoryObj<typeof meta>
export const Info: Story = {
args: { severity: 'info' },
render: (args) => ({
components: { NychMessage },
setup: () => ({ args }),
template: `<NychMessage v-bind="args" style="width: 24rem;">The Oracle has spoken.</NychMessage>`,
}),
}
export const Success: Story = {
args: { severity: 'success' },
render: (args) => ({
components: { NychMessage },
setup: () => ({ args }),
template: `<NychMessage v-bind="args" style="width: 24rem;">The offering was accepted.</NychMessage>`,
}),
}
export const Warn: Story = {
args: { severity: 'warn' },
render: (args) => ({
components: { NychMessage },
setup: () => ({ args }),
template: `<NychMessage v-bind="args" style="width: 24rem;">The ferryman grows impatient.</NychMessage>`,
}),
}
export const Error: Story = {
args: { severity: 'error' },
render: (args) => ({
components: { NychMessage },
setup: () => ({ args }),
template: `<NychMessage v-bind="args" style="width: 24rem;">The gods have refused your plea.</NychMessage>`,
}),
}
export const Closable: Story = {
args: { severity: 'info', closable: true },
render: (args) => ({
components: { NychMessage },
setup: () => ({ args }),
template: `<NychMessage v-bind="args" style="width: 24rem;">This omen may be dismissed.</NychMessage>`,
}),
}
export const AllSeverities: Story = {
render: () => ({
components: { NychMessage },
template: `
<div style="display: flex; flex-direction: column; gap: 0.75rem; width: 24rem;">
<NychMessage severity="info">The Oracle has spoken.</NychMessage>
<NychMessage severity="success">The offering was accepted.</NychMessage>
<NychMessage severity="warn">The ferryman grows impatient.</NychMessage>
<NychMessage severity="error">The gods have refused your plea.</NychMessage>
<NychMessage severity="secondary">A neutral notice from the keeper.</NychMessage>
</div>
`,
}),
}