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

6.5 KiB

MalioRadioGroup — conception

Problème

RadioButton.vue porte le message (error/success/hint) sur chaque radio et masque tous les messages sauf le dernier via un hack CSS (.radio-item:has(+ .radio-item) .radio-message { display: none }). Conséquences :

  • impossible d'aligner un groupe de radios sur un MalioSelect (cercles centrés sur la box, message sur la même ligne que le message du select) ;
  • pas de reserveMessageSpace alors que c'est la convention de tous les autres champs (Select, Input*, Date, Time, Checkbox) ;
  • l'affichage « un seul message » est un effet de bord CSS fragile.

Toutes les libs matures (MUI RadioGroup/FormHelperText, Vuetify v-radio-group, Element Plus el-radio-group, Ant Design Radio.Group) résolvent ça avec un parent de groupe qui possède la valeur, le name partagé et l'unique message. Le codebase a déjà ce précédent : Accordion + AccordionItem via provide/inject et un context.ts.

Décisions

  1. Nouveau composant parent MalioRadioGroup (les RadioButton deviennent des inputs simples). RadioButton reste utilisable seul.
  2. API enfants : prop :options (principal, cohérent avec MalioSelect) + slot par défaut en repli (cas custom).
  3. Label de groupe : prop label optionnelle, rendue au-dessus en <legend> (accessibilité). Omise → le groupe s'aligne directement avec un select.

Architecture

Calquée sur Accordion/AccordionItem.

app/components/malio/radio/context.ts (nouveau)

import type {InjectionKey} from 'vue'

export type RadioValue = string | number | boolean | null | undefined

export interface RadioGroupContext {
  name: ComputedRef<string>
  selectedValue: ComputedRef<RadioValue>
  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')

app/components/malio/radio/RadioGroup.vue (nouveau)

defineOptions({name: 'MalioRadioGroup', inheritAttrs: false}).

Props

prop type défaut rôle
modelValue RadioValue undefined valeur sélectionnée (contrôlé/non-contrôlé)
options {label, value, disabled?}[] [] radios déclaratifs
label string '' legend optionnelle au-dessus
name string auto (useId) name partagé des inputs
inline boolean false orientation horizontale
disabled / readonly / required boolean false propagés au groupe
hint / error / success string '' message unique
reserveMessageSpace boolean true réserve min-h-[1rem] (comme Select)
groupClass / inputClass / labelClass string '' overrides twMerge

Rendu

<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"
  >
    <!-- options -->
    <MalioRadioButton
      v-for="opt in options" :key="..."
      :label="opt.label" :value="opt.value" :disabled="opt.disabled"
    />
    <!-- ou slot custom -->
    <slot />
  </div>

  <p
    v-if="reserveMessageSpace || hasError || hasSuccess || hint"
    :id="`${groupId}-describedby`"
    :class="messageClass"  <!-- identique au Select : 'mt-1 ml-[2px] text-xs' (+min-h-[1rem]) -->
  >
    {{ error || success || hint }}
  </p>
</div>
  • contentClass : inlineflex flex-wrap items-center gap-x-6 min-h-10 ; empilé → flex flex-col. Le min-h-10 fait coïncider la rangée de radios avec la box d'un MalioSelect (h-10), donc les cercles se centrent sur la box du select.
  • messageClass reprend exactement le markup du message de Select.vue (mt-1 ml-[2px] text-xs, couleur text-m-danger/text-m-success/text-m-muted, min-h-[1rem] si reserveMessageSpace) → alignement automatique avec le message du select voisin.
  • v-model géré ici : select(value) émet update:modelValue (et tient localValue en non-contrôlé).
  • provide(radioGroupContextKey, …).

app/components/malio/radio/RadioButton.vue (modifié)

  • const group = inject(radioGroupContextKey, null).
  • Dans un groupe :
    • name = group.name ; isChecked = group.isSelected(value) ;
    • styling erreur/succès (cercles rouges/verts) piloté par group.hasError/hasSuccess ;
    • disabled/readonly/required = groupe OU prop locale ;
    • au changegroup.select(value) (au lieu d'emit('update:modelValue')) ;
    • n'affiche aucun message (shouldShowMessage = false) ;
    • aria-describedby = group.describedBy.
  • Hors groupe : comportement actuel inchangé.
  • Supprimer le CSS .radio-item:has(+ .radio-item) .radio-message { display: none } (remplacé par le vrai groupe).

Usage cible (remplace le hack du formulaire client)

<MalioRadioGroup
  v-model="prestationChoice"
  :options="prestationOptions"
  inline
  error="Sélection requise"
/>

Les cercles s'alignent sur la box du MalioSelect voisin et le message sur le message du select, sans réglage manuel.

Tests

  • RadioGroup.test.ts (nouveau) : rendu options + slot, v-model (contrôlé/non), message unique, reserveMessageSpace, état erreur propagé aux enfants (aria-invalid, classe is-error), inline, disabled/readonly/required, a11y (role=radiogroup, aria-labelledby, aria-describedby).
  • RadioButton.test.ts : ajuster — vérifier le mode groupé (inject) et conserver le mode standalone. Retirer le test du hack CSS s'il existe.

Documentation & playground

  • app/story/radio/RadioGroup.story.vue (Histoire).
  • Page playground : variantes du groupe + entrée nav (playground.nav.ts).
  • Revert client.vue vers MalioRadioGroup (retirer <style scoped> et le <p> manuel).
  • Mise à jour manuelle de COMPONENTS.md + CHANGELOG.md.

Hors périmètre (YAGNI)

  • Pas de groupe de checkbox (séparé).
  • Pas de validation/form-state global.
  • Pas de layout en grille interne au groupe (inline / empilé suffisent).