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

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 *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:

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.vue with 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, 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:

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 — 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):

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
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:

## 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.