Skip to content

Commit 1a15037

Browse files
authored
feat(Radio): add new component (#92)
1 parent 76af2b3 commit 1a15037

File tree

14 files changed

+2163
-1
lines changed

14 files changed

+2163
-1
lines changed

apps/docs/build/generate-api.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,21 @@ export interface ApiProp {
5656
required: boolean
5757
default?: string
5858
description?: string
59+
example?: string
5960
}
6061

6162
export interface ApiEvent {
6263
name: string
6364
type: string
6465
description?: string
66+
example?: string
6567
}
6668

6769
export interface ApiSlot {
6870
name: string
6971
type?: string
7072
description?: string
73+
example?: string
7174
}
7275

7376
export interface ComponentApi {
@@ -140,6 +143,21 @@ function getChecker () {
140143
return checker
141144
}
142145

146+
/**
147+
* Extract example from vue-component-meta tags array
148+
*/
149+
function extractExampleTag (tags: { name: string, text?: string }[]): string | undefined {
150+
const exampleTag = tags.find(t => t.name === 'example')
151+
if (!exampleTag?.text) return undefined
152+
153+
let example = exampleTag.text.trim()
154+
// Remove markdown code fence if present
155+
if (example.startsWith('```')) {
156+
example = example.replace(/^```\w*\n?/, '').replace(/\n?```$/, '')
157+
}
158+
return example || undefined
159+
}
160+
143161
function extractComponentApi (filePath: string): ComponentApi | null {
144162
try {
145163
const meta = getChecker().getComponentMeta(filePath)
@@ -152,6 +170,7 @@ function extractComponentApi (filePath: string): ComponentApi | null {
152170
required: p.required,
153171
default: p.default,
154172
description: p.description,
173+
example: extractExampleTag(p.tags),
155174
}))
156175

157176
const events: ApiEvent[] = meta.events
@@ -160,12 +179,14 @@ function extractComponentApi (filePath: string): ComponentApi | null {
160179
name: e.name,
161180
type: e.type,
162181
description: e.description,
182+
example: extractExampleTag(e.tags),
163183
}))
164184

165185
const slots: ApiSlot[] = meta.slots.map(s => ({
166186
name: s.name,
167187
type: s.type,
168188
description: s.description,
189+
example: extractExampleTag(s.tags),
169190
}))
170191

171192
return {

apps/docs/build/shiki-api-transformer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const V0_COMPONENTS = new Set([
2828
'Group',
2929
'Pagination',
3030
'Popover',
31+
'Radio',
3132
'Selection',
3233
'Single',
3334
'Step',

apps/docs/src/composables/useApiHelpers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,12 @@ export function useApiHelpers (): UseApiHelpersReturn {
4444
expandedExamples.value = newSet
4545
if (code && !highlightedExamples[key]) {
4646
const hl = highlighter.value ?? await getHighlighter()
47+
// Detect language: Vue SFC if it starts with <template> or <script, otherwise TypeScript
48+
const isVue = /^<(?:template|script)/.test(code.trim())
4749
highlightedExamples[key] = {
4850
code,
4951
html: hl.codeToHtml(code, {
50-
lang: 'typescript',
52+
lang: isVue ? 'vue' : 'typescript',
5153
themes: SHIKI_THEMES,
5254
defaultColor: false,
5355
transformers: [createApiTransformer()],
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<script setup lang="ts">
2+
import { Radio } from '@vuetify/v0'
3+
import { ref } from 'vue'
4+
5+
const selected = ref<string>()
6+
7+
const options = [
8+
{ value: 'small', label: 'Small' },
9+
{ value: 'medium', label: 'Medium' },
10+
{ value: 'large', label: 'Large' },
11+
]
12+
</script>
13+
14+
<template>
15+
<Radio.Group v-model="selected" class="flex flex-col gap-2">
16+
<label
17+
v-for="option in options"
18+
:key="option.value"
19+
class="inline-flex items-center gap-2"
20+
>
21+
<Radio.Root
22+
class="size-5 border rounded-full inline-flex items-center justify-center border-divider data-[state=checked]:border-primary"
23+
:value="option.value"
24+
>
25+
<Radio.Indicator class="size-2.5 rounded-full bg-primary" />
26+
</Radio.Root>
27+
<span>{{ option.label }}</span>
28+
</label>
29+
</Radio.Group>
30+
31+
<p class="mt-4 text-sm text-on-surface-variant">
32+
Selected: {{ selected || 'none' }}
33+
</p>
34+
</template>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<script setup lang="ts">
2+
import { Radio } from '@vuetify/v0'
3+
import { ref } from 'vue'
4+
5+
const plan = ref<string>()
6+
7+
const plans = [
8+
{ value: 'free', label: 'Free', description: 'Basic features' },
9+
{ value: 'pro', label: 'Pro', description: 'Advanced features' },
10+
{ value: 'enterprise', label: 'Enterprise', description: 'Custom solutions' },
11+
]
12+
</script>
13+
14+
<template>
15+
<Radio.Group v-model="plan" class="flex flex-col gap-3" mandatory="force">
16+
<label
17+
v-for="item in plans"
18+
:key="item.value"
19+
class="flex items-start gap-3 p-3 border rounded-lg border-divider has-[:checked]:border-primary has-[:checked]:bg-primary/5 cursor-pointer"
20+
>
21+
<Radio.Root
22+
class="size-5 mt-0.5 border rounded-full inline-flex items-center justify-center border-divider data-[state=checked]:border-primary"
23+
:value="item.value"
24+
>
25+
<Radio.Indicator class="size-2.5 rounded-full bg-primary" />
26+
</Radio.Root>
27+
<div>
28+
<div class="font-medium">{{ item.label }}</div>
29+
<div class="text-sm text-on-surface-variant">{{ item.description }}</div>
30+
</div>
31+
</label>
32+
</Radio.Group>
33+
34+
<p class="mt-4 text-sm text-on-surface-variant">
35+
Selected plan: {{ plan }}
36+
</p>
37+
</template>
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
---
2+
title: Radio - Accessible Radio Button Controls
3+
meta:
4+
- name: description
5+
content: Headless radio button component for single-selection groups. Keyboard navigation, roving tabindex, and full ARIA compliance built-in.
6+
- name: keywords
7+
content: radio, radio button, form control, single-select, accessible, ARIA, Vue 3, headless
8+
features:
9+
category: Component
10+
label: 'E: Radio'
11+
level: 2
12+
github: /components/Radio/
13+
related:
14+
- /composables/selection/use-single
15+
- /components/providers/single
16+
- /components/forms/checkbox
17+
---
18+
19+
<script setup>
20+
import GroupExample from '@/examples/components/radio/group.vue'
21+
import GroupExampleRaw from '@/examples/components/radio/group.vue?raw'
22+
import MandatoryExample from '@/examples/components/radio/mandatory.vue'
23+
import MandatoryExampleRaw from '@/examples/components/radio/mandatory.vue?raw'
24+
</script>
25+
26+
# Radio
27+
28+
A headless radio button component for single-selection groups with keyboard navigation and roving tabindex.
29+
30+
<DocsPageFeatures :frontmatter />
31+
32+
## Usage
33+
34+
Radio buttons must be used within a `Radio.Group`. Use `v-model` on the group to bind the selected value:
35+
36+
<DocsExample file="group.vue" :code="GroupExampleRaw">
37+
<GroupExample />
38+
</DocsExample>
39+
40+
## Anatomy
41+
42+
```vue Anatomy playground
43+
<script setup lang="ts">
44+
import { Radio } from '@vuetify/v0'
45+
</script>
46+
47+
<template>
48+
<Radio.Group>
49+
<Radio.Root>
50+
<Radio.Indicator />
51+
</Radio.Root>
52+
53+
<Radio.Root>
54+
<Radio.Indicator />
55+
</Radio.Root>
56+
</Radio.Group>
57+
58+
<!-- With form submission -->
59+
<Radio.Group>
60+
<Radio.Root>
61+
<Radio.Indicator />
62+
63+
<Radio.HiddenInput />
64+
</Radio.Root>
65+
66+
<Radio.Root>
67+
<Radio.Indicator />
68+
69+
<Radio.HiddenInput />
70+
</Radio.Root>
71+
</Radio.Group>
72+
</template>
73+
```
74+
75+
## Auto-Select First Option
76+
77+
Radio groups are inherently mandatory—once a selection is made, it can only be changed, not cleared. Use `mandatory="force"` to automatically select the first non-disabled option on mount:
78+
79+
<DocsExample file="mandatory.vue" :code="MandatoryExampleRaw">
80+
<MandatoryExample />
81+
</DocsExample>
82+
83+
## Accessibility
84+
85+
The Radio components handle all ARIA attributes automatically:
86+
87+
- `role="radiogroup"` on the Group
88+
- `role="radio"` on each Root
89+
- `aria-checked` reflects checked state
90+
- `aria-disabled` when radio is disabled
91+
- `aria-required` for form validation (set on Group)
92+
- `aria-label` from the `label` prop
93+
- Roving `tabindex` - only the selected radio (or first if none) is tabbable
94+
- Space key selects the focused radio
95+
- Arrow keys navigate between radios
96+
97+
For custom implementations, use `renderless` mode and bind the `attrs` slot prop to your element:
98+
99+
```vue
100+
<template>
101+
<Radio.Root v-slot="{ attrs }" renderless>
102+
<div v-bind="attrs">
103+
<!-- Custom radio visual -->
104+
</div>
105+
</Radio.Root>
106+
</template>
107+
```
108+
109+
## Keyboard Navigation
110+
111+
Arrow keys provide circular navigation within a radio group:
112+
113+
| Key | Action |
114+
|-----|--------|
115+
| `Space` | Select focused radio |
116+
| `ArrowUp` / `ArrowLeft` | Move to previous radio |
117+
| `ArrowDown` / `ArrowRight` | Move to next radio |
118+
119+
Navigation automatically skips disabled items and wraps around.
120+
121+
## Form Integration
122+
123+
Set the `name` prop on `Radio.Group` to enable form submission for all radios in the group:
124+
125+
```vue
126+
<template>
127+
<Radio.Group v-model="selected" name="size">
128+
<Radio.Root value="small">
129+
<Radio.Indicator />
130+
Small
131+
</Radio.Root>
132+
133+
<Radio.Root value="large">
134+
<Radio.Indicator />
135+
Large
136+
</Radio.Root>
137+
</Radio.Group>
138+
</template>
139+
```
140+
141+
Each `Radio.Root` automatically renders a hidden native radio input with the shared `name` and its own `value`.
142+
143+
For custom form integration, use `Radio.HiddenInput` explicitly:
144+
145+
```vue
146+
<template>
147+
<Radio.Group>
148+
<Radio.Root value="a">
149+
<Radio.Indicator />
150+
151+
<Radio.HiddenInput name="custom" value="override" />
152+
</Radio.Root>
153+
</Radio.Group>
154+
</template>
155+
```
156+
157+
<DocsApi />

apps/docs/src/typed-router.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ declare module 'vue-router/auto-routes' {
8686
Record<never, never>,
8787
| never
8888
>,
89+
'/components/forms/radio': RouteRecordInfo<
90+
'/components/forms/radio',
91+
'/components/forms/radio',
92+
Record<never, never>,
93+
Record<never, never>,
94+
| never
95+
>,
8996
'/components/primitives/atom': RouteRecordInfo<
9097
'/components/primitives/atom',
9198
'/components/primitives/atom',
@@ -622,6 +629,12 @@ declare module 'vue-router/auto-routes' {
622629
views:
623630
| never
624631
}
632+
'src/pages/components/forms/radio.md': {
633+
routes:
634+
| '/components/forms/radio'
635+
views:
636+
| never
637+
}
625638
'src/pages/components/primitives/atom.md': {
626639
routes:
627640
| '/components/primitives/atom'

0 commit comments

Comments
 (0)