Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { RadioGroupItem } from '@nuxt/ui'

const items = [
{
value: 'free',
label: 'Free',
description: 'For trying out the basics.',
slot: 'free' as const
},
{
value: 'pro',
label: 'Pro',
description: 'For growing teams.'
}
] satisfies RadioGroupItem[]
</script>

<template>
<URadioGroup :items="items" variant="card" default-value="free" class="w-full">
<template #free="{ item }">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium">
{{ item.label }}
</p>
<p class="text-muted">
{{ item.description }}
</p>
</div>
<UBadge label="Popular" color="primary" variant="subtle" />
</div>
</template>
</URadioGroup>
</template>
16 changes: 16 additions & 0 deletions docs/content/docs/2.components/radio-group.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ You can also pass an array of objects with the following properties:

- `label?: string`{lang="ts-type"}
- `description?: string`{lang="ts-type"}
- [`slot?: string`{lang="ts-type"}](#with-custom-slot)
- [`value?: string`{lang="ts-type"}](#value-key)
- `disabled?: boolean`{lang="ts-type"}
- `class?: any`{lang="ts-type"}
Expand Down Expand Up @@ -290,6 +291,21 @@ props:
---
::

## Examples

### With custom slot :badge{label="Soon" class="align-text-top"}

Use the `slot` property to replace the label and description of a specific item with custom content.

You will have access to the following slots:

- `#{{ item.slot }}`{lang="ts-type"}
- `#content`{lang="ts-type"} to customize the content of items without a `slot` property

Slots receive the `item` and `modelValue` properties.

:component-example{name="radio-group-custom-slot-example"}

## API

### Props
Expand Down
47 changes: 35 additions & 12 deletions src/runtime/components/RadioGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type RadioGroupValue = AcceptableValue
export type RadioGroupItem = RadioGroupValue | {
label?: string
description?: string
slot?: string
disabled?: boolean
value?: RadioGroupValue
class?: any
Expand Down Expand Up @@ -80,14 +81,25 @@ export type RadioGroupEmits<T extends RadioGroupItem[] = RadioGroupItem[], VK ex
} & GetModelValueEmits<T, VK, false>

type NormalizeItem<T extends RadioGroupItem> = Exclude<T & { id: string }, RadioGroupValue>

type SlotProps<T extends RadioGroupItem> = (props: { item: NormalizeItem<T>, modelValue: RadioGroupValue }) => VNode[]

type DynamicSlotProps<T extends RadioGroupItem[]> = {
[K in T[number] extends infer Item
? Item extends { slot?: infer Slot }
? Slot extends string ? Slot : never
: never
: never]?: SlotProps<T[number]>
}

export interface RadioGroupSlots<T extends RadioGroupItem[] = RadioGroupItem[]> {
legend?(props?: {}): VNode[]
label?: SlotProps<T[number]>
description?: SlotProps<T[number]>
}

type RadioGroupAllSlots<T extends RadioGroupItem[] = RadioGroupItem[]> = RadioGroupSlots<T> & {
content?: SlotProps<T[number]>
} & DynamicSlotProps<T>
</script>

<script setup lang="ts" generic="T extends RadioGroupItem[], VK extends GetItemKeys<T> = 'value'">
Expand All @@ -108,7 +120,7 @@ const _props = withDefaults(defineProps<RadioGroupProps<T, VK>>(), {
orientation: 'vertical'
})
const emits = defineEmits<RadioGroupEmits<T, VK>>()
const slots = defineSlots<RadioGroupSlots<T>>()
const slots = defineSlots<RadioGroupAllSlots<T>>()

const props = useComponentProps<RadioGroupProps<T, VK>>('radioGroup', _props)

Expand Down Expand Up @@ -211,17 +223,28 @@ function onUpdate(value: any) {
</RRadioGroupItem>
</div>

<div v-if="(item.label || !!slots.label) || (item.description || !!slots.description)" data-slot="wrapper" :class="ui.wrapper({ class: [props.ui?.wrapper, item.ui?.wrapper] })">
<component :is="(!props.variant || props.variant === 'list') ? Label : 'p'" v-if="item.label || !!slots.label" :for="item.id" data-slot="label" :class="ui.label({ class: [props.ui?.label, item.ui?.label], disabled: item.disabled || disabled })">
<slot name="label" :item="item" :model-value="(props.modelValue as RadioGroupValue)">
{{ item.label }}
</slot>
<div v-if="(item.label || !!slots.label) || (item.description || !!slots.description) || (item.slot ? !!slots[item.slot as keyof RadioGroupAllSlots<T>] : !!slots.content)" data-slot="wrapper" :class="ui.wrapper({ class: [props.ui?.wrapper, item.ui?.wrapper] })">
<component
:is="(!props.variant || props.variant === 'list') ? Label : 'div'"
v-if="item.slot ? !!slots[item.slot as keyof RadioGroupAllSlots<T>] : !!slots.content"
:for="(!props.variant || props.variant === 'list') ? item.id : undefined"
data-slot="content"
>
<slot :name="((item.slot || 'content') as keyof RadioGroupAllSlots<T>)" :item="item" :model-value="(props.modelValue as RadioGroupValue)" />
</component>
<p v-if="item.description || !!slots.description" data-slot="description" :class="ui.description({ class: [props.ui?.description, item.ui?.description], disabled: item.disabled || disabled })">
<slot name="description" :item="item" :model-value="(props.modelValue as RadioGroupValue)">
{{ item.description }}
</slot>
</p>

<template v-else>
<component :is="(!props.variant || props.variant === 'list') ? Label : 'p'" v-if="item.label || !!slots.label" :for="item.id" data-slot="label" :class="ui.label({ class: [props.ui?.label, item.ui?.label], disabled: item.disabled || disabled })">
<slot name="label" :item="item" :model-value="(props.modelValue as RadioGroupValue)">
{{ item.label }}
</slot>
</component>
<p v-if="item.description || !!slots.description" data-slot="description" :class="ui.description({ class: [props.ui?.description, item.ui?.description], disabled: item.disabled || disabled })">
<slot name="description" :item="item" :model-value="(props.modelValue as RadioGroupValue)">
{{ item.description }}
</slot>
</p>
</template>
</div>
</component>
</fieldset>
Expand Down
45 changes: 45 additions & 0 deletions test/components/RadioGroup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,51 @@ describe('RadioGroup', () => {
expect(await axe(wrapper.element)).toHaveNoViolations()
})

it('renders a custom slot for the matching item', async () => {
const wrapper = await mountSuspended(RadioGroup, {
props: {
items: [
{ value: '1', label: 'Option 1', slot: 'custom' },
{ value: '2', label: 'Option 2' }
]
},
slots: {
custom: ({ item }) => `Custom ${item.label}`
}
})

expect(wrapper.text()).toContain('Custom Option 1')
expect(wrapper.text()).toContain('Option 2')
expect(wrapper.find('[data-slot="content"]').text()).toBe('Custom Option 1')
expect(wrapper.findAll('[data-slot="label"]')).toHaveLength(1)
})

it('renders the content slot for items without a custom slot', async () => {
const wrapper = await mountSuspended(RadioGroup, {
props,
slots: {
content: ({ item }) => `Custom: ${item.label}`
}
})

expect(wrapper.findAll('[data-slot="content"]')).toHaveLength(items.length)
expect(wrapper.text()).toContain('Custom: Option 1')
expect(wrapper.text()).toContain('Custom: Option 3')
expect(wrapper.findAll('[data-slot="label"]')).toHaveLength(0)
})

it('falls back to label and description when a custom slot is not provided', async () => {
const wrapper = await mountSuspended(RadioGroup, {
props: {
items: [{ value: '1', label: 'Option 1', description: 'Description 1', slot: 'custom' }]
}
})

expect(wrapper.text()).toContain('Option 1')
expect(wrapper.text()).toContain('Description 1')
expect(wrapper.find('[data-slot="content"]').exists()).toBe(false)
})

describe('emits', () => {
test('update:modelValue event', async () => {
const wrapper = mount(RadioGroup, { props: { items: ['Option 1', 'Option 2'] } })
Expand Down
Loading