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 { CheckboxGroup } from './ui/checkbox-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,
Select,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
Dialog,
Message,
Badge,

View file

@ -1,7 +1,6 @@
import type { App } from 'vue'
import {
Select as PrimeSelect,
Card as PrimeCard,
Dialog as PrimeDialog,
Message as PrimeMessage,
} from 'primevue'
@ -19,6 +18,13 @@ import {
CheckboxGroup,
RadioGroup,
RadioGroupItem,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
} from '../components'
export {
@ -33,6 +39,13 @@ export {
CheckboxGroup,
RadioGroup,
RadioGroupItem,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
}
/**
@ -89,15 +102,6 @@ const nychthemeron: ThemeLibrary = {
'optionLabel',
'emptyMessage',
]),
Card: ptThemeable(PrimeCard, 'card', [
'header',
'body',
'caption',
'title',
'subtitle',
'content',
'footer',
]),
Dialog: ptThemeable(PrimeDialog, 'dialog', [
'mask',
'header',
@ -121,7 +125,6 @@ const _engine = new ThemeEngine(nychthemeron)
export const LoadingIcon = _engine.getComponent<typeof NychLoadingIcon>('LoadingIcon')
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 Message: typeof PrimeMessage = _engine.getComponent<typeof PrimeMessage>('Message')
@ -136,7 +139,13 @@ export const createNychthemeron = (): {
RadioGroupItem: typeof RadioGroupItem
Switch: typeof Switch
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
Message: typeof PrimeMessage
Badge: typeof Badge
@ -153,6 +162,12 @@ export const createNychthemeron = (): {
Switch,
Select,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
Dialog,
Message,
Badge,
@ -168,6 +183,12 @@ export const createNychthemeron = (): {
app.component('NychSwitch', Switch)
app.component('NychSelect', Select)
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('NychMessage', Message)
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,
LoadingIcon,
Select,
Card,
Dialog,
Message,
} from '../src/index'
@ -64,7 +63,6 @@ describe('LoadingIcon', () => {
describe('themed pt components', () => {
const cases = [
{ name: 'Select', component: Select, base: 'nych-select' },
{ name: 'Card', component: Card, base: 'nych-card' },
{ name: 'Dialog', component: Dialog, base: 'nych-dialog' },
{ name: 'Message', component: Message, base: 'nych-message' },
]

View file

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