docs(radio): conception MalioRadioGroup (parent group + message unique)
This commit is contained in:
@@ -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 `<legend>`
|
||||||
|
(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<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**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<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` : `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
|
||||||
|
<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).
|
||||||
Reference in New Issue
Block a user