feat: migrate Button from PrimeVue to shadcn-vue
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
106c214b16
commit
3a8dd109bb
8 changed files with 153 additions and 145 deletions
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
/* ============================================================
|
||||
tailwind.css
|
||||
Toggle themes by setting data-theme on <html>:
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
40
packages/library/src/components/ui/button/Button.vue
Normal file
40
packages/library/src/components/ui/button/Button.vue
Normal 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>
|
||||
31
packages/library/src/components/ui/button/index.ts
Normal file
31
packages/library/src/components/ui/button/index.ts
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
30
packages/library/tests/Button.spec.ts
Normal file
30
packages/library/tests/Button.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue