From 3a8dd109bbd2dc0c652c805b0ab0f5f37e17be90 Mon Sep 17 00:00:00 2001 From: Matthew L McPeak Date: Sat, 11 Jul 2026 14:19:39 -0400 Subject: [PATCH] feat: migrate Button from PrimeVue to shadcn-vue Co-Authored-By: Claude Sonnet 5 --- packages/library/src/assets/css/tailwind.css | 1 + packages/library/src/components/index.ts | 2 + .../src/components/ui/button/Button.vue | 40 ++++++ .../library/src/components/ui/button/index.ts | 31 +++++ packages/library/src/lib/index.ts | 39 +----- packages/library/tests/Button.spec.ts | 30 +++++ packages/library/tests/lib.spec.ts | 40 ------ packages/playground/stories/Button.stories.ts | 115 +++++++----------- 8 files changed, 153 insertions(+), 145 deletions(-) create mode 100644 packages/library/src/components/ui/button/Button.vue create mode 100644 packages/library/src/components/ui/button/index.ts create mode 100644 packages/library/tests/Button.spec.ts diff --git a/packages/library/src/assets/css/tailwind.css b/packages/library/src/assets/css/tailwind.css index 07edf85..d1b5d94 100644 --- a/packages/library/src/assets/css/tailwind.css +++ b/packages/library/src/assets/css/tailwind.css @@ -1,3 +1,4 @@ + /* ============================================================ tailwind.css Toggle themes by setting data-theme on : diff --git a/packages/library/src/components/index.ts b/packages/library/src/components/index.ts index 3d3722e..d380276 100644 --- a/packages/library/src/components/index.ts +++ b/packages/library/src/components/index.ts @@ -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' diff --git a/packages/library/src/components/ui/button/Button.vue b/packages/library/src/components/ui/button/Button.vue new file mode 100644 index 0000000..3624b7b --- /dev/null +++ b/packages/library/src/components/ui/button/Button.vue @@ -0,0 +1,40 @@ + + + diff --git a/packages/library/src/components/ui/button/index.ts b/packages/library/src/components/ui/button/index.ts new file mode 100644 index 0000000..974f88d --- /dev/null +++ b/packages/library/src/components/ui/button/index.ts @@ -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 diff --git a/packages/library/src/lib/index.ts b/packages/library/src/lib/index.ts index 4bc2e52..58cb645 100644 --- a/packages/library/src/lib/index.ts +++ b/packages/library/src/lib/index.ts @@ -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 = ( }) as Themeable['propMutator'], }) -const buttonThemeable: Themeable = { - 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 = { component: NychLoadingIcon, unstyled: false, @@ -88,7 +59,6 @@ const loadingIconThemeable: Themeable = { 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('Button') export const LoadingIcon = _engine.getComponent('LoadingIcon') export const InputText: typeof PrimeInputText = _engine.getComponent('InputText') @@ -161,7 +130,7 @@ export const Message: typeof PrimeMessage = _engine.getComponent('Tag') export const createNychthemeron = (): { - Button: typeof PrimeButton + Button: typeof Button LoadingIcon: typeof NychLoadingIcon InputText: typeof PrimeInputText Textarea: typeof PrimeTextarea diff --git a/packages/library/tests/Button.spec.ts b/packages/library/tests/Button.spec.ts new file mode 100644 index 0000000..3f6fcdd --- /dev/null +++ b/packages/library/tests/Button.spec.ts @@ -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() + }) +}) diff --git a/packages/library/tests/lib.spec.ts b/packages/library/tests/lib.spec.ts index aef3b44..10dd589 100644 --- a/packages/library/tests/lib.spec.ts +++ b/packages/library/tests/lib.spec.ts @@ -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).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() diff --git a/packages/playground/stories/Button.stories.ts b/packages/playground/stories/Button.stories.ts index e7b2c54..7e58516 100644 --- a/packages/playground/stories/Button.stories.ts +++ b/packages/playground/stories/Button.stories.ts @@ -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 @@ -40,77 +36,61 @@ export default meta type Story = StoryObj export const Primary: Story = { - args: { - label: 'Primary Button', - severity: 'primary', - }, + args: { variant: 'primary' }, + render: (args) => ({ + components: { NychButton }, + setup: () => ({ args }), + template: `Summon the Oracle`, + }), } -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: ` +
+ Primary + Secondary + Info + Success + Warning + Danger +
+ `, + }), } export const Sizes: Story = { render: () => ({ components: { NychButton }, - setup() { - return {} - }, template: `
- - - + Small + Default + Large
`, }), } +export const Disabled: Story = { + args: { disabled: true }, + render: (args) => ({ + components: { NychButton }, + setup: () => ({ args }), + template: `Disabled`, + }), +} + +export const Loading: Story = { + args: { loading: true }, + render: (args) => ({ + components: { NychButton }, + setup: () => ({ args }), + template: `Ask`, + }), +} + export const Interactive: Story = { render: () => ({ components: { NychButton }, @@ -130,12 +110,7 @@ export const Interactive: Story = { }, template: `
- + Click Me

Clicks: {{ clickCount }}

`,