feat: migrate Button from PrimeVue to shadcn-vue

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Matthew McPeak 2026-07-11 14:19:39 -04:00
parent 106c214b16
commit 3a8dd109bb
8 changed files with 153 additions and 145 deletions

View file

@ -1,3 +1,4 @@
/* ============================================================
tailwind.css
Toggle themes by setting data-theme on <html>:

View file

@ -1,2 +1,4 @@
// Re-export all components here as they're added
export { default as NychLoadingIcon } from './NychLoadingIcon.vue'
export { Button, buttonVariants } from './ui/button'
export type { ButtonVariants } from './ui/button'

View file

@ -0,0 +1,40 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { computed } from 'vue'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import NychLoadingIcon from '@/components/NychLoadingIcon.vue'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
loading?: boolean
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
variant: 'primary',
})
const isDisabled = computed(() => props.disabled || props.loading)
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:disabled="isDisabled"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<NychLoadingIcon v-if="loading" class="size-4" />
<slot />
</Primitive>
</template>

View file

@ -0,0 +1,31 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border border-transparent font-medium transition-all outline-none disabled:pointer-events-none disabled:opacity-50 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4',
{
variants: {
variant: {
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
info: 'bg-info text-info-foreground hover:bg-info/90',
success: 'bg-success text-success-foreground hover:bg-success/90',
warning: 'bg-warning text-warning-foreground hover:bg-warning/90',
danger: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
},
size: {
default: 'h-8 px-3 text-sm',
sm: 'h-7 px-2.5 text-xs',
lg: 'h-9 px-4 text-base',
icon: 'size-8',
},
},
defaultVariants: {
variant: 'primary',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>

View file

@ -1,6 +1,5 @@
import type { App } from 'vue'
import {
Button as PrimeButton,
InputText as PrimeInputText,
Textarea as PrimeTextarea,
Checkbox as PrimeCheckbox,
@ -15,7 +14,9 @@ import {
Tag as PrimeTag,
} from 'primevue'
import { ThemeLibrary, type Themeable, ThemeEngine, type LibComponent, type Key } from '../engine'
import { NychLoadingIcon } from '../components'
import { NychLoadingIcon, Button, buttonVariants } from '../components'
export { Button, buttonVariants }
/**
* Build a Themeable that themes a PrimeVue component through its passthrough (`pt`) API.
@ -51,36 +52,6 @@ const ptThemeable = <T extends LibComponent>(
}) as Themeable<T>['propMutator'],
})
const buttonThemeable: Themeable<typeof PrimeButton> = {
component: PrimeButton,
unstyled: true,
propMutator: (props) => {
const severity = (props.severity as string) ?? 'primary'
delete props.severity
if (props.loading) props.disabled = true
const pt = props.pt as { root?: { class?: string } }
const classes = pt?.root?.class?.split(' ') ?? []
classes.push(`nych-button-${severity}`)
if (!pt.root) pt.root = {}
pt.root.class = classes.join(' ')
props.pt = pt
return props
},
slots: {
loadingicon: NychLoadingIcon,
},
injectionKeys: {
class: 'pt',
},
}
const loadingIconThemeable: Themeable<typeof NychLoadingIcon> = {
component: NychLoadingIcon,
unstyled: false,
@ -88,7 +59,6 @@ const loadingIconThemeable: Themeable<typeof NychLoadingIcon> = {
const nychthemeron: ThemeLibrary = {
components: {
Button: buttonThemeable,
LoadingIcon: loadingIconThemeable,
InputText: ptThemeable(PrimeInputText, 'input'),
Textarea: ptThemeable(PrimeTextarea, 'textarea'),
@ -140,7 +110,6 @@ const nychthemeron: ThemeLibrary = {
const _engine = new ThemeEngine(nychthemeron)
export const Button: typeof PrimeButton = _engine.getComponent<typeof PrimeButton>('Button')
export const LoadingIcon = _engine.getComponent<typeof NychLoadingIcon>('LoadingIcon')
export const InputText: typeof PrimeInputText =
_engine.getComponent<typeof PrimeInputText>('InputText')
@ -161,7 +130,7 @@ export const Message: typeof PrimeMessage = _engine.getComponent<typeof PrimeMes
export const Tag: typeof PrimeTag = _engine.getComponent<typeof PrimeTag>('Tag')
export const createNychthemeron = (): {
Button: typeof PrimeButton
Button: typeof Button
LoadingIcon: typeof NychLoadingIcon
InputText: typeof PrimeInputText
Textarea: typeof PrimeTextarea

View file

@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { Button } from '../src/lib'
describe('Button', () => {
it('defaults to the primary variant', () => {
const wrapper = mount(Button, { slots: { default: 'Click' } })
expect(wrapper.classes().join(' ')).toContain('bg-primary')
})
it('applies the danger variant class', () => {
const wrapper = mount(Button, { props: { variant: 'danger' } })
expect(wrapper.classes().join(' ')).toContain('bg-destructive')
})
it('auto-disables when loading is true', () => {
const wrapper = mount(Button, { props: { loading: true } })
expect(wrapper.attributes('disabled')).toBeDefined()
})
it('renders the loading icon when loading is true', () => {
const wrapper = mount(Button, { props: { loading: true } })
expect(wrapper.find('svg.nych-loading-icon').exists()).toBe(true)
})
it('stays enabled when loading is false', () => {
const wrapper = mount(Button)
expect(wrapper.attributes('disabled')).toBeUndefined()
})
})

View file

@ -2,7 +2,6 @@ import { describe, it, expect, vi } from 'vitest'
import type { App } from 'vue'
import {
createNychthemeron,
Button,
LoadingIcon,
InputText,
Textarea,
@ -64,45 +63,6 @@ describe('createNychthemeron', () => {
})
})
describe('Button', () => {
it('is a valid Vue component', () => {
expect(Button).toBeDefined()
expect(typeof (Button as Record<string, unknown>).setup).toBe('function')
})
it('auto-disables when loading is true', () => {
const result = callSetup(Button, { pt: {}, loading: true })
expect(result.disabled).toBe(true)
})
it('stays enabled when loading is false', () => {
const result = callSetup(Button, { pt: {}, loading: false })
expect(result.disabled).toBeFalsy()
})
it('injects severity class into pt.root', () => {
const result = callSetup(Button, { pt: {}, severity: 'danger' })
expect(result.pt?.root?.class).toContain('nych-button-danger')
})
it('defaults severity to primary', () => {
const result = callSetup(Button, { pt: {} })
expect(result.pt?.root?.class).toContain('nych-button-primary')
})
it('does not leak engine internal props', () => {
const result = callSetup(Button, { pt: {} })
expect(result).not.toHaveProperty('injectedClasses')
expect(result).not.toHaveProperty('injectedStyles')
expect(result).not.toHaveProperty('stylePlug')
})
it('does not leak severity to inner component', () => {
const result = callSetup(Button, { pt: {}, severity: 'danger' })
expect(result).not.toHaveProperty('severity')
})
})
describe('LoadingIcon', () => {
it('is a valid Vue component', () => {
expect(LoadingIcon).toBeDefined()

View file

@ -11,14 +11,14 @@ const meta = {
},
tags: ['autodocs'],
argTypes: {
severity: {
variant: {
control: { type: 'select' },
options: ['primary', 'secondary', 'info', 'success', 'warning', 'danger'],
description: 'Button color severity',
description: 'Button color variant',
},
size: {
control: { type: 'select' },
options: ['small', 'normal', 'large'],
options: ['default', 'sm', 'lg', 'icon'],
description: 'Button size',
},
disabled: {
@ -29,10 +29,6 @@ const meta = {
control: { type: 'boolean' },
description: 'Show loading state',
},
label: {
control: { type: 'text' },
description: 'Button label text',
},
},
} satisfies Meta<typeof NychButton>
@ -40,77 +36,61 @@ export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {
label: 'Primary Button',
severity: 'primary',
},
args: { variant: 'primary' },
render: (args) => ({
components: { NychButton },
setup: () => ({ args }),
template: `<NychButton v-bind="args">Summon the Oracle</NychButton>`,
}),
}
export const Secondary: Story = {
args: {
label: 'Secondary Button',
severity: 'secondary',
},
}
export const Info: Story = {
args: {
label: 'Info Button',
severity: 'info',
},
}
export const Success: Story = {
args: {
label: 'Success Button',
severity: 'success',
},
}
export const Warning: Story = {
args: {
label: 'Warning Button',
severity: 'warning',
},
}
export const Danger: Story = {
args: {
label: 'Danger Button',
severity: 'danger',
},
}
export const Disabled: Story = {
args: {
label: 'Disabled Button',
disabled: true,
},
}
export const Loading: Story = {
args: {
label: 'Loading Button',
loading: true,
},
export const AllVariants: Story = {
render: () => ({
components: { NychButton },
template: `
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
<NychButton variant="primary">Primary</NychButton>
<NychButton variant="secondary">Secondary</NychButton>
<NychButton variant="info">Info</NychButton>
<NychButton variant="success">Success</NychButton>
<NychButton variant="warning">Warning</NychButton>
<NychButton variant="danger">Danger</NychButton>
</div>
`,
}),
}
export const Sizes: Story = {
render: () => ({
components: { NychButton },
setup() {
return {}
},
template: `
<div style="display: flex; gap: 1rem; align-items: center;">
<NychButton label="Small" size="small" />
<NychButton label="Normal" size="normal" />
<NychButton label="Large" size="large" />
<NychButton size="sm">Small</NychButton>
<NychButton size="default">Default</NychButton>
<NychButton size="lg">Large</NychButton>
</div>
`,
}),
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { NychButton },
setup: () => ({ args }),
template: `<NychButton v-bind="args">Disabled</NychButton>`,
}),
}
export const Loading: Story = {
args: { loading: true },
render: (args) => ({
components: { NychButton },
setup: () => ({ args }),
template: `<NychButton v-bind="args">Ask</NychButton>`,
}),
}
export const Interactive: Story = {
render: () => ({
components: { NychButton },
@ -130,12 +110,7 @@ export const Interactive: Story = {
},
template: `
<div style="display: flex; flex-direction: column; align-items: center; gap: 1rem; padding: 2rem;">
<NychButton
label="Click Me"
@click="handleClick"
:loading="isLoading"
:disabled="isLoading"
/>
<NychButton @click="handleClick" :loading="isLoading">Click Me</NychButton>
<p style="font-size: 1.2rem; font-weight: bold;">Clicks: {{ clickCount }}</p>
</div>
`,