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 { Badge, badgeVariants } from './ui/badge'
|
||||||
export { Checkbox } from './ui/checkbox'
|
export { Checkbox } from './ui/checkbox'
|
||||||
export { CheckboxGroup } from './ui/checkbox-group'
|
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,
|
Textarea,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
CheckboxGroup,
|
CheckboxGroup,
|
||||||
RadioButton,
|
RadioGroup,
|
||||||
RadioButtonGroup,
|
RadioGroupItem,
|
||||||
Switch,
|
Switch,
|
||||||
Select,
|
Select,
|
||||||
Card,
|
Card,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import type { App } from 'vue'
|
import type { App } from 'vue'
|
||||||
import {
|
import {
|
||||||
RadioButton as PrimeRadioButton,
|
|
||||||
RadioButtonGroup as PrimeRadioButtonGroup,
|
|
||||||
Select as PrimeSelect,
|
Select as PrimeSelect,
|
||||||
Card as PrimeCard,
|
Card as PrimeCard,
|
||||||
Dialog as PrimeDialog,
|
Dialog as PrimeDialog,
|
||||||
|
|
@ -19,9 +17,23 @@ import {
|
||||||
badgeVariants,
|
badgeVariants,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
CheckboxGroup,
|
CheckboxGroup,
|
||||||
|
RadioGroup,
|
||||||
|
RadioGroupItem,
|
||||||
} from '../components'
|
} 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.
|
* 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 = {
|
const nychthemeron: ThemeLibrary = {
|
||||||
components: {
|
components: {
|
||||||
LoadingIcon: loadingIconThemeable,
|
LoadingIcon: loadingIconThemeable,
|
||||||
RadioButton: ptThemeable(PrimeRadioButton, 'radio', ['box', 'input', 'icon']),
|
|
||||||
RadioButtonGroup: ptThemeable(PrimeRadioButtonGroup, 'radio-group'),
|
|
||||||
Select: ptThemeable(PrimeSelect, 'select', [
|
Select: ptThemeable(PrimeSelect, 'select', [
|
||||||
'label',
|
'label',
|
||||||
'dropdown',
|
'dropdown',
|
||||||
|
|
@ -110,10 +120,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 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 Select: typeof PrimeSelect = _engine.getComponent<typeof PrimeSelect>('Select')
|
||||||
export const Card: typeof PrimeCard = _engine.getComponent<typeof PrimeCard>('Card')
|
export const Card: typeof PrimeCard = _engine.getComponent<typeof PrimeCard>('Card')
|
||||||
export const Dialog: typeof PrimeDialog = _engine.getComponent<typeof PrimeDialog>('Dialog')
|
export const Dialog: typeof PrimeDialog = _engine.getComponent<typeof PrimeDialog>('Dialog')
|
||||||
|
|
@ -126,8 +132,8 @@ export const createNychthemeron = (): {
|
||||||
Textarea: typeof Textarea
|
Textarea: typeof Textarea
|
||||||
Checkbox: typeof Checkbox
|
Checkbox: typeof Checkbox
|
||||||
CheckboxGroup: typeof CheckboxGroup
|
CheckboxGroup: typeof CheckboxGroup
|
||||||
RadioButton: typeof PrimeRadioButton
|
RadioGroup: typeof RadioGroup
|
||||||
RadioButtonGroup: typeof PrimeRadioButtonGroup
|
RadioGroupItem: typeof RadioGroupItem
|
||||||
Switch: typeof Switch
|
Switch: typeof Switch
|
||||||
Select: typeof PrimeSelect
|
Select: typeof PrimeSelect
|
||||||
Card: typeof PrimeCard
|
Card: typeof PrimeCard
|
||||||
|
|
@ -142,8 +148,8 @@ export const createNychthemeron = (): {
|
||||||
Textarea,
|
Textarea,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
CheckboxGroup,
|
CheckboxGroup,
|
||||||
RadioButton,
|
RadioGroup,
|
||||||
RadioButtonGroup,
|
RadioGroupItem,
|
||||||
Switch,
|
Switch,
|
||||||
Select,
|
Select,
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -157,8 +163,8 @@ export const createNychthemeron = (): {
|
||||||
app.component('NychTextarea', Textarea)
|
app.component('NychTextarea', Textarea)
|
||||||
app.component('NychCheckbox', Checkbox)
|
app.component('NychCheckbox', Checkbox)
|
||||||
app.component('NychCheckboxGroup', CheckboxGroup)
|
app.component('NychCheckboxGroup', CheckboxGroup)
|
||||||
app.component('NychRadioButton', RadioButton)
|
app.component('NychRadioGroup', RadioGroup)
|
||||||
app.component('NychRadioButtonGroup', RadioButtonGroup)
|
app.component('NychRadioGroupItem', RadioGroupItem)
|
||||||
app.component('NychSwitch', Switch)
|
app.component('NychSwitch', Switch)
|
||||||
app.component('NychSelect', Select)
|
app.component('NychSelect', Select)
|
||||||
app.component('NychCard', Card)
|
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 {
|
import {
|
||||||
createNychthemeron,
|
createNychthemeron,
|
||||||
LoadingIcon,
|
LoadingIcon,
|
||||||
RadioButton,
|
|
||||||
RadioButtonGroup,
|
|
||||||
Select,
|
Select,
|
||||||
Card,
|
Card,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -65,8 +63,6 @@ describe('LoadingIcon', () => {
|
||||||
|
|
||||||
describe('themed pt components', () => {
|
describe('themed pt components', () => {
|
||||||
const cases = [
|
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: 'Select', component: Select, base: 'nych-select' },
|
||||||
{ name: 'Card', component: Card, base: 'nych-card' },
|
{ name: 'Card', component: Card, base: 'nych-card' },
|
||||||
{ name: 'Dialog', component: Dialog, base: 'nych-dialog' },
|
{ 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