feat: 新增 FaRadioGroup 组件

This commit is contained in:
Hooray
2026-04-29 00:46:48 +08:00
parent 931b10c1f3
commit bb3bd479b8
14 changed files with 579 additions and 76 deletions
@@ -222,6 +222,14 @@ const routes: RouteRecordRaw = {
title: '进度条',
},
},
{
path: 'radio_group',
name: 'componentExampleRadioGroup',
component: () => import('@/views/component_example/radio_group/index.vue'),
meta: {
title: '单选组',
},
},
{
path: 'scroll_area',
name: 'componentExampleScrollArea',
Vendored Executable → Regular
+2 -76
View File
@@ -19,82 +19,45 @@ declare module 'vue' {
AppNotAllowed: typeof import('./../components/AppNotAllowed/index.vue')['default']
AppNotSupportedMobile: typeof import('./../components/AppNotSupportedMobile/index.vue')['default']
AppSystemInfo: typeof import('./../components/AppSystemInfo/index.vue')['default']
FaAnimatedBeam: typeof import('@fantastic-admin/components')['FaAnimatedBeam']
FaAnimatedCountTo: typeof import('@fantastic-admin/components')['FaAnimatedCountTo']
FaAnimatedCountToGroup: typeof import('@fantastic-admin/components')['FaAnimatedCountToGroup']
FaAvatar: typeof import('@fantastic-admin/components')['FaAvatar']
FaBlurReveal: typeof import('@fantastic-admin/components')['FaBlurReveal']
FaBorderBeam: typeof import('@fantastic-admin/components')['FaBorderBeam']
FaButton: typeof import('@fantastic-admin/components')['FaButton']
FaButtonGroup: typeof import('@fantastic-admin/components')['FaButtonGroup']
FaCard: typeof import('@fantastic-admin/components')['FaCard']
FaCarousel: typeof import('@fantastic-admin/components')['FaCarousel']
FaCascader: typeof import('@fantastic-admin/components')['FaCascader']
FaCheckbox: typeof import('@fantastic-admin/components')['FaCheckbox']
FaCode: typeof import('@fantastic-admin/components')['FaCode']
FaCodePreview: typeof import('@fantastic-admin/components')['FaCodePreview']
FaCollapsible: typeof import('@fantastic-admin/components')['FaCollapsible']
FaContextMenu: typeof import('@fantastic-admin/components')['FaContextMenu']
FaCountTo: typeof import('@fantastic-admin/components')['FaCountTo']
FaDigitalCard: typeof import('@fantastic-admin/components')['FaDigitalCard']
FaDivider: typeof import('@fantastic-admin/components')['FaDivider']
FaDrawer: typeof import('@fantastic-admin/components')['FaDrawer']
FaDropdown: typeof import('@fantastic-admin/components')['FaDropdown']
FaEmpty: typeof import('@fantastic-admin/components')['FaEmpty']
FaFileUpload: typeof import('@fantastic-admin/components')['FaFileUpload']
FaFixedBar: typeof import('@fantastic-admin/components')['FaFixedBar']
FaFlipCard: typeof import('@fantastic-admin/components')['FaFlipCard']
FaFlipWords: typeof import('@fantastic-admin/components')['FaFlipWords']
FaGlowyCard: typeof import('@fantastic-admin/components')['FaGlowyCard']
FaGlowyCardWrapper: typeof import('@fantastic-admin/components')['FaGlowyCardWrapper']
FaGradientButton: typeof import('@fantastic-admin/components')['FaGradientButton']
FaHoverCard: typeof import('@fantastic-admin/components')['FaHoverCard']
FaIcon: typeof import('@fantastic-admin/components')['FaIcon']
FaIconPicker: typeof import('@fantastic-admin/components')['FaIconPicker']
FaImagePreview: typeof import('@fantastic-admin/components')['FaImagePreview']
FaImageUpload: typeof import('@fantastic-admin/components')['FaImageUpload']
FaInput: typeof import('@fantastic-admin/components')['FaInput']
FaInputOTP: typeof import('@fantastic-admin/components')['FaInputOTP']
FaInteractiveButton: typeof import('@fantastic-admin/components')['FaInteractiveButton']
FaKbd: typeof import('@fantastic-admin/components')['FaKbd']
FaKbdGroup: typeof import('@fantastic-admin/components')['FaKbdGroup']
FaLabel: typeof import('@fantastic-admin/components')['FaLabel']
FaLayoutContainer: typeof import('@fantastic-admin/components')['FaLayoutContainer']
FaLinkPreview: typeof import('@fantastic-admin/components')['FaLinkPreview']
FaLoading: typeof import('@fantastic-admin/components')['FaLoading']
FaMarquee: typeof import('@fantastic-admin/components')['FaMarquee']
FaModal: typeof import('@fantastic-admin/components')['FaModal']
FaMultiStepLoader: typeof import('@fantastic-admin/components')['FaMultiStepLoader']
FaNumberField: typeof import('@fantastic-admin/components')['FaNumberField']
FaPageHeader: typeof import('@fantastic-admin/components')['FaPageHeader']
FaPageMain: typeof import('@fantastic-admin/components')['FaPageMain']
FaPagination: typeof import('@fantastic-admin/components')['FaPagination']
FaParticlesBg: typeof import('@fantastic-admin/components')['FaParticlesBg']
FaPasswordStrength: typeof import('@fantastic-admin/components')['FaPasswordStrength']
FaPatternBg: typeof import('@fantastic-admin/components')['FaPatternBg']
FaPopover: typeof import('@fantastic-admin/components')['FaPopover']
FaProgress: typeof import('@fantastic-admin/components')['FaProgress']
FaQrcode: typeof import('@fantastic-admin/components')['FaQrcode']
FaScratchOff: typeof import('@fantastic-admin/components')['FaScratchOff']
FaRadioGroup: typeof import('@fantastic-admin/components')['FaRadioGroup']
FaScrollArea: typeof import('@fantastic-admin/components')['FaScrollArea']
FaScrollingText: typeof import('@fantastic-admin/components')['FaScrollingText']
FaSearchBar: typeof import('@fantastic-admin/components')['FaSearchBar']
FaSelect: typeof import('@fantastic-admin/components')['FaSelect']
FaSlider: typeof import('@fantastic-admin/components')['FaSlider']
FaSmoothSwipe: typeof import('@fantastic-admin/components')['FaSmoothSwipe']
FaSparklesText: typeof import('@fantastic-admin/components')['FaSparklesText']
FaSparkline: typeof import('@fantastic-admin/components')['FaSparkline']
FaSpotlightCard: typeof import('@fantastic-admin/components')['FaSpotlightCard']
FaStorageBox: typeof import('@fantastic-admin/components')['FaStorageBox']
FaSwitch: typeof import('@fantastic-admin/components')['FaSwitch']
FaTabs: typeof import('@fantastic-admin/components')['FaTabs']
FaTextarea: typeof import('@fantastic-admin/components')['FaTextarea']
FaTextHighlight: typeof import('@fantastic-admin/components')['FaTextHighlight']
FaTimeAgo: typeof import('@fantastic-admin/components')['FaTimeAgo']
FaTimeline: typeof import('@fantastic-admin/components')['FaTimeline']
FaToast: typeof import('@fantastic-admin/components')['FaToast']
FaTooltip: typeof import('@fantastic-admin/components')['FaTooltip']
FaTree: typeof import('@fantastic-admin/components')['FaTree']
FaTrend: typeof import('@fantastic-admin/components')['FaTrend']
}
}
@@ -108,81 +71,44 @@ declare global {
const AppNotAllowed: typeof import('./../components/AppNotAllowed/index.vue')['default']
const AppNotSupportedMobile: typeof import('./../components/AppNotSupportedMobile/index.vue')['default']
const AppSystemInfo: typeof import('./../components/AppSystemInfo/index.vue')['default']
const FaAnimatedBeam: typeof import('@fantastic-admin/components')['FaAnimatedBeam']
const FaAnimatedCountTo: typeof import('@fantastic-admin/components')['FaAnimatedCountTo']
const FaAnimatedCountToGroup: typeof import('@fantastic-admin/components')['FaAnimatedCountToGroup']
const FaAvatar: typeof import('@fantastic-admin/components')['FaAvatar']
const FaBlurReveal: typeof import('@fantastic-admin/components')['FaBlurReveal']
const FaBorderBeam: typeof import('@fantastic-admin/components')['FaBorderBeam']
const FaButton: typeof import('@fantastic-admin/components')['FaButton']
const FaButtonGroup: typeof import('@fantastic-admin/components')['FaButtonGroup']
const FaCard: typeof import('@fantastic-admin/components')['FaCard']
const FaCarousel: typeof import('@fantastic-admin/components')['FaCarousel']
const FaCascader: typeof import('@fantastic-admin/components')['FaCascader']
const FaCheckbox: typeof import('@fantastic-admin/components')['FaCheckbox']
const FaCode: typeof import('@fantastic-admin/components')['FaCode']
const FaCodePreview: typeof import('@fantastic-admin/components')['FaCodePreview']
const FaCollapsible: typeof import('@fantastic-admin/components')['FaCollapsible']
const FaContextMenu: typeof import('@fantastic-admin/components')['FaContextMenu']
const FaCountTo: typeof import('@fantastic-admin/components')['FaCountTo']
const FaDigitalCard: typeof import('@fantastic-admin/components')['FaDigitalCard']
const FaDivider: typeof import('@fantastic-admin/components')['FaDivider']
const FaDrawer: typeof import('@fantastic-admin/components')['FaDrawer']
const FaDropdown: typeof import('@fantastic-admin/components')['FaDropdown']
const FaEmpty: typeof import('@fantastic-admin/components')['FaEmpty']
const FaFileUpload: typeof import('@fantastic-admin/components')['FaFileUpload']
const FaFixedBar: typeof import('@fantastic-admin/components')['FaFixedBar']
const FaFlipCard: typeof import('@fantastic-admin/components')['FaFlipCard']
const FaFlipWords: typeof import('@fantastic-admin/components')['FaFlipWords']
const FaGlowyCard: typeof import('@fantastic-admin/components')['FaGlowyCard']
const FaGlowyCardWrapper: typeof import('@fantastic-admin/components')['FaGlowyCardWrapper']
const FaGradientButton: typeof import('@fantastic-admin/components')['FaGradientButton']
const FaHoverCard: typeof import('@fantastic-admin/components')['FaHoverCard']
const FaIcon: typeof import('@fantastic-admin/components')['FaIcon']
const FaIconPicker: typeof import('@fantastic-admin/components')['FaIconPicker']
const FaImagePreview: typeof import('@fantastic-admin/components')['FaImagePreview']
const FaImageUpload: typeof import('@fantastic-admin/components')['FaImageUpload']
const FaInput: typeof import('@fantastic-admin/components')['FaInput']
const FaInputOTP: typeof import('@fantastic-admin/components')['FaInputOTP']
const FaInteractiveButton: typeof import('@fantastic-admin/components')['FaInteractiveButton']
const FaKbd: typeof import('@fantastic-admin/components')['FaKbd']
const FaKbdGroup: typeof import('@fantastic-admin/components')['FaKbdGroup']
const FaLabel: typeof import('@fantastic-admin/components')['FaLabel']
const FaLayoutContainer: typeof import('@fantastic-admin/components')['FaLayoutContainer']
const FaLinkPreview: typeof import('@fantastic-admin/components')['FaLinkPreview']
const FaLoading: typeof import('@fantastic-admin/components')['FaLoading']
const FaMarquee: typeof import('@fantastic-admin/components')['FaMarquee']
const FaModal: typeof import('@fantastic-admin/components')['FaModal']
const FaMultiStepLoader: typeof import('@fantastic-admin/components')['FaMultiStepLoader']
const FaNumberField: typeof import('@fantastic-admin/components')['FaNumberField']
const FaPageHeader: typeof import('@fantastic-admin/components')['FaPageHeader']
const FaPageMain: typeof import('@fantastic-admin/components')['FaPageMain']
const FaPagination: typeof import('@fantastic-admin/components')['FaPagination']
const FaParticlesBg: typeof import('@fantastic-admin/components')['FaParticlesBg']
const FaPasswordStrength: typeof import('@fantastic-admin/components')['FaPasswordStrength']
const FaPatternBg: typeof import('@fantastic-admin/components')['FaPatternBg']
const FaPopover: typeof import('@fantastic-admin/components')['FaPopover']
const FaProgress: typeof import('@fantastic-admin/components')['FaProgress']
const FaQrcode: typeof import('@fantastic-admin/components')['FaQrcode']
const FaScratchOff: typeof import('@fantastic-admin/components')['FaScratchOff']
const FaRadioGroup: typeof import('@fantastic-admin/components')['FaRadioGroup']
const FaScrollArea: typeof import('@fantastic-admin/components')['FaScrollArea']
const FaScrollingText: typeof import('@fantastic-admin/components')['FaScrollingText']
const FaSearchBar: typeof import('@fantastic-admin/components')['FaSearchBar']
const FaSelect: typeof import('@fantastic-admin/components')['FaSelect']
const FaSlider: typeof import('@fantastic-admin/components')['FaSlider']
const FaSmoothSwipe: typeof import('@fantastic-admin/components')['FaSmoothSwipe']
const FaSparklesText: typeof import('@fantastic-admin/components')['FaSparklesText']
const FaSparkline: typeof import('@fantastic-admin/components')['FaSparkline']
const FaSpotlightCard: typeof import('@fantastic-admin/components')['FaSpotlightCard']
const FaStorageBox: typeof import('@fantastic-admin/components')['FaStorageBox']
const FaSwitch: typeof import('@fantastic-admin/components')['FaSwitch']
const FaTabs: typeof import('@fantastic-admin/components')['FaTabs']
const FaTextarea: typeof import('@fantastic-admin/components')['FaTextarea']
const FaTextHighlight: typeof import('@fantastic-admin/components')['FaTextHighlight']
const FaTimeAgo: typeof import('@fantastic-admin/components')['FaTimeAgo']
const FaTimeline: typeof import('@fantastic-admin/components')['FaTimeline']
const FaToast: typeof import('@fantastic-admin/components')['FaToast']
const FaTooltip: typeof import('@fantastic-admin/components')['FaTooltip']
const FaTree: typeof import('@fantastic-admin/components')['FaTree']
const FaTrend: typeof import('@fantastic-admin/components')['FaTrend']
}
@@ -0,0 +1,18 @@
<script setup lang="ts">
const value = ref('comfortable')
const options = [
{ label: '默认', value: 'default' },
{ label: '舒适', value: 'comfortable' },
{ label: '紧凑', value: 'compact' },
]
</script>
<template>
<div class="gap-4 grid">
<FaRadioGroup v-model="value" :options="options" />
<div class="text-sm text-muted-foreground">
当前值:{{ value }}
</div>
</div>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
const value = ref('growth')
const options = [
{
label: '创业版',
value: 'starter',
description: '适合 1-10 人小团队,保留核心能力。',
},
{
label: '成长版',
value: 'growth',
description: '适合多角色协作,支持审批与审计流程。',
},
{
label: '企业版',
value: 'enterprise',
description: '高级安全策略与 SSO 即将开放。',
disabled: true,
},
]
const currentLabel = computed(() =>
options.find(option => option.value === value.value)?.label ?? '',
)
</script>
<template>
<div class="gap-4 grid">
<FaRadioGroup v-model="value" :options="options" />
<div class="text-sm text-muted-foreground">
已选套餐:{{ currentLabel }}
</div>
</div>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
const value = ref('balanced')
const options = [
{
label: '专注模式',
value: 'focus',
description: '突出主任务,弱化辅助信息,适合录入和审批场景。',
},
{
label: '平衡模式',
value: 'balanced',
description: '信息密度与可读性保持平衡,适合作为默认配置。',
},
{
label: '高密度模式',
value: 'dense',
description: '在大屏中同时承载更多信息,适合运营与监控看板。',
},
]
const currentLabel = computed(() =>
options.find(option => option.value === value.value)?.label ?? '',
)
</script>
<template>
<div class="gap-4 grid">
<FaRadioGroup
v-model="value"
orientation="horizontal"
:options="options"
class="gap-4 md:grid-cols-3"
option-class="rounded-xl border border-transparent px-1 py-1"
item-class="mt-1"
>
<template #option="{ option, checked, disabled }">
<div
class="px-4 py-3 border rounded-xl flex gap-3 w-full transition-colors items-start justify-between" :class="[
checked ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40',
disabled && 'opacity-60',
]"
>
<div class="gap-1 grid min-w-0">
<div class="text-sm font-medium truncate">
{{ option.label }}
</div>
<div
v-if="option.description"
class="text-xs text-muted-foreground leading-5"
>
{{ option.description }}
</div>
</div>
<span
class="text-xs font-medium shrink-0" :class="[
checked ? 'text-primary' : 'text-muted-foreground',
]"
>
{{ checked ? '已选中' : '可选择' }}
</span>
</div>
</template>
</FaRadioGroup>
<div class="text-sm text-muted-foreground">
当前模式:{{ currentLabel }}
</div>
</div>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import Demo1 from './_demo1.vue'
import Demo2 from './_demo2.vue'
import Demo3 from './_demo3.vue'
</script>
<template>
<div>
<FaPageHeader title="单选组" description="FaRadioGroup" />
<FaPageMain main-class="p-4">
<Demo1 />
</FaPageMain>
<FaPageMain title="带描述和禁用态" main-class="p-4">
<Demo2 />
</FaPageMain>
<FaPageMain title="自定义选项内容" main-class="p-4">
<Demo3 />
</FaPageMain>
</div>
</template>
+1
View File
@@ -28,6 +28,7 @@ declare module 'vue' {
FaPasswordStrength: typeof import('./index')['FaPasswordStrength']
FaPopover: typeof import('./index')['FaPopover']
FaProgress: typeof import('./index')['FaProgress']
FaRadioGroup: typeof import('./index')['FaRadioGroup']
FaScrollArea: typeof import('./index')['FaScrollArea']
FaSearchBar: typeof import('./index')['FaSearchBar']
FaSelect: typeof import('./index')['FaSelect']
+1
View File
@@ -31,6 +31,7 @@ export { default as FaPagination } from './src/pagination/index.vue'
export { default as FaPasswordStrength } from './src/password-strength/index.vue'
export { default as FaPopover } from './src/popover/index.vue'
export { default as FaProgress } from './src/progress/index.vue'
export { default as FaRadioGroup } from './src/radio-group/index.vue'
export { default as FaScrollArea } from './src/scroll-area/index.vue'
export { default as FaSearchBar } from './src/search-bar/index.vue'
export { default as FaSelect } from './src/select/index.vue'
+1
View File
@@ -34,6 +34,7 @@ const COMPONENT_NAMES = [
'FaPasswordStrength',
'FaPopover',
'FaProgress',
'FaRadioGroup',
'FaScrollArea',
'FaSearchBar',
'FaSelect',
@@ -0,0 +1,226 @@
# FaRadioGroup 单选组组件
基于选项数组的单选组件,支持描述文案、自定义布局样式和选项插槽扩展。
## 使用场景
- 表单中的单选配置项
- 布局模式切换
- 套餐、规格、主题方案选择
- 带说明文案的单选列表
- 需要自定义选项卡片样式的单选交互
## Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `options` | `RadioGroupOption[]` | **必需** | 单选组选项数据 |
| `disabled` | `boolean` | `false` | 是否整体禁用 |
| `orientation` | `'horizontal' \| 'vertical'` | `'vertical'` | 排列方向 |
| `dir` | `'ltr' \| 'rtl'` | - | 文本方向 |
| `class` | `HTMLAttributes['class']` | - | 单选组容器类名 |
| `optionClass` | `HTMLAttributes['class']` | - | 每个选项外层容器类名 |
| `itemClass` | `HTMLAttributes['class']` | - | `RadioGroupItem` 类名 |
### RadioGroupOption 接口
```ts
interface RadioGroupOption {
label: string
value: AcceptableValue
description?: string
disabled?: boolean
id?: string
}
```
## Slots
| 名称 | 说明 |
|------|------|
| `option` | 自定义选项内容,slot props: `{ option, checked, disabled, id }` |
## Events
| 事件名 | 说明 | 回调参数 |
|--------|------|----------|
| `change` | 选中值变化时触发 | `value: AcceptableValue \| undefined` |
通过 `v-model` 实现双向绑定。
## 示例
### 基础用法
```vue
<script setup lang="ts">
const value = ref('comfortable')
const options = [
{ label: '默认', value: 'default' },
{ label: '舒适', value: 'comfortable' },
{ label: '紧凑', value: 'compact' },
]
</script>
<template>
<FaRadioGroup v-model="value" :options="options" />
</template>
```
### 带描述文案
```vue
<script setup lang="ts">
const value = ref('growth')
const options = [
{
label: '创业版',
value: 'starter',
description: '适合 1-10 人小团队,保留核心能力。',
},
{
label: '成长版',
value: 'growth',
description: '适合多角色协作,支持审批与审计流程。',
},
{
label: '企业版',
value: 'enterprise',
description: '高级安全策略与 SSO 即将开放。',
},
]
</script>
<template>
<FaRadioGroup v-model="value" :options="options" />
</template>
```
### 禁用状态
```vue
<script setup lang="ts">
const value = ref('growth')
const options = [
{ label: '标准版', value: 'standard' },
{ label: '专业版', value: 'pro' },
{ label: '企业版', value: 'enterprise', disabled: true },
]
</script>
<template>
<FaRadioGroup v-model="value" :options="options" />
</template>
```
### 横向排列
```vue
<script setup lang="ts">
const value = ref('balanced')
const options = [
{ label: '专注模式', value: 'focus' },
{ label: '平衡模式', value: 'balanced' },
{ label: '高密度模式', value: 'dense' },
]
</script>
<template>
<FaRadioGroup
v-model="value"
orientation="horizontal"
:options="options"
class="gap-4"
/>
</template>
```
### 自定义选项内容
```vue
<script setup lang="ts">
const value = ref('balanced')
const options = [
{
label: '专注模式',
value: 'focus',
description: '突出主任务,弱化辅助信息。',
},
{
label: '平衡模式',
value: 'balanced',
description: '信息密度与可读性保持平衡。',
},
{
label: '高密度模式',
value: 'dense',
description: '适合大屏运营与监控看板。',
},
]
</script>
<template>
<FaRadioGroup
v-model="value"
orientation="horizontal"
:options="options"
class="gap-4"
option-class="w-full sm:w-[260px]"
>
<template #option="{ option, checked }">
<div
:class="[
'flex w-full items-start justify-between gap-3 rounded-xl border px-4 py-3',
checked ? 'border-primary bg-primary/5' : 'border-border',
]"
>
<div class="min-w-0">
<div class="truncate font-medium">{{ option.label }}</div>
<div v-if="option.description" class="text-xs text-muted-foreground leading-5">
{{ option.description }}
</div>
</div>
<span class="text-xs" :class="checked ? 'text-primary' : 'text-muted-foreground'">
{{ checked ? '已选中' : '可选择' }}
</span>
</div>
</template>
</FaRadioGroup>
</template>
```
### 监听变化事件
```vue
<script setup lang="ts">
const value = ref('default')
const options = [
{ label: '默认', value: 'default' },
{ label: '舒适', value: 'comfortable' },
{ label: '紧凑', value: 'compact' },
]
function handleChange(value) {
console.log('选中值变化:', value)
}
</script>
<template>
<FaRadioGroup v-model="value" :options="options" @change="handleChange" />
</template>
```
## 注意事项
1. **初始化值**:组件当前通过 `v-model` 管理选中值,不再提供 `defaultValue`。
2. **选项结构**:`options` 至少需要 `label` 和 `value` 两个字段。
3. **禁用优先级**:组件整体 `disabled` 为 `true` 时,会覆盖单项可用状态。
4. **描述文案布局**:存在 `description` 时,选项会自动切换为顶部对齐布局。
5. **自定义插槽**:使用 `option` 插槽后,默认 `RadioGroupItem` 会隐藏,需要由插槽内容自行承担完整视觉表现。
6. **点击区域**:默认选项内容包裹在 `Label` 中,点击文字区域也能切换选中状态。
@@ -0,0 +1,131 @@
<script setup lang="ts">
import type { AcceptableValue, RadioGroupRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '../../utils'
import { Label } from '../label/label'
import { RadioGroup, RadioGroupItem } from './radio-group'
interface RadioGroupOption {
label: string
value: AcceptableValue
description?: string
disabled?: boolean
id?: string
}
defineOptions({
name: 'FaRadioGroup',
})
const props = withDefaults(defineProps<{
options: RadioGroupOption[]
disabled?: RadioGroupRootProps['disabled']
orientation?: RadioGroupRootProps['orientation']
dir?: RadioGroupRootProps['dir']
class?: HTMLAttributes['class']
optionClass?: HTMLAttributes['class']
itemClass?: HTMLAttributes['class']
}>(), {
orientation: 'vertical',
})
const emit = defineEmits<{
change: [value: AcceptableValue | undefined]
}>()
const slots = defineSlots<{
option?: (props: {
option: RadioGroupOption
checked: boolean
disabled: boolean
id: string
}) => any
}>()
const value = defineModel<AcceptableValue | undefined>()
const baseId = useId()
watch(value, (newValue) => {
emit('change', newValue)
})
function getOptionId(option: RadioGroupOption, index: number) {
return option.id || `${baseId}-${index}`
}
function getOptionKey(option: RadioGroupOption, index: number) {
if (option.id) {
return option.id
}
return typeof option.value === 'string' || typeof option.value === 'number'
? option.value
: index
}
function isOptionDisabled(option: RadioGroupOption) {
return Boolean(props.disabled || option.disabled)
}
function isOptionChecked(option: RadioGroupOption) {
return Object.is(value.value, option.value)
}
</script>
<template>
<RadioGroup
v-model="value"
:disabled="disabled"
:orientation="orientation"
:dir="dir"
:class="props.class"
>
<div
v-for="(option, index) in props.options"
:key="getOptionKey(option, index)"
:class="
cn(
'flex gap-3',
option.description ? 'items-start' : 'items-center',
props.optionClass,
)
"
>
<RadioGroupItem
v-show="!slots.option"
:id="getOptionId(option, index)"
:value="option.value"
:disabled="isOptionDisabled(option)"
:class="props.itemClass"
/>
<Label
:for="getOptionId(option, index)"
:class="
cn(
'min-w-0 flex-1 cursor-pointer gap-0',
option.description ? 'items-start' : 'items-center',
isOptionDisabled(option) && 'cursor-not-allowed opacity-60',
)
"
>
<slot
:id="getOptionId(option, index)"
name="option"
:option="option"
:checked="isOptionChecked(option)"
:disabled="isOptionDisabled(option)"
>
<div class="gap-1 grid min-w-0">
<span class="truncate">{{ option.label }}</span>
<span
v-if="option.description"
class="text-xs text-muted-foreground leading-5 font-normal"
>
{{ option.description }}
</span>
</div>
</slot>
</Label>
</div>
</RadioGroup>
</template>
@@ -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 '@/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-3', props.class)"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</RadioGroupRoot>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { RadioGroupItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CircleIcon } from 'lucide-vue-next'
import {
RadioGroupIndicator,
RadioGroupItem,
useForwardProps,
} from 'reka-ui'
import { cn } from '@/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 text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
>
<RadioGroupIndicator
data-slot="radio-group-indicator"
class="flex items-center justify-center relative"
>
<slot>
<CircleIcon class="size-2 left-1/2 top-1/2 absolute fill-primary -translate-x-1/2 -translate-y-1/2" />
</slot>
</RadioGroupIndicator>
</RadioGroupItem>
</template>
@@ -0,0 +1,2 @@
export { default as RadioGroup } from './RadioGroup.vue'
export { default as RadioGroupItem } from './RadioGroupItem.vue'