feat: migrate Card to shadcn-vue compound API

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Matthew McPeak 2026-07-11 14:41:58 -04:00
parent d999cd95f6
commit fed19667f7
14 changed files with 246 additions and 39 deletions

View file

@ -9,3 +9,4 @@ export { Badge, badgeVariants } from './ui/badge'
export { Checkbox } from './ui/checkbox' 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'

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
size?: "default" | "sm"
}>(), {
size: "default",
})
</script>
<template>
<div
data-slot="card"
:data-size="size"
:class="cn('ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col', 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="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', 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="card-content"
:class="cn('px-4 group-data-[size=sm]/card:px-3', 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="card-description"
:class="cn('text-muted-foreground text-sm', 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="card-footer"
:class="cn('bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center', 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="card-header"
:class="cn('gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]', 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="card-title"
:class="cn('text-base leading-snug font-medium group-data-[size=sm]/card:text-sm cn-font-heading', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,7 @@
export { default as Card } from "./Card.vue"
export { default as CardAction } from "./CardAction.vue"
export { default as CardContent } from "./CardContent.vue"
export { default as CardDescription } from "./CardDescription.vue"
export { default as CardFooter } from "./CardFooter.vue"
export { default as CardHeader } from "./CardHeader.vue"
export { default as CardTitle } from "./CardTitle.vue"

View file

@ -19,6 +19,12 @@ export {
Switch, Switch,
Select, Select,
Card, Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
Dialog, Dialog,
Message, Message,
Badge, Badge,

View file

@ -1,7 +1,6 @@
import type { App } from 'vue' import type { App } from 'vue'
import { import {
Select as PrimeSelect, Select as PrimeSelect,
Card as PrimeCard,
Dialog as PrimeDialog, Dialog as PrimeDialog,
Message as PrimeMessage, Message as PrimeMessage,
} from 'primevue' } from 'primevue'
@ -19,6 +18,13 @@ import {
CheckboxGroup, CheckboxGroup,
RadioGroup, RadioGroup,
RadioGroupItem, RadioGroupItem,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
} from '../components' } from '../components'
export { export {
@ -33,6 +39,13 @@ export {
CheckboxGroup, CheckboxGroup,
RadioGroup, RadioGroup,
RadioGroupItem, RadioGroupItem,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
} }
/** /**
@ -89,15 +102,6 @@ const nychthemeron: ThemeLibrary = {
'optionLabel', 'optionLabel',
'emptyMessage', 'emptyMessage',
]), ]),
Card: ptThemeable(PrimeCard, 'card', [
'header',
'body',
'caption',
'title',
'subtitle',
'content',
'footer',
]),
Dialog: ptThemeable(PrimeDialog, 'dialog', [ Dialog: ptThemeable(PrimeDialog, 'dialog', [
'mask', 'mask',
'header', 'header',
@ -121,7 +125,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 Card: typeof PrimeCard = _engine.getComponent<typeof PrimeCard>('Card')
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 Message: typeof PrimeMessage = _engine.getComponent<typeof PrimeMessage>('Message')
@ -136,7 +139,13 @@ export const createNychthemeron = (): {
RadioGroupItem: typeof RadioGroupItem RadioGroupItem: typeof RadioGroupItem
Switch: typeof Switch Switch: typeof Switch
Select: typeof PrimeSelect Select: typeof PrimeSelect
Card: typeof PrimeCard Card: typeof Card
CardHeader: typeof CardHeader
CardTitle: typeof CardTitle
CardDescription: typeof CardDescription
CardContent: typeof CardContent
CardFooter: typeof CardFooter
CardAction: typeof CardAction
Dialog: typeof PrimeDialog Dialog: typeof PrimeDialog
Message: typeof PrimeMessage Message: typeof PrimeMessage
Badge: typeof Badge Badge: typeof Badge
@ -153,6 +162,12 @@ export const createNychthemeron = (): {
Switch, Switch,
Select, Select,
Card, Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
Dialog, Dialog,
Message, Message,
Badge, Badge,
@ -168,6 +183,12 @@ export const createNychthemeron = (): {
app.component('NychSwitch', Switch) app.component('NychSwitch', Switch)
app.component('NychSelect', Select) app.component('NychSelect', Select)
app.component('NychCard', Card) app.component('NychCard', Card)
app.component('NychCardHeader', CardHeader)
app.component('NychCardTitle', CardTitle)
app.component('NychCardDescription', CardDescription)
app.component('NychCardContent', CardContent)
app.component('NychCardFooter', CardFooter)
app.component('NychCardAction', CardAction)
app.component('NychDialog', Dialog) app.component('NychDialog', Dialog)
app.component('NychMessage', Message) app.component('NychMessage', Message)
app.component('NychBadge', Badge) app.component('NychBadge', Badge)

View file

@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { h } from 'vue'
import { mount } from '@vue/test-utils'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../src/lib'
describe('Card', () => {
it('renders header, title, description, content, and footer together', () => {
const wrapper = mount(Card, {
slots: {
default: () => [
h(CardHeader, undefined, {
default: () => [
h(CardTitle, undefined, { default: () => 'Elysium' }),
h(CardDescription, undefined, { default: () => 'The blessed fields' }),
],
}),
h(CardContent, undefined, { default: () => 'A resting place for the virtuous.' }),
h(CardFooter, undefined, { default: () => 'Enter' }),
],
},
})
expect(wrapper.text()).toContain('Elysium')
expect(wrapper.text()).toContain('The blessed fields')
expect(wrapper.text()).toContain('A resting place for the virtuous.')
expect(wrapper.text()).toContain('Enter')
})
it('exposes a data-slot attribute for styling hooks', () => {
const wrapper = mount(Card)
expect(wrapper.attributes('data-slot')).toBe('card')
})
})

View file

@ -4,7 +4,6 @@ import {
createNychthemeron, createNychthemeron,
LoadingIcon, LoadingIcon,
Select, Select,
Card,
Dialog, Dialog,
Message, Message,
} from '../src/index' } from '../src/index'
@ -64,7 +63,6 @@ describe('LoadingIcon', () => {
describe('themed pt components', () => { describe('themed pt components', () => {
const cases = [ const cases = [
{ name: 'Select', component: Select, base: 'nych-select' }, { name: 'Select', component: Select, base: 'nych-select' },
{ name: 'Card', component: Card, base: 'nych-card' },
{ name: 'Dialog', component: Dialog, base: 'nych-dialog' }, { name: 'Dialog', component: Dialog, base: 'nych-dialog' },
{ name: 'Message', component: Message, base: 'nych-message' }, { name: 'Message', component: Message, base: 'nych-message' },
] ]

View file

@ -1,6 +1,15 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite' import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Card as NychCard, Button as NychButton, Badge as NychBadge } from '@nychthemeron/library' import {
Card as NychCard,
CardHeader as NychCardHeader,
CardTitle as NychCardTitle,
CardDescription as NychCardDescription,
CardContent as NychCardContent,
CardFooter as NychCardFooter,
Button as NychButton,
Badge as NychBadge,
} from '@nychthemeron/library'
const meta = { const meta = {
title: 'Components/Card', title: 'Components/Card',
@ -16,15 +25,17 @@ type Story = StoryObj<typeof meta>
export const Default: Story = { export const Default: Story = {
render: () => ({ render: () => ({
components: { NychCard }, components: { NychCard, NychCardHeader, NychCardTitle, NychCardDescription, NychCardContent },
template: ` template: `
<NychCard style="width: 22rem;"> <NychCard style="width: 22rem;">
<template #title>Elysium</template> <NychCardHeader>
<template #subtitle>The blessed fields</template> <NychCardTitle>Elysium</NychCardTitle>
<template #content> <NychCardDescription>The blessed fields</NychCardDescription>
</NychCardHeader>
<NychCardContent>
A resting place reserved for the heroic and the virtuous, where the A resting place reserved for the heroic and the virtuous, where the
air is mild and the laurel never wilts. air is mild and the laurel never wilts.
</template> </NychCardContent>
</NychCard> </NychCard>
`, `,
}), }),
@ -32,19 +43,29 @@ export const Default: Story = {
export const WithFooter: Story = { export const WithFooter: Story = {
render: () => ({ render: () => ({
components: { NychCard, NychButton }, components: {
NychCard,
NychCardHeader,
NychCardTitle,
NychCardDescription,
NychCardContent,
NychCardFooter,
NychButton,
},
template: ` template: `
<NychCard style="width: 22rem;"> <NychCard style="width: 22rem;">
<template #title>Passage of the Styx</template> <NychCardHeader>
<template #subtitle>One obol required</template> <NychCardTitle>Passage of the Styx</NychCardTitle>
<template #content> <NychCardDescription>One obol required</NychCardDescription>
</NychCardHeader>
<NychCardContent>
Charon ferries the souls of the dead across the river that divides Charon ferries the souls of the dead across the river that divides
the world of the living from the world of the dead. the world of the living from the world of the dead.
</template> </NychCardContent>
<template #footer> <NychCardFooter>
<NychButton label="Pay the toll" /> <NychButton>Pay the toll</NychButton>
<NychButton label="Turn back" severity="secondary" /> <NychButton variant="secondary">Turn back</NychButton>
</template> </NychCardFooter>
</NychCard> </NychCard>
`, `,
}), }),
@ -52,21 +73,19 @@ export const WithFooter: Story = {
export const WithHeaderAndTag: Story = { export const WithHeaderAndTag: Story = {
render: () => ({ render: () => ({
components: { NychCard, NychBadge }, components: { NychCard, NychCardHeader, NychCardTitle, NychCardContent, NychBadge },
template: ` template: `
<NychCard style="width: 22rem;"> <NychCard style="width: 22rem;">
<template #header> <div style="height: 120px; background: linear-gradient(135deg, var(--surface-2), var(--surface-3));"></div>
<div style="height: 120px; background: linear-gradient(135deg, var(--surface-2), var(--surface-3));"></div> <NychCardHeader>
</template> <NychCardTitle style="display: flex; align-items: center; gap: 0.5rem;">
<template #title>
<span style="display: flex; align-items: center; gap: 0.5rem;">
Oracle of Delphi <NychBadge variant="info">Sacred</NychBadge> Oracle of Delphi <NychBadge variant="info">Sacred</NychBadge>
</span> </NychCardTitle>
</template> </NychCardHeader>
<template #content> <NychCardContent>
Seekers travelled from across the Aegean to hear the Pythia speak the Seekers travelled from across the Aegean to hear the Pythia speak the
will of Apollo in riddling verse. will of Apollo in riddling verse.
</template> </NychCardContent>
</NychCard> </NychCard>
`, `,
}), }),