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
reserveMessageSpacealors 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
- Nouveau composant parent
MalioRadioGroup(lesRadioButtondeviennent des inputs simples).RadioButtonreste utilisable seul. - API enfants : prop
:options(principal, cohérent avecMalioSelect) + slot par défaut en repli (cas custom). - Label de groupe : prop
labeloptionnelle, 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:inline→flex flex-wrap items-center gap-x-6 min-h-10; empilé →flex flex-col. Lemin-h-10fait coïncider la rangée de radios avec la box d'unMalioSelect(h-10), donc les cercles se centrent sur la box du select.messageClassreprend exactement le markup du message deSelect.vue(mt-1 ml-[2px] text-xs, couleurtext-m-danger/text-m-success/text-m-muted,min-h-[1rem]sireserveMessageSpace) → alignement automatique avec le message du select voisin.v-modelgéré ici :select(value)émetupdate:modelValue(et tientlocalValueen 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)
<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, classeis-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.vueversMalioRadioGroup(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).