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 tailwind.css
Toggle themes by setting data-theme on <html>: Toggle themes by setting data-theme on <html>:

View file

@ -1,2 +1,4 @@
// Re-export all components here as they're added // Re-export all components here as they're added
export { default as NychLoadingIcon } from './NychLoadingIcon.vue' 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 type { App } from 'vue'
import { import {
Button as PrimeButton,
InputText as PrimeInputText, InputText as PrimeInputText,
Textarea as PrimeTextarea, Textarea as PrimeTextarea,
Checkbox as PrimeCheckbox, Checkbox as PrimeCheckbox,
@ -15,7 +14,9 @@ import {
Tag as PrimeTag, Tag as PrimeTag,
} 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 { 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. * 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'], }) 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> = { const loadingIconThemeable: Themeable<typeof NychLoadingIcon> = {
component: NychLoadingIcon, component: NychLoadingIcon,
unstyled: false, unstyled: false,
@ -88,7 +59,6 @@ const loadingIconThemeable: Themeable<typeof NychLoadingIcon> = {
const nychthemeron: ThemeLibrary = { const nychthemeron: ThemeLibrary = {
components: { components: {
Button: buttonThemeable,
LoadingIcon: loadingIconThemeable, LoadingIcon: loadingIconThemeable,
InputText: ptThemeable(PrimeInputText, 'input'), InputText: ptThemeable(PrimeInputText, 'input'),
Textarea: ptThemeable(PrimeTextarea, 'textarea'), Textarea: ptThemeable(PrimeTextarea, 'textarea'),
@ -140,7 +110,6 @@ const nychthemeron: ThemeLibrary = {
const _engine = new ThemeEngine(nychthemeron) 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 LoadingIcon = _engine.getComponent<typeof NychLoadingIcon>('LoadingIcon')
export const InputText: typeof PrimeInputText = export const InputText: typeof PrimeInputText =
_engine.getComponent<typeof PrimeInputText>('InputText') _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 Tag: typeof PrimeTag = _engine.getComponent<typeof PrimeTag>('Tag')
export const createNychthemeron = (): { export const createNychthemeron = (): {
Button: typeof PrimeButton Button: typeof Button
LoadingIcon: typeof NychLoadingIcon LoadingIcon: typeof NychLoadingIcon
InputText: typeof PrimeInputText InputText: typeof PrimeInputText
Textarea: typeof PrimeTextarea 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 type { App } from 'vue'
import { import {
createNychthemeron, createNychthemeron,
Button,
LoadingIcon, LoadingIcon,
InputText, InputText,
Textarea, 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', () => { describe('LoadingIcon', () => {
it('is a valid Vue component', () => { it('is a valid Vue component', () => {
expect(LoadingIcon).toBeDefined() expect(LoadingIcon).toBeDefined()

View file

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