33 KiB
MalioRadioGroup Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Introduce a MalioRadioGroup parent component that owns the value, shared name, and a single field message (with reserved space, like MalioSelect), so a radio group aligns visually with a select; turn MalioRadioButton into a group-aware input.
Architecture: Mirror the existing Accordion/AccordionItem precedent — a context.ts injection key, the group provides context, the radio injects it. In a group, the radio inherits name/checked/error-styling/disabled and renders no message; standalone radios are unchanged. The group's message markup is copied verbatim from Select.vue so the two align automatically.
Tech Stack: Nuxt 4 layer, Vue 3 <script setup lang="ts">, Tailwind (palette m-*), tailwind-merge, Vitest + @vue/test-utils (jsdom).
Global Constraints
defineOptions({ name: 'MalioXxx', inheritAttrs: false })at top of every component.- Classes merged with
twMerge(); expose*Classprops for consumer overrides. - Accessibility:
role="radiogroup",aria-labelledby,aria-invalid,aria-describedby, labels tied byfor/id. - Components live in
app/components/malio/<dir>/(auto-imported as<MalioXxx>). - Tests colocated as
ComponentName.test.ts; helpermount(...)per existing patterns. - Conventional Commits. The pre-commit hook is flaky (intermittent test timeouts) — if a commit fails on a hook timeout unrelated to your change, retry; if it persists, commit with
--no-verifyand note it. - Branch:
feature/radio-group(already created and checked out). - Stage files explicitly (never
git add -A); never stagenuxt.config.ts.
Task 1: context.ts + group-aware RadioButton
Files:
- Create:
app/components/malio/radio/context.ts - Modify:
app/components/malio/radio/RadioButton.vue(full replacement of script + template + style below) - Test:
app/components/malio/radio/RadioButton.test.ts(add group tests; existing tests must stay green)
Interfaces:
-
Produces
context.ts:type RadioValue = string | number | boolean | null | undefinedinterface RadioGroupContext { name: ComputedRef<string>; isSelected: (v: RadioValue) => boolean; select: (v: RadioValue) => void; hasError: ComputedRef<boolean>; hasSuccess: ComputedRef<boolean>; disabled: ComputedRef<boolean>; readonly: ComputedRef<boolean>; required: ComputedRef<boolean>; describedBy: ComputedRef<string | undefined> }const radioGroupContextKey: InjectionKey<RadioGroupContext>
-
Produces (RadioButton): when a
RadioGroupContextis injected, the radio usesctx.name,ctx.isSelected(value),ctx.hasError/hasSuccess/disabled/readonly/required,ctx.describedBy, and callsctx.select(value)on change; it renders no.radio-message. -
Consumed by Task 2 (
RadioGroup.vueprovides this context). -
Step 1: Write the failing test (group behavior)
Append to app/components/malio/radio/RadioButton.test.ts:
import {computed, ref} from 'vue'
import {vi} from 'vitest'
import {radioGroupContextKey, type RadioGroupContext, type RadioValue} from './context'
const makeGroupCtx = (over: Partial<{
selected: RadioValue; error: boolean; success: boolean
disabled: boolean; readonly: boolean; required: boolean
}> = {}) => {
const selected = ref<RadioValue>(over.selected ?? null)
const select = vi.fn((v: RadioValue) => { selected.value = v })
const ctx: RadioGroupContext = {
name: computed(() => 'grp'),
isSelected: (v) => selected.value === v,
select,
hasError: computed(() => !!over.error),
hasSuccess: computed(() => !!over.success),
disabled: computed(() => !!over.disabled),
readonly: computed(() => !!over.readonly),
required: computed(() => !!over.required),
describedBy: computed(() => 'grp-describedby'),
}
return {ctx, select, selected}
}
const mountInGroup = (props: RadioButtonProps, ctx: RadioGroupContext) =>
mount(RadioButtonForTest, {
props,
global: {provide: {[radioGroupContextKey as symbol]: ctx}},
})
describe('MalioRadioButton dans un groupe', () => {
it('hérite du name du groupe', () => {
const {ctx} = makeGroupCtx()
const wrapper = mountInGroup({value: 'a', label: 'A'}, ctx)
expect(wrapper.get('input').attributes('name')).toBe('grp')
})
it('coché selon isSelected du groupe', () => {
const {ctx} = makeGroupCtx({selected: 'a'})
const wrapper = mountInGroup({value: 'a', label: 'A'}, ctx)
expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(true)
})
it('appelle ctx.select au change au lieu d\'émettre', async () => {
const {ctx, select} = makeGroupCtx()
const wrapper = mountInGroup({value: 'b', label: 'B'}, ctx)
await wrapper.get('input').trigger('change')
expect(select).toHaveBeenCalledWith('b')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('reflète l\'erreur du groupe et ne rend aucun message propre', () => {
const {ctx} = makeGroupCtx({error: true})
const wrapper = mountInGroup({value: 'a', label: 'A', hint: 'ignoré'}, ctx)
expect(wrapper.get('.radio-control').classes()).toContain('is-error')
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
expect(wrapper.find('.radio-message').exists()).toBe(false)
expect(wrapper.get('input').attributes('aria-describedby')).toBe('grp-describedby')
})
it('hérite de disabled/required du groupe', () => {
const {ctx} = makeGroupCtx({disabled: true, required: true})
const wrapper = mountInGroup({value: 'a', label: 'A'}, ctx)
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
expect(wrapper.get('input').attributes('required')).toBeDefined()
})
})
- Step 2: Run the new tests to verify they fail
Run: npm run test -- RadioButton
Expected: FAIL — ./context cannot be resolved (module missing).
- Step 3: Create
context.ts
app/components/malio/radio/context.ts:
import type {ComputedRef, InjectionKey} from 'vue'
export type RadioValue = string | number | boolean | null | undefined
export interface RadioGroupContext {
name: ComputedRef<string>
isSelected: (value: RadioValue) => boolean
select: (value: RadioValue) => void
hasError: ComputedRef<boolean>
hasSuccess: ComputedRef<boolean>
disabled: ComputedRef<boolean>
readonly: ComputedRef<boolean>
required: ComputedRef<boolean>
describedBy: ComputedRef<string | undefined>
}
export const radioGroupContextKey: InjectionKey<RadioGroupContext> = Symbol('MalioRadioGroup')
- Step 4: Replace
RadioButton.vuewith the group-aware version
app/components/malio/radio/RadioButton.vue (full file):
<template>
<div :class="mergedGroupClass">
<div :class="mergedControlClass">
<label :for="inputId" :class="indicatorClass">
<input
:id="inputId"
:name="resolvedName"
:value="value"
:checked="isChecked"
:required="resolvedRequired"
:disabled="resolvedDisabled"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:class="mergedInputClass"
v-bind="attrs"
type="radio"
@click="onClick"
@change="onChange"
>
<span class="radio-dot pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-black opacity-0">
<svg viewBox="0 0 16 16" fill="currentColor" class="h-[10px]">
<circle cx="8" cy="8" r="8" />
</svg>
</span>
</label>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="resolvedRequired" />
</label>
</div>
<p
v-if="shouldShowMessage"
:id="`${inputId}-describedby`"
:class="mergedMessageClass"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, inject, ref, useAttrs, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {radioGroupContextKey, type RadioValue} from './context'
defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
label?: string
name?: string
modelValue?: RadioValue
value?: RadioValue
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
}>(),
{
id: '',
label: '',
name: '',
modelValue: undefined,
value: undefined,
inputClass: '',
labelClass: '',
groupClass: '',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
},
)
const attrs = useAttrs()
const generatedId = useId()
const localValue = ref<RadioValue>(undefined)
const group = inject(radioGroupContextKey, null)
const inputId = computed(() => props.id?.toString() || `malio-radio-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const resolvedName = computed(() => (group ? group.name.value : props.name))
const resolvedDisabled = computed(() => props.disabled || (group?.disabled.value ?? false))
const resolvedReadonly = computed(() => props.readonly || (group?.readonly.value ?? false))
const resolvedRequired = computed(() => props.required || (group?.required.value ?? false))
const isChecked = computed(() => {
if (group) return group.isSelected(props.value)
return isControlled.value ? props.modelValue === props.value : localValue.value === props.value
})
const hasError = computed(() => (group ? group.hasError.value : !!props.error))
const hasSuccess = computed(() =>
group ? group.hasSuccess.value : !!props.success && !hasError.value,
)
const shouldShowMessage = computed(
() => !group && !!(props.hint || hasError.value || hasSuccess.value),
)
const describedBy = computed(() => {
if (group) return group.describedBy.value
if (!shouldShowMessage.value) return undefined
return `${inputId.value}-describedby`
})
const mergedGroupClass = computed(() =>
twMerge(group ? 'radio-item w-auto' : 'radio-item mt-4 w-full', props.groupClass),
)
const indicatorClass = computed(() =>
twMerge(
'radio-indicator relative flex cursor-pointer items-center',
group ? 'px-3 py-2.5' : 'p-3',
),
)
const mergedControlClass = computed(() =>
twMerge(
'radio-control flex items-center',
hasError.value ? 'is-error' : '',
hasSuccess.value ? 'is-success' : '',
resolvedDisabled.value ? 'is-disabled' : '',
),
)
const mergedInputClass = computed(() =>
twMerge(
'h-5 w-5 cursor-pointer appearance-none rounded-full border-2 border-m-muted checked:border-black',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'radio-text mt-px cursor-pointer',
isChecked.value ? 'text-black' : 'text-m-muted',
hasError.value ? 'text-m-danger' : '',
hasSuccess.value ? 'text-m-success' : '',
resolvedDisabled.value ? 'cursor-not-allowed text-black/60' : '',
props.labelClass,
),
)
const mergedMessageClass = computed(() =>
twMerge(
'radio-message ml-3 -mt-1 text-xs',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: 'text-m-muted',
),
)
const emit = defineEmits<{
(event: 'update:modelValue', value: RadioValue): void
}>()
const onClick = (event: MouseEvent) => {
if (!resolvedReadonly.value) return
event.preventDefault()
}
const onChange = (event: Event) => {
if (resolvedReadonly.value) {
const target = event.target as HTMLInputElement
target.checked = isChecked.value
return
}
if (group) {
group.select(props.value)
return
}
if (!isControlled.value) {
localValue.value = props.value
}
emit('update:modelValue', props.value)
}
</script>
<style scoped>
.radio-control input[type='radio']:checked + .radio-dot {
opacity: 1;
}
.radio-control input[type='radio']:focus-visible {
outline: 2px solid rgb(var(--m-primary) / 1);
outline-offset: 2px;
}
.radio-control.is-error input[type='radio'] {
border-color: rgb(var(--m-danger) / 1);
}
.radio-control.is-error .radio-dot {
color: rgb(var(--m-danger) / 1);
}
.radio-control.is-success input[type='radio'] {
border-color: rgb(var(--m-success) / 1);
}
.radio-control.is-success .radio-dot {
color: rgb(var(--m-success) / 1);
}
.radio-control.is-disabled .radio-indicator,
.radio-control.is-disabled .radio-text {
cursor: not-allowed;
opacity: 0.6;
}
</style>
Note: the old .radio-item:has(+ .radio-item) .radio-message { display: none } rule is intentionally removed — the group now owns the single message.
- Step 5: Run RadioButton tests (new + existing regression)
Run: npm run test -- RadioButton
Expected: PASS — all existing standalone tests plus the new "dans un groupe" suite.
(If a flaky timeout occurs, re-run once.)
- Step 6: Lint
Run: npm run lint -- app/components/malio/radio
Expected: no errors.
- Step 7: Commit
git add app/components/malio/radio/context.ts app/components/malio/radio/RadioButton.vue app/components/malio/radio/RadioButton.test.ts
git commit -m "feat(radio): contexte de groupe injectable pour RadioButton"
Task 2: RadioGroup.vue
Files:
- Create:
app/components/malio/radio/RadioGroup.vue - Test:
app/components/malio/radio/RadioGroup.test.ts
Interfaces:
-
Consumes
radioGroupContextKey,RadioValuefrom./context(Task 1) andMalioRadioButton(Task 1). -
Produces
<MalioRadioGroup>with props:modelValue?: RadioValue,options?: {label: string; value: RadioValue; disabled?: boolean}[],label?: string,name?: string,inline?: boolean,disabled?/readonly?/required?: boolean,hint?/error?/success?: string,reserveMessageSpace?: boolean(defaulttrue),groupClass?/inputClass?/labelClass?: string. Emitsupdate:modelValue(value: RadioValue). Default slot renders extra<MalioRadioButton>s. -
Step 1: Write the failing test
app/components/malio/radio/RadioGroup.test.ts:
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import RadioGroup from './RadioGroup.vue'
import RadioButton from './RadioButton.vue'
type Opt = {label: string; value: string; disabled?: boolean}
type RadioGroupProps = {
modelValue?: string | number | boolean | null
options?: Opt[]
label?: string
name?: string
inline?: boolean
disabled?: boolean
readonly?: boolean
required?: boolean
hint?: string
error?: string
success?: string
reserveMessageSpace?: boolean
groupClass?: string
inputClass?: string
labelClass?: string
}
const RadioGroupForTest = RadioGroup as DefineComponent<RadioGroupProps>
const options: Opt[] = [
{label: 'Oui', value: 'oui'},
{label: 'Non', value: 'non'},
]
const mountGroup = (props: RadioGroupProps = {}) =>
mount(RadioGroupForTest, {props: {options, ...props}})
describe('MalioRadioGroup', () => {
it('rend une option par entrée et un seul role=radiogroup', () => {
const wrapper = mountGroup()
expect(wrapper.findAll('input[type="radio"]')).toHaveLength(2)
expect(wrapper.findAll('[role="radiogroup"]')).toHaveLength(1)
})
it('partage le même name natif entre les radios', () => {
const wrapper = mountGroup({name: 'prestation'})
const names = wrapper.findAll('input').map(i => i.attributes('name'))
expect(names).toEqual(['prestation', 'prestation'])
})
it('coche selon modelValue', () => {
const wrapper = mountGroup({modelValue: 'non'})
const inputs = wrapper.findAll('input')
expect((inputs[1].element as HTMLInputElement).checked).toBe(true)
expect((inputs[0].element as HTMLInputElement).checked).toBe(false)
})
it('émet update:modelValue au clic sur une option', async () => {
const wrapper = mountGroup()
await wrapper.findAll('input')[1].trigger('change')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['non'])
})
it('affiche UN seul message d\'erreur réservant l\'espace', () => {
const wrapper = mountGroup({error: 'Sélection requise'})
const msgs = wrapper.findAll('[id$="-describedby"]')
expect(msgs).toHaveLength(1)
expect(msgs[0].text()).toBe('Sélection requise')
expect(msgs[0].classes()).toContain('min-h-[1rem]')
expect(msgs[0].classes()).toContain('text-m-danger')
expect(wrapper.find('.radio-message').exists()).toBe(false)
})
it('propage l\'erreur aux radios enfants', () => {
const wrapper = mountGroup({error: 'Sélection requise'})
expect(wrapper.findAll('.radio-control.is-error')).toHaveLength(2)
expect(wrapper.findAll('input').every(i => i.attributes('aria-invalid') === 'true')).toBe(true)
})
it('reserveMessageSpace=false sans message : aucune ligne réservée', () => {
const wrapper = mountGroup({reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('rend la legend et la lie via aria-labelledby', () => {
const wrapper = mountGroup({label: 'Prestation'})
const legendId = wrapper.find('[id$="-label"]').attributes('id')
expect(wrapper.get('[id$="-label"]').text()).toContain('Prestation')
expect(wrapper.get('[role="radiogroup"]').attributes('aria-labelledby')).toBe(legendId)
})
it('inline : la zone radios réserve la hauteur d\'un champ', () => {
const wrapper = mountGroup({inline: true})
expect(wrapper.get('[role="radiogroup"]').classes()).toContain('min-h-[2.5rem]')
})
it('accepte des radios via le slot par défaut', () => {
const wrapper = mount(RadioGroupForTest, {
props: {modelValue: 'b'},
slots: {
default: () => [
h(RadioButton, {value: 'a', label: 'A'}),
h(RadioButton, {value: 'b', label: 'B'}),
],
},
})
expect(wrapper.findAll('input')).toHaveLength(2)
expect((wrapper.findAll('input')[1].element as HTMLInputElement).checked).toBe(true)
})
})
Add import {h} from 'vue' at the top of the test file (used by the slot test).
- Step 2: Run the test to verify it fails
Run: npm run test -- RadioGroup
Expected: FAIL — cannot resolve ./RadioGroup.vue.
- Step 3: Create
RadioGroup.vue
app/components/malio/radio/RadioGroup.vue:
<template>
<div :class="mergedGroupClass">
<span
v-if="label"
:id="`${groupId}-label`"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
</span>
<div
role="radiogroup"
:aria-labelledby="label ? `${groupId}-label` : undefined"
:aria-invalid="hasError || undefined"
:aria-describedby="describedBy"
:class="contentClass"
>
<MalioRadioButton
v-for="(option, index) in options"
:key="`${groupId}-opt-${index}`"
:label="option.label"
:value="option.value"
:disabled="option.disabled"
:input-class="inputClass"
/>
<slot />
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${groupId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 ml-[2px] text-xs',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, provide, ref, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
import MalioRadioButton from './RadioButton.vue'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {radioGroupContextKey, type RadioValue} from './context'
defineOptions({name: 'MalioRadioGroup', inheritAttrs: false})
interface RadioOption {
label: string
value: RadioValue
disabled?: boolean
}
const props = withDefaults(
defineProps<{
modelValue?: RadioValue
options?: RadioOption[]
label?: string
name?: string
inline?: boolean
disabled?: boolean
readonly?: boolean
required?: boolean
hint?: string
error?: string
success?: string
reserveMessageSpace?: boolean
groupClass?: string
inputClass?: string
labelClass?: string
}>(),
{
modelValue: undefined,
options: () => [],
label: '',
name: '',
inline: false,
disabled: false,
readonly: false,
required: false,
hint: '',
error: '',
success: '',
reserveMessageSpace: true,
groupClass: '',
inputClass: '',
labelClass: '',
},
)
const emit = defineEmits<{
(event: 'update:modelValue', value: RadioValue): void
}>()
const generatedId = useId()
const groupId = computed(() => props.name || `malio-radio-group-${generatedId}`)
const localValue = ref<RadioValue>(undefined)
const isControlled = computed(() => props.modelValue !== undefined)
const selectedValue = computed(() =>
isControlled.value ? props.modelValue : localValue.value,
)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const shouldShowMessage = computed(() => !!(props.hint || hasError.value || hasSuccess.value))
const describedBy = computed(() =>
props.reserveMessageSpace || shouldShowMessage.value
? `${groupId.value}-describedby`
: undefined,
)
const select = (value: RadioValue) => {
if (props.readonly || props.disabled) return
if (!isControlled.value) localValue.value = value
emit('update:modelValue', value)
}
provide(radioGroupContextKey, {
name: computed(() => groupId.value),
isSelected: (value: RadioValue) => selectedValue.value === value,
select,
hasError,
hasSuccess,
disabled: computed(() => props.disabled),
readonly: computed(() => props.readonly),
required: computed(() => props.required),
describedBy,
})
const contentClass = computed(() =>
props.inline
? 'flex flex-wrap items-center gap-x-6 min-h-[2.5rem]'
: 'flex flex-col gap-y-1',
)
const mergedGroupClass = computed(() => twMerge('w-full', props.groupClass))
const mergedLabelClass = computed(() =>
twMerge(
'mb-1 block text-sm text-m-text',
hasError.value ? 'text-m-danger' : '',
props.labelClass,
),
)
</script>
- Step 4: Run the test to verify it passes
Run: npm run test -- RadioGroup
Expected: PASS — all MalioRadioGroup tests.
- Step 5: Lint
Run: npm run lint -- app/components/malio/radio
Expected: no errors.
- Step 6: Commit
git add app/components/malio/radio/RadioGroup.vue app/components/malio/radio/RadioGroup.test.ts
git commit -m "feat(radio): composant MalioRadioGroup (message unique, reserveMessageSpace)"
Task 3: Histoire story + playground page + nav
Files:
- Create:
app/story/radio/RadioGroup.story.vue - Create:
.playground/pages/composant/radio/radioGroup.vue - Modify:
.playground/playground.nav.ts(add nav entry under "SÉLECTIONS")
Interfaces:
-
Consumes
<MalioRadioGroup>(Task 2, auto-imported in story/playground). -
Step 1: Create the Histoire story
app/story/radio/RadioGroup.story.vue:
<template>
<Story title="Input/RadioGroup">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Empilé</h2>
<MalioRadioGroup v-model="stacked" :options="options" label="Catégorie" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">En ligne</h2>
<MalioRadioGroup v-model="inline" :options="yesNo" inline label="Prestation" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioRadioGroup v-model="errored" :options="yesNo" inline error="Sélection requise" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioRadioGroup v-model="ok" :options="yesNo" inline success="Enregistré" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioRadioGroup v-model="disabled" :options="options" disabled label="Catégorie" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Requis + slot</h2>
<MalioRadioGroup v-model="slotted" required label="Civilité" inline>
<MalioRadioButton value="M" label="Monsieur" />
<MalioRadioButton value="Mme" label="Madame" />
</MalioRadioGroup>
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const options = [
{label: 'Catégorie 1', value: 'cat1'},
{label: 'Catégorie 2', value: 'cat2'},
{label: 'Catégorie 3', value: 'cat3'},
]
const yesNo = [
{label: 'Oui', value: 'oui'},
{label: 'Non', value: 'non'},
]
const stacked = ref<string | null>(null)
const inline = ref<string | null>('oui')
const errored = ref<string | null>(null)
const ok = ref<string | null>('oui')
const disabled = ref<string | null>('cat2')
const slotted = ref<string | null>(null)
</script>
- Step 2: Create the playground page
.playground/pages/composant/radio/radioGroup.vue:
<template>
<div class="space-y-8 p-8">
<section>
<h2 class="mb-4 text-xl font-bold">Aligné avec un select (en ligne, erreur)</h2>
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioRadioGroup v-model="prestation" :options="yesNo" inline error="Sélection requise" />
<MalioSelect
v-model="fournisseur"
label="Fournisseur"
error="Sélection requise"
:options="[
{label: 'Fournisseur 1', value: 'Fournisseur 1'},
{label: 'Fournisseur 2', value: 'Fournisseur 2'},
]"
/>
</div>
</section>
<section>
<h2 class="mb-4 text-xl font-bold">Empilé avec label</h2>
<MalioRadioGroup v-model="categorie" :options="categories" label="Catégorie" />
</section>
<section>
<h2 class="mb-4 text-xl font-bold">Slot custom + requis</h2>
<MalioRadioGroup v-model="civilite" inline required label="Civilité">
<MalioRadioButton value="M" label="Monsieur" />
<MalioRadioButton value="Mme" label="Madame" />
</MalioRadioGroup>
</section>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const yesNo = [
{label: 'Oui', value: 'oui'},
{label: 'Non', value: 'non'},
]
const categories = [
{label: 'Catégorie 1', value: 'cat1'},
{label: 'Catégorie 2', value: 'cat2'},
]
const prestation = ref<string | null>(null)
const fournisseur = ref<string>('')
const categorie = ref<string | null>(null)
const civilite = ref<string | null>(null)
</script>
- Step 3: Add the nav entry
In .playground/playground.nav.ts, inside the "SÉLECTIONS" group's items, add immediately after the Radio entry:
{label: 'Radio', to: '/composant/radio/radioButton'},
{label: 'Radio (groupe)', to: '/composant/radio/radioGroup'},
- Step 4: Lint
Run: npm run lint -- .playground/pages/composant/radio app/story/radio
Expected: no new errors on the created files (pre-existing warnings elsewhere are fine).
- Step 5: Commit
git add app/story/radio/RadioGroup.story.vue .playground/pages/composant/radio/radioGroup.vue .playground/playground.nav.ts
git commit -m "docs(radio): story + page playground MalioRadioGroup"
Task 4: Use MalioRadioGroup in the client form (revert the hack)
Files:
- Modify:
.playground/pages/composant/form/client.vue
Interfaces:
-
Consumes
<MalioRadioGroup>(Task 2). -
Step 1: Replace the manual radio block
In .playground/pages/composant/form/client.vue, replace the whole <div class="prestation-field …">…</div> block (the radios wrapper + manual <p>) with:
<MalioRadioGroup
v-model="prestationChoice"
:options="prestationOptions"
inline
error="Sélection requise"
/>
-
Step 2: Remove the now-unused import and style
-
Delete the line
import MalioRadioButton from "../../../../app/components/malio/radio/RadioButton.vue";(no longer referenced —MalioRadioGroupis auto-imported). -
Delete the entire
<style scoped> … .prestation-field :deep(.radio-message) … </style>block at the end of the file. -
Keep
prestationChoiceandprestationOptionsrefs as-is. -
Step 3: Lint
Run: npm run lint -- .playground/pages/composant/form/client.vue
Expected: no new errors from this change.
- Step 4: Visual verification in the browser
Start a clean dev server on a dedicated port and confirm alignment (the original bug):
PORT=3010 npm run dev
Navigate to http://localhost:3010/composant/form/client. Using Chrome MCP evaluate_script, assert that on the "Prestation de triage" row:
- the radio circles' vertical center ≈ the
Fournisseurselect box vertical center (±2px); - the radio group message top ≈ the
Fournisseurerror message top (±2px).
Expected: both differences within 2px (was ~10px circle offset / ~6px message offset before).
Per project convention, do not open the browser MCP without the user's go-ahead — propose this verification step and wait for approval, or let the user run it. Tests + lint are the primary gate.
- Step 5: Commit
git add .playground/pages/composant/form/client.vue
git commit -m "feat(playground): formulaire client utilise MalioRadioGroup"
Task 5: Documentation (COMPONENTS.md + CHANGELOG.md)
Files:
-
Modify:
COMPONENTS.md(add a## MalioRadioGroupsection after theMalioRadioButtonsection, ending line ~500) -
Modify:
CHANGELOG.md(add anAddedentry) -
Step 1: Add the
MalioRadioGroupsection toCOMPONENTS.md
Insert immediately after the MalioRadioButton section's closing --- (currently line 500), before ## MalioDate:
## MalioRadioGroup
Groupe de boutons radio : possède la valeur, le `name` partagé et **un seul** message (erreur/succès/aide) avec espace réservé comme les autres champs — un groupe en ligne s'aligne donc avec un `MalioSelect` voisin. Les options sont déclarées via `:options` ou via le slot par défaut (`<MalioRadioButton>`).
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string \| number \| boolean \| null` | `undefined` | Valeur sélectionnée (v-model) |
| `options` | `{label, value, disabled?}[]` | `[]` | Options déclaratives |
| `label` | `string` | `''` | Label de groupe (legend, lié par `aria-labelledby`) |
| `name` | `string` | auto | Nom natif partagé des radios |
| `inline` | `boolean` | `false` | Disposition horizontale |
| `disabled` | `boolean` | `false` | Désactive tout le groupe |
| `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis (astérisque dans la legend) |
| `hint` / `error` / `success` | `string` | `''` | Message unique du groupe |
| `reserveMessageSpace` | `boolean` | `true` | Réserve la ligne de message (alignement) |
| `groupClass` / `inputClass` / `labelClass` | `string` | `''` | Overrides `twMerge` |
**Events :** `update:modelValue(value: string | number | boolean | null)`
**Accessibilité :** conteneur `role="radiogroup"`, `aria-labelledby` (si `label`), `aria-invalid` et `aria-describedby` sur le message unique. Les radios enfants héritent de l'état d'erreur/désactivé du groupe.
```vue
<MalioRadioGroup
v-model="prestation"
:options="[{label: 'Oui', value: 'oui'}, {label: 'Non', value: 'non'}]"
inline
error="Sélection requise"
/>
<MalioRadioGroup v-model="civilite" label="Civilité" inline>
<MalioRadioButton value="M" label="Monsieur" />
<MalioRadioButton value="Mme" label="Madame" />
</MalioRadioGroup>
- [ ] **Step 2: Add the `CHANGELOG.md` entry**
Under `## [0.0.0]` → `### Added`, append a new bullet at the end of the list:
```markdown
* [#MUI-radio-group] Création d'un composant radio group (message unique, alignement select)
(If the team uses a real issue id, substitute it for #MUI-radio-group.)
- Step 3: Commit
git add COMPONENTS.md CHANGELOG.md
git commit -m "docs(radio): documente MalioRadioGroup (COMPONENTS + CHANGELOG)"
Final verification
- Run the full radio suite + lint
Run: npm run test -- radio then npm run lint
Expected: radio tests green; lint reports no new errors from the created/modified files.
- Confirm the branch is clean except intended files
Run: git status
Expected: only intended changes committed; nuxt.config.ts remains unstaged/untouched.