From ccc470d8095340b68fa15e8ab1b706c2ef413b92 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 16:07:24 +0200 Subject: [PATCH] docs(radio): conception MalioRadioGroup (parent group + message unique) --- .../specs/2026-06-24-radio-group-design.md | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-radio-group-design.md diff --git a/docs/superpowers/specs/2026-06-24-radio-group-design.md b/docs/superpowers/specs/2026-06-24-radio-group-design.md new file mode 100644 index 0000000..681881b --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-radio-group-design.md @@ -0,0 +1,170 @@ +# 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 `` + (accessibilité). Omise → le groupe s'aligne directement avec un select. + +## Architecture + +Calquée sur `Accordion`/`AccordionItem`. + +### `app/components/malio/radio/context.ts` (nouveau) + +```ts +import type {InjectionKey} from 'vue' + +export type RadioValue = string | number | boolean | null | undefined + +export interface RadioGroupContext { + name: ComputedRef + selectedValue: ComputedRef + isSelected: (value: RadioValue) => boolean + select: (value: RadioValue) => void + hasError: ComputedRef + hasSuccess: ComputedRef + disabled: ComputedRef + readonly: ComputedRef + required: ComputedRef + describedBy: ComputedRef +} + +export const radioGroupContextKey: InjectionKey = + 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** + +```html +
+ + {{ label }} + + +
+ + + + +
+ +

+ > + {{ error || success || hint }} +

+
+``` + +- `contentClass` : `inline` → `flex 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 `change` → `group.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) + +```html + +``` + +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 `