feat: migrate Card to shadcn-vue compound API
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
d999cd95f6
commit
fed19667f7
14 changed files with 246 additions and 39 deletions
|
|
@ -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'
|
||||||
|
|
|
||||||
21
packages/library/src/components/ui/card/Card.vue
Normal file
21
packages/library/src/components/ui/card/Card.vue
Normal 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>
|
||||||
17
packages/library/src/components/ui/card/CardAction.vue
Normal file
17
packages/library/src/components/ui/card/CardAction.vue
Normal 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>
|
||||||
17
packages/library/src/components/ui/card/CardContent.vue
Normal file
17
packages/library/src/components/ui/card/CardContent.vue
Normal 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>
|
||||||
17
packages/library/src/components/ui/card/CardDescription.vue
Normal file
17
packages/library/src/components/ui/card/CardDescription.vue
Normal 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>
|
||||||
17
packages/library/src/components/ui/card/CardFooter.vue
Normal file
17
packages/library/src/components/ui/card/CardFooter.vue
Normal 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>
|
||||||
17
packages/library/src/components/ui/card/CardHeader.vue
Normal file
17
packages/library/src/components/ui/card/CardHeader.vue
Normal 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>
|
||||||
17
packages/library/src/components/ui/card/CardTitle.vue
Normal file
17
packages/library/src/components/ui/card/CardTitle.vue
Normal 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>
|
||||||
7
packages/library/src/components/ui/card/index.ts
Normal file
7
packages/library/src/components/ui/card/index.ts
Normal 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"
|
||||||
|
|
@ -19,6 +19,12 @@ export {
|
||||||
Switch,
|
Switch,
|
||||||
Select,
|
Select,
|
||||||
Card,
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardAction,
|
||||||
Dialog,
|
Dialog,
|
||||||
Message,
|
Message,
|
||||||
Badge,
|
Badge,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
32
packages/library/tests/Card.spec.ts
Normal file
32
packages/library/tests/Card.spec.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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' },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
`,
|
`,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue