feat: migrate Checkbox and CheckboxGroup to shadcn-vue
CheckboxGroup is hand-built (no shadcn-vue stock equivalent) as a simple name/layout provider via provide/inject, matching RadioGroup's pattern -- it does not coordinate an array modelValue across children the way PrimeVue's did, since each Checkbox now owns its own boolean v-model. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
5238d61f24
commit
2eb438932b
10 changed files with 114 additions and 49 deletions
|
|
@ -6,3 +6,5 @@ export { Input } from './ui/input'
|
||||||
export { Textarea } from './ui/textarea'
|
export { Textarea } from './ui/textarea'
|
||||||
export { Switch } from './ui/switch'
|
export { Switch } from './ui/switch'
|
||||||
export { Badge, badgeVariants } from './ui/badge'
|
export { Badge, badgeVariants } from './ui/badge'
|
||||||
|
export { Checkbox } from './ui/checkbox'
|
||||||
|
export { CheckboxGroup } from './ui/checkbox-group'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { provide } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
name?: string
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
provide('nych-checkbox-group-name', props.name)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div data-slot="checkbox-group" role="group" :class="cn('grid gap-2 w-full', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default as CheckboxGroup } from './CheckboxGroup.vue'
|
||||||
|
export const CHECKBOX_GROUP_NAME_KEY = 'nych-checkbox-group-name'
|
||||||
34
packages/library/src/components/ui/checkbox/Checkbox.vue
Normal file
34
packages/library/src/components/ui/checkbox/Checkbox.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CheckIcon } from '@lucide/vue';
|
||||||
|
|
||||||
|
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<CheckboxRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CheckboxRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="checkbox"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
||||||
|
>
|
||||||
|
<CheckboxIndicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<CheckIcon />
|
||||||
|
</slot>
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
|
</template>
|
||||||
1
packages/library/src/components/ui/checkbox/index.ts
Normal file
1
packages/library/src/components/ui/checkbox/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default as Checkbox } from "./Checkbox.vue"
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import type { App } from 'vue'
|
import type { App } from 'vue'
|
||||||
import {
|
import {
|
||||||
Checkbox as PrimeCheckbox,
|
|
||||||
CheckboxGroup as PrimeCheckboxGroup,
|
|
||||||
RadioButton as PrimeRadioButton,
|
RadioButton as PrimeRadioButton,
|
||||||
RadioButtonGroup as PrimeRadioButtonGroup,
|
RadioButtonGroup as PrimeRadioButtonGroup,
|
||||||
Select as PrimeSelect,
|
Select as PrimeSelect,
|
||||||
|
|
@ -19,9 +17,11 @@ import {
|
||||||
Switch,
|
Switch,
|
||||||
Badge,
|
Badge,
|
||||||
badgeVariants,
|
badgeVariants,
|
||||||
|
Checkbox,
|
||||||
|
CheckboxGroup,
|
||||||
} from '../components'
|
} from '../components'
|
||||||
|
|
||||||
export { Button, buttonVariants, Input, Textarea, Switch, Badge, badgeVariants }
|
export { Button, buttonVariants, Input, Textarea, Switch, Badge, badgeVariants, Checkbox, CheckboxGroup }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
|
|
@ -65,8 +65,6 @@ const loadingIconThemeable: Themeable<typeof NychLoadingIcon> = {
|
||||||
const nychthemeron: ThemeLibrary = {
|
const nychthemeron: ThemeLibrary = {
|
||||||
components: {
|
components: {
|
||||||
LoadingIcon: loadingIconThemeable,
|
LoadingIcon: loadingIconThemeable,
|
||||||
Checkbox: ptThemeable(PrimeCheckbox, 'checkbox', ['box', 'input', 'icon']),
|
|
||||||
CheckboxGroup: ptThemeable(PrimeCheckboxGroup, 'checkbox-group'),
|
|
||||||
RadioButton: ptThemeable(PrimeRadioButton, 'radio', ['box', 'input', 'icon']),
|
RadioButton: ptThemeable(PrimeRadioButton, 'radio', ['box', 'input', 'icon']),
|
||||||
RadioButtonGroup: ptThemeable(PrimeRadioButtonGroup, 'radio-group'),
|
RadioButtonGroup: ptThemeable(PrimeRadioButtonGroup, 'radio-group'),
|
||||||
Select: ptThemeable(PrimeSelect, 'select', [
|
Select: ptThemeable(PrimeSelect, 'select', [
|
||||||
|
|
@ -112,9 +110,6 @@ const nychthemeron: ThemeLibrary = {
|
||||||
const _engine = new ThemeEngine(nychthemeron)
|
const _engine = new ThemeEngine(nychthemeron)
|
||||||
|
|
||||||
export const LoadingIcon = _engine.getComponent<typeof NychLoadingIcon>('LoadingIcon')
|
export const LoadingIcon = _engine.getComponent<typeof NychLoadingIcon>('LoadingIcon')
|
||||||
export const Checkbox: typeof PrimeCheckbox = _engine.getComponent<typeof PrimeCheckbox>('Checkbox')
|
|
||||||
export const CheckboxGroup: typeof PrimeCheckboxGroup =
|
|
||||||
_engine.getComponent<typeof PrimeCheckboxGroup>('CheckboxGroup')
|
|
||||||
export const RadioButton: typeof PrimeRadioButton =
|
export const RadioButton: typeof PrimeRadioButton =
|
||||||
_engine.getComponent<typeof PrimeRadioButton>('RadioButton')
|
_engine.getComponent<typeof PrimeRadioButton>('RadioButton')
|
||||||
export const RadioButtonGroup: typeof PrimeRadioButtonGroup =
|
export const RadioButtonGroup: typeof PrimeRadioButtonGroup =
|
||||||
|
|
@ -129,8 +124,8 @@ export const createNychthemeron = (): {
|
||||||
LoadingIcon: typeof NychLoadingIcon
|
LoadingIcon: typeof NychLoadingIcon
|
||||||
Input: typeof Input
|
Input: typeof Input
|
||||||
Textarea: typeof Textarea
|
Textarea: typeof Textarea
|
||||||
Checkbox: typeof PrimeCheckbox
|
Checkbox: typeof Checkbox
|
||||||
CheckboxGroup: typeof PrimeCheckboxGroup
|
CheckboxGroup: typeof CheckboxGroup
|
||||||
RadioButton: typeof PrimeRadioButton
|
RadioButton: typeof PrimeRadioButton
|
||||||
RadioButtonGroup: typeof PrimeRadioButtonGroup
|
RadioButtonGroup: typeof PrimeRadioButtonGroup
|
||||||
Switch: typeof Switch
|
Switch: typeof Switch
|
||||||
|
|
|
||||||
21
packages/library/tests/Checkbox.spec.ts
Normal file
21
packages/library/tests/Checkbox.spec.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { Checkbox } from '../src/lib'
|
||||||
|
|
||||||
|
describe('Checkbox', () => {
|
||||||
|
it('renders unchecked by default', () => {
|
||||||
|
const wrapper = mount(Checkbox)
|
||||||
|
expect(wrapper.get('button').attributes('data-state')).toBe('unchecked')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reflects modelValue as checked', () => {
|
||||||
|
const wrapper = mount(Checkbox, { props: { modelValue: true } })
|
||||||
|
expect(wrapper.get('button').attributes('data-state')).toBe('checked')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue on click', async () => {
|
||||||
|
const wrapper = mount(Checkbox, { props: { modelValue: false } })
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
|
||||||
|
})
|
||||||
|
})
|
||||||
11
packages/library/tests/CheckboxGroup.spec.ts
Normal file
11
packages/library/tests/CheckboxGroup.spec.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { CheckboxGroup } from '../src/lib'
|
||||||
|
|
||||||
|
describe('CheckboxGroup', () => {
|
||||||
|
it('renders as a group container', () => {
|
||||||
|
const wrapper = mount(CheckboxGroup, { slots: { default: '<span>child</span>' } })
|
||||||
|
expect(wrapper.attributes('role')).toBe('group')
|
||||||
|
expect(wrapper.html()).toContain('<span>child</span>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -3,9 +3,7 @@ import type { App } from 'vue'
|
||||||
import {
|
import {
|
||||||
createNychthemeron,
|
createNychthemeron,
|
||||||
LoadingIcon,
|
LoadingIcon,
|
||||||
Checkbox,
|
|
||||||
RadioButton,
|
RadioButton,
|
||||||
CheckboxGroup,
|
|
||||||
RadioButtonGroup,
|
RadioButtonGroup,
|
||||||
Select,
|
Select,
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -67,8 +65,6 @@ describe('LoadingIcon', () => {
|
||||||
|
|
||||||
describe('themed pt components', () => {
|
describe('themed pt components', () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
{ name: 'Checkbox', component: Checkbox, base: 'nych-checkbox' },
|
|
||||||
{ name: 'CheckboxGroup', component: CheckboxGroup, base: 'nych-checkbox-group' },
|
|
||||||
{ name: 'RadioButton', component: RadioButton, base: 'nych-radio' },
|
{ name: 'RadioButton', component: RadioButton, base: 'nych-radio' },
|
||||||
{ name: 'RadioButtonGroup', component: RadioButtonGroup, base: 'nych-radio-group' },
|
{ name: 'RadioButtonGroup', component: RadioButtonGroup, base: 'nych-radio-group' },
|
||||||
{ name: 'Select', component: Select, base: 'nych-select' },
|
{ name: 'Select', component: Select, base: 'nych-select' },
|
||||||
|
|
@ -94,13 +90,6 @@ describe('themed pt components', () => {
|
||||||
expect(result).not.toHaveProperty('stylePlug')
|
expect(result).not.toHaveProperty('stylePlug')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Checkbox themes its box, input and icon sections', () => {
|
|
||||||
const result = callSetup(Checkbox, { pt: {} })
|
|
||||||
expect(result.pt?.box?.class).toContain('nych-checkbox-box')
|
|
||||||
expect(result.pt?.input?.class).toContain('nych-checkbox-input')
|
|
||||||
expect(result.pt?.icon?.class).toContain('nych-checkbox-icon')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Select themes its overlay and option sections', () => {
|
it('Select themes its overlay and option sections', () => {
|
||||||
const result = callSetup(Select, { pt: {} })
|
const result = callSetup(Select, { pt: {} })
|
||||||
expect(result.pt?.overlay?.class).toContain('nych-select-overlay')
|
expect(result.pt?.overlay?.class).toContain('nych-select-overlay')
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,6 @@ const meta = {
|
||||||
layout: 'centered',
|
layout: 'centered',
|
||||||
},
|
},
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
argTypes: {
|
|
||||||
disabled: {
|
|
||||||
control: { type: 'boolean' },
|
|
||||||
description: 'Disable the checkbox',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies Meta<typeof NychCheckbox>
|
} satisfies Meta<typeof NychCheckbox>
|
||||||
|
|
||||||
export default meta
|
export default meta
|
||||||
|
|
@ -28,7 +22,7 @@ export const Default: Story = {
|
||||||
const checked = ref(false)
|
const checked = ref(false)
|
||||||
return { checked }
|
return { checked }
|
||||||
},
|
},
|
||||||
template: `<NychCheckbox v-model="checked" binary />`,
|
template: `<NychCheckbox v-model="checked" />`,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,21 +33,17 @@ export const Checked: Story = {
|
||||||
const checked = ref(true)
|
const checked = ref(true)
|
||||||
return { checked }
|
return { checked }
|
||||||
},
|
},
|
||||||
template: `<NychCheckbox v-model="checked" binary />`,
|
template: `<NychCheckbox v-model="checked" />`,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Disabled: Story = {
|
export const Disabled: Story = {
|
||||||
render: () => ({
|
render: () => ({
|
||||||
components: { NychCheckbox },
|
components: { NychCheckbox },
|
||||||
setup() {
|
|
||||||
const checked = ref(true)
|
|
||||||
return { checked }
|
|
||||||
},
|
|
||||||
template: `
|
template: `
|
||||||
<div style="display: flex; gap: 1rem;">
|
<div style="display: flex; gap: 1rem;">
|
||||||
<NychCheckbox :modelValue="false" binary disabled />
|
<NychCheckbox :model-value="false" disabled />
|
||||||
<NychCheckbox :modelValue="true" binary disabled />
|
<NychCheckbox :model-value="true" disabled />
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
}),
|
}),
|
||||||
|
|
@ -63,48 +53,50 @@ export const WithLabels: Story = {
|
||||||
render: () => ({
|
render: () => ({
|
||||||
components: { NychCheckbox },
|
components: { NychCheckbox },
|
||||||
setup() {
|
setup() {
|
||||||
const offerings = ref(['honey'])
|
const honey = ref(true)
|
||||||
return { offerings }
|
const wine = ref(false)
|
||||||
|
const laurel = ref(false)
|
||||||
|
return { honey, wine, laurel }
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
<div style="display: flex; flex-direction: column; gap: 0.75rem; font-family: var(--font-sans); color: var(--text-body);">
|
<div style="display: flex; flex-direction: column; gap: 0.75rem; font-family: var(--font-sans); color: var(--text-body);">
|
||||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||||
<NychCheckbox v-model="offerings" value="honey" /> Honey
|
<NychCheckbox v-model="honey" /> Honey
|
||||||
</label>
|
</label>
|
||||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||||
<NychCheckbox v-model="offerings" value="wine" /> Wine
|
<NychCheckbox v-model="wine" /> Wine
|
||||||
</label>
|
</label>
|
||||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||||
<NychCheckbox v-model="offerings" value="laurel" /> Laurel
|
<NychCheckbox v-model="laurel" /> Laurel
|
||||||
</label>
|
</label>
|
||||||
<p style="color: var(--text-muted); margin-top: 0.25rem;">Offerings: {{ offerings.join(', ') || 'none' }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckboxGroup holds the array v-model and shares a name across children.
|
// CheckboxGroup shares layout and a name across children; each checkbox still owns its own v-model.
|
||||||
export const Group: Story = {
|
export const Group: Story = {
|
||||||
render: () => ({
|
render: () => ({
|
||||||
components: { NychCheckbox, NychCheckboxGroup },
|
components: { NychCheckbox, NychCheckboxGroup },
|
||||||
setup() {
|
setup() {
|
||||||
const offerings = ref(['honey'])
|
const honey = ref(true)
|
||||||
return { offerings }
|
const wine = ref(false)
|
||||||
|
const laurel = ref(false)
|
||||||
|
return { honey, wine, laurel }
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
<div style="font-family: var(--font-sans); color: var(--text-body);">
|
<div style="font-family: var(--font-sans); color: var(--text-body);">
|
||||||
<NychCheckboxGroup v-model="offerings" name="offerings">
|
<NychCheckboxGroup name="offerings">
|
||||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||||
<NychCheckbox value="honey" /> Honey
|
<NychCheckbox v-model="honey" /> Honey
|
||||||
</label>
|
</label>
|
||||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||||
<NychCheckbox value="wine" /> Wine
|
<NychCheckbox v-model="wine" /> Wine
|
||||||
</label>
|
</label>
|
||||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||||
<NychCheckbox value="laurel" /> Laurel
|
<NychCheckbox v-model="laurel" /> Laurel
|
||||||
</label>
|
</label>
|
||||||
</NychCheckboxGroup>
|
</NychCheckboxGroup>
|
||||||
<p style="color: var(--text-muted); margin-top: 0.75rem;">Offerings: {{ offerings.join(', ') || 'none' }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue