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
|
tailwind.css
|
||||||
Toggle themes by setting data-theme on <html>:
|
Toggle themes by setting data-theme on <html>:
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
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 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
|
||||||
|
|
|
||||||
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 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()
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
`,
|
`,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue