feat: migrate RadioButton/RadioButtonGroup to shadcn-vue RadioGroup
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
2eb438932b
commit
d999cd95f6
10 changed files with 194 additions and 130 deletions
|
|
@ -8,3 +8,4 @@ export { Switch } from './ui/switch'
|
|||
export { Badge, badgeVariants } from './ui/badge'
|
||||
export { Checkbox } from './ui/checkbox'
|
||||
export { CheckboxGroup } from './ui/checkbox-group'
|
||||
export { RadioGroup, RadioGroupItem } from './ui/radio-group'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import type { RadioGroupRootEmits, RadioGroupRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { RadioGroupRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<RadioGroupRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<RadioGroupRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RadioGroupRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="radio-group"
|
||||
:class="cn('grid gap-2 w-full', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</RadioGroupRoot>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<script setup lang="ts">
|
||||
import { CircleIcon } from '@lucide/vue';
|
||||
|
||||
import type { RadioGroupItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
RadioGroupIndicator,
|
||||
RadioGroupItem,
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<RadioGroupItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RadioGroupItem
|
||||
data-slot="radio-group-item"
|
||||
v-bind="forwardedProps"
|
||||
: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 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 flex size-4 rounded-full focus-visible:ring-3 aria-invalid:ring-3 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<RadioGroupIndicator
|
||||
data-slot="radio-group-indicator"
|
||||
class="flex size-4 items-center justify-center"
|
||||
>
|
||||
<slot>
|
||||
<CircleIcon class="bg-primary-foreground absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full" />
|
||||
</slot>
|
||||
</RadioGroupIndicator>
|
||||
</RadioGroupItem>
|
||||
</template>
|
||||
2
packages/library/src/components/ui/radio-group/index.ts
Normal file
2
packages/library/src/components/ui/radio-group/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as RadioGroup } from "./RadioGroup.vue"
|
||||
export { default as RadioGroupItem } from "./RadioGroupItem.vue"
|
||||
|
|
@ -14,8 +14,8 @@ export {
|
|||
Textarea,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
RadioButton,
|
||||
RadioButtonGroup,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Switch,
|
||||
Select,
|
||||
Card,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import type { App } from 'vue'
|
||||
import {
|
||||
RadioButton as PrimeRadioButton,
|
||||
RadioButtonGroup as PrimeRadioButtonGroup,
|
||||
Select as PrimeSelect,
|
||||
Card as PrimeCard,
|
||||
Dialog as PrimeDialog,
|
||||
|
|
@ -19,9 +17,23 @@ import {
|
|||
badgeVariants,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from '../components'
|
||||
|
||||
export { Button, buttonVariants, Input, Textarea, Switch, Badge, badgeVariants, Checkbox, CheckboxGroup }
|
||||
export {
|
||||
Button,
|
||||
buttonVariants,
|
||||
Input,
|
||||
Textarea,
|
||||
Switch,
|
||||
Badge,
|
||||
badgeVariants,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Themeable that themes a PrimeVue component through its passthrough (`pt`) API.
|
||||
|
|
@ -65,8 +77,6 @@ const loadingIconThemeable: Themeable<typeof NychLoadingIcon> = {
|
|||
const nychthemeron: ThemeLibrary = {
|
||||
components: {
|
||||
LoadingIcon: loadingIconThemeable,
|
||||
RadioButton: ptThemeable(PrimeRadioButton, 'radio', ['box', 'input', 'icon']),
|
||||
RadioButtonGroup: ptThemeable(PrimeRadioButtonGroup, 'radio-group'),
|
||||
Select: ptThemeable(PrimeSelect, 'select', [
|
||||
'label',
|
||||
'dropdown',
|
||||
|
|
@ -110,10 +120,6 @@ const nychthemeron: ThemeLibrary = {
|
|||
const _engine = new ThemeEngine(nychthemeron)
|
||||
|
||||
export const LoadingIcon = _engine.getComponent<typeof NychLoadingIcon>('LoadingIcon')
|
||||
export const RadioButton: typeof PrimeRadioButton =
|
||||
_engine.getComponent<typeof PrimeRadioButton>('RadioButton')
|
||||
export const RadioButtonGroup: typeof PrimeRadioButtonGroup =
|
||||
_engine.getComponent<typeof PrimeRadioButtonGroup>('RadioButtonGroup')
|
||||
export const Select: typeof PrimeSelect = _engine.getComponent<typeof PrimeSelect>('Select')
|
||||
export const Card: typeof PrimeCard = _engine.getComponent<typeof PrimeCard>('Card')
|
||||
export const Dialog: typeof PrimeDialog = _engine.getComponent<typeof PrimeDialog>('Dialog')
|
||||
|
|
@ -126,8 +132,8 @@ export const createNychthemeron = (): {
|
|||
Textarea: typeof Textarea
|
||||
Checkbox: typeof Checkbox
|
||||
CheckboxGroup: typeof CheckboxGroup
|
||||
RadioButton: typeof PrimeRadioButton
|
||||
RadioButtonGroup: typeof PrimeRadioButtonGroup
|
||||
RadioGroup: typeof RadioGroup
|
||||
RadioGroupItem: typeof RadioGroupItem
|
||||
Switch: typeof Switch
|
||||
Select: typeof PrimeSelect
|
||||
Card: typeof PrimeCard
|
||||
|
|
@ -142,8 +148,8 @@ export const createNychthemeron = (): {
|
|||
Textarea,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
RadioButton,
|
||||
RadioButtonGroup,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Switch,
|
||||
Select,
|
||||
Card,
|
||||
|
|
@ -157,8 +163,8 @@ export const createNychthemeron = (): {
|
|||
app.component('NychTextarea', Textarea)
|
||||
app.component('NychCheckbox', Checkbox)
|
||||
app.component('NychCheckboxGroup', CheckboxGroup)
|
||||
app.component('NychRadioButton', RadioButton)
|
||||
app.component('NychRadioButtonGroup', RadioButtonGroup)
|
||||
app.component('NychRadioGroup', RadioGroup)
|
||||
app.component('NychRadioGroupItem', RadioGroupItem)
|
||||
app.component('NychSwitch', Switch)
|
||||
app.component('NychSelect', Select)
|
||||
app.component('NychCard', Card)
|
||||
|
|
|
|||
32
packages/library/tests/RadioGroup.spec.ts
Normal file
32
packages/library/tests/RadioGroup.spec.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { RadioGroup, RadioGroupItem } from '../src/lib'
|
||||
|
||||
describe('RadioGroup', () => {
|
||||
it('marks the item matching modelValue as checked', () => {
|
||||
const wrapper = mount(RadioGroup, {
|
||||
props: { modelValue: 'hades' },
|
||||
slots: {
|
||||
default: () => [
|
||||
h(RadioGroupItem, { value: 'apollo' }),
|
||||
h(RadioGroupItem, { value: 'hades' }),
|
||||
],
|
||||
},
|
||||
})
|
||||
const items = wrapper.findAllComponents(RadioGroupItem)
|
||||
expect(items[0]?.get('button').attributes('data-state')).toBe('unchecked')
|
||||
expect(items[1]?.get('button').attributes('data-state')).toBe('checked')
|
||||
})
|
||||
|
||||
it('emits update:modelValue when an item is selected', async () => {
|
||||
const wrapper = mount(RadioGroup, {
|
||||
props: { modelValue: 'apollo' },
|
||||
slots: {
|
||||
default: () => [h(RadioGroupItem, { value: 'hades' })],
|
||||
},
|
||||
})
|
||||
await wrapper.findComponent(RadioGroupItem).get('button').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['hades'])
|
||||
})
|
||||
})
|
||||
|
|
@ -3,8 +3,6 @@ import type { App } from 'vue'
|
|||
import {
|
||||
createNychthemeron,
|
||||
LoadingIcon,
|
||||
RadioButton,
|
||||
RadioButtonGroup,
|
||||
Select,
|
||||
Card,
|
||||
Dialog,
|
||||
|
|
@ -65,8 +63,6 @@ describe('LoadingIcon', () => {
|
|||
|
||||
describe('themed pt components', () => {
|
||||
const cases = [
|
||||
{ name: 'RadioButton', component: RadioButton, base: 'nych-radio' },
|
||||
{ name: 'RadioButtonGroup', component: RadioButtonGroup, base: 'nych-radio-group' },
|
||||
{ name: 'Select', component: Select, base: 'nych-select' },
|
||||
{ name: 'Card', component: Card, base: 'nych-card' },
|
||||
{ name: 'Dialog', component: Dialog, base: 'nych-dialog' },
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
RadioButton as NychRadioButton,
|
||||
RadioButtonGroup as NychRadioButtonGroup,
|
||||
} from '@nychthemeron/library'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/RadioButton',
|
||||
component: NychRadioButton,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
disabled: {
|
||||
control: { type: 'boolean' },
|
||||
description: 'Disable the radio button',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof NychRadioButton>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { NychRadioButton },
|
||||
setup() {
|
||||
const realm = ref('underworld')
|
||||
return { realm }
|
||||
},
|
||||
template: `
|
||||
<div style="display: flex; gap: 1rem;">
|
||||
<NychRadioButton v-model="realm" value="underworld" />
|
||||
<NychRadioButton v-model="realm" value="sky" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => ({
|
||||
components: { NychRadioButton },
|
||||
setup() {
|
||||
const realm = ref('sky')
|
||||
return { realm }
|
||||
},
|
||||
template: `
|
||||
<div style="display: flex; gap: 1rem;">
|
||||
<NychRadioButton :modelValue="'underworld'" value="sky" disabled />
|
||||
<NychRadioButton :modelValue="'sky'" value="sky" disabled />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const WithLabels: Story = {
|
||||
render: () => ({
|
||||
components: { NychRadioButton },
|
||||
setup() {
|
||||
const god = ref('apollo')
|
||||
return { god }
|
||||
},
|
||||
template: `
|
||||
<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;">
|
||||
<NychRadioButton v-model="god" value="apollo" /> Apollo
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<NychRadioButton v-model="god" value="hades" /> Hades
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<NychRadioButton v-model="god" value="hermes" /> Hermes
|
||||
</label>
|
||||
<p style="color: var(--text-muted); margin-top: 0.25rem;">Chosen: {{ god }}</p>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
// RadioButtonGroup binds the v-model once on the group and shares a name across
|
||||
// children — the children only carry a `value`.
|
||||
export const Group: Story = {
|
||||
render: () => ({
|
||||
components: { NychRadioButton, NychRadioButtonGroup },
|
||||
setup() {
|
||||
const god = ref('apollo')
|
||||
return { god }
|
||||
},
|
||||
template: `
|
||||
<div style="font-family: var(--font-sans); color: var(--text-body);">
|
||||
<NychRadioButtonGroup v-model="god" name="god">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<NychRadioButton value="apollo" /> Apollo
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<NychRadioButton value="hades" /> Hades
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<NychRadioButton value="hermes" /> Hermes
|
||||
</label>
|
||||
</NychRadioButtonGroup>
|
||||
<p style="color: var(--text-muted); margin-top: 0.75rem;">Chosen: {{ god }}</p>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
70
packages/playground/stories/RadioGroup.stories.ts
Normal file
70
packages/playground/stories/RadioGroup.stories.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { RadioGroup as NychRadioGroup, RadioGroupItem as NychRadioGroupItem } from '@nychthemeron/library'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/RadioGroup',
|
||||
component: NychRadioGroup,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof NychRadioGroup>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { NychRadioGroup, NychRadioGroupItem },
|
||||
setup() {
|
||||
const realm = ref('underworld')
|
||||
return { realm }
|
||||
},
|
||||
template: `
|
||||
<NychRadioGroup v-model="realm" style="display: flex; flex-direction: row; gap: 1rem;">
|
||||
<NychRadioGroupItem value="underworld" />
|
||||
<NychRadioGroupItem value="sky" />
|
||||
</NychRadioGroup>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => ({
|
||||
components: { NychRadioGroup, NychRadioGroupItem },
|
||||
template: `
|
||||
<NychRadioGroup model-value="sky" disabled style="display: flex; flex-direction: row; gap: 1rem;">
|
||||
<NychRadioGroupItem value="underworld" />
|
||||
<NychRadioGroupItem value="sky" />
|
||||
</NychRadioGroup>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const WithLabels: Story = {
|
||||
render: () => ({
|
||||
components: { NychRadioGroup, NychRadioGroupItem },
|
||||
setup() {
|
||||
const god = ref('apollo')
|
||||
return { god }
|
||||
},
|
||||
template: `
|
||||
<div style="font-family: var(--font-sans); color: var(--text-body);">
|
||||
<NychRadioGroup v-model="god">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<NychRadioGroupItem value="apollo" /> Apollo
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<NychRadioGroupItem value="hades" /> Hades
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<NychRadioGroupItem value="hermes" /> Hermes
|
||||
</label>
|
||||
</NychRadioGroup>
|
||||
<p style="color: var(--text-muted); margin-top: 0.75rem;">Chosen: {{ god }}</p>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
Loading…
Reference in a new issue