feat: migrate RadioButton/RadioButtonGroup to shadcn-vue RadioGroup

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Matthew McPeak 2026-07-11 14:39:22 -04:00
parent 2eb438932b
commit d999cd95f6
10 changed files with 194 additions and 130 deletions

View file

@ -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'

View file

@ -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>

View file

@ -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>

View file

@ -0,0 +1,2 @@
export { default as RadioGroup } from "./RadioGroup.vue"
export { default as RadioGroupItem } from "./RadioGroupItem.vue"

View file

@ -14,8 +14,8 @@ export {
Textarea,
Checkbox,
CheckboxGroup,
RadioButton,
RadioButtonGroup,
RadioGroup,
RadioGroupItem,
Switch,
Select,
Card,

View file

@ -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)

View 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'])
})
})

View file

@ -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' },

View file

@ -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>
`,
}),
}

View 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>
`,
}),
}