Files
malio-layer-ui/docs/superpowers/plans/2026-06-24-radio-group.md
T

1008 lines
33 KiB
Markdown

# 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 `provide`s context, the radio `inject`s 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 `*Class` props for consumer overrides.
- Accessibility: `role="radiogroup"`, `aria-labelledby`, `aria-invalid`, `aria-describedby`, labels tied by `for`/`id`.
- Components live in `app/components/malio/<dir>/` (auto-imported as `<MalioXxx>`).
- Tests colocated as `ComponentName.test.ts`; helper `mount(...)` 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-verify` and note it.
- Branch: `feature/radio-group` (already created and checked out).
- Stage files explicitly (never `git add -A`); never stage `nuxt.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 | undefined`
- `interface 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 `RadioGroupContext` is injected, the radio uses `ctx.name`, `ctx.isSelected(value)`, `ctx.hasError/hasSuccess/disabled/readonly/required`, `ctx.describedBy`, and calls `ctx.select(value)` on change; it renders **no** `.radio-message`.
- Consumed by Task 2 (`RadioGroup.vue` provides this context).
- [ ] **Step 1: Write the failing test (group behavior)**
Append to `app/components/malio/radio/RadioButton.test.ts`:
```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`:
```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.vue` with the group-aware version**
`app/components/malio/radio/RadioButton.vue` (full file):
```vue
<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**
```bash
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`, `RadioValue` from `./context` (Task 1) and `MalioRadioButton` (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` (default `true`), `groupClass?/inputClass?/labelClass?: string`. Emits `update:modelValue(value: RadioValue)`. Default slot renders extra `<MalioRadioButton>`s.
- [ ] **Step 1: Write the failing test**
`app/components/malio/radio/RadioGroup.test.ts`:
```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`:
```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**
```bash
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`:
```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`:
```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:
```ts
{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**
```bash
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:
```html
<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 — `MalioRadioGroup` is auto-imported).
- Delete the entire `<style scoped> … .prestation-field :deep(.radio-message) … </style>` block at the end of the file.
- Keep `prestationChoice` and `prestationOptions` refs 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):
```bash
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 `Fournisseur` select box vertical center (±2px);
- the radio group message top ≈ the `Fournisseur` error 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**
```bash
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 `## MalioRadioGroup` section after the `MalioRadioButton` section, ending line ~500)
- Modify: `CHANGELOG.md` (add an `Added` entry)
- [ ] **Step 1: Add the `MalioRadioGroup` section to `COMPONENTS.md`**
Insert immediately after the `MalioRadioButton` section's closing `---` (currently line 500), before `## MalioDate`:
```markdown
## 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**
```bash
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.