Compare commits

...

12 Commits

Author SHA1 Message Date
tristan d06458a2a9 feat(playground) : options Fond mouvant/Benne en space-between 2026-06-25 09:48:01 +02:00
tristan 66ad644728 feat(radio) : prop contentClass pour piloter la disposition des radios 2026-06-25 09:47:36 +02:00
tristan 1c36d40bfd feat(playground) : RadioGroup du formulaire client en required + erreur 2026-06-25 09:43:01 +02:00
tristan 1bc3c11444 docs(radio) : précise que labelClass du RadioGroup cible la legend 2026-06-24 16:47:08 +02:00
tristan 29c2bf48d3 docs(radio) : documente MalioRadioGroup (COMPONENTS + CHANGELOG)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:41:42 +02:00
tristan 8d7abc3406 fix(radio) : aligne la zone inline du RadioGroup sur le champ select (h-12) 2026-06-24 16:38:51 +02:00
tristan 06f9b0218a feat(playground) : formulaire client utilise MalioRadioGroup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 16:33:29 +02:00
tristan a24748f7b1 docs(radio) : story + page playground MalioRadioGroup 2026-06-24 16:30:05 +02:00
tristan 62b23c53b4 feat(radio) : composant MalioRadioGroup (message unique, reserveMessageSpace) 2026-06-24 16:25:57 +02:00
tristan 9207d7bb95 feat(radio) : contexte de groupe injectable pour RadioButton
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 16:21:34 +02:00
tristan 2da30a9138 docs(radio): plan d'implémentation MalioRadioGroup 2026-06-24 16:13:36 +02:00
tristan ccc470d809 docs(radio): conception MalioRadioGroup (parent group + message unique) 2026-06-24 16:07:24 +02:00
13 changed files with 1757 additions and 28 deletions
@@ -68,6 +68,24 @@
]"
/>
<MalioCheckbox label="Prestation de triage" groupClass="self-center"/>
<MalioRadioGroup
v-model="prestationChoice"
:options="prestationOptions"
inline
required
content-class="justify-between"
error="Sélection requise"
/>
<MalioSelect
v-model="fournisseur"
value=""
label="Fournisseur"
error="Sélection requise"
:options="[
{label: 'Fournisseur 1', value: 'Fournisseur 1'},
{label: 'Fournisseur 2', value: 'Fournisseur 2'},
]"
/>
</div>
<div class="mt-12 flex justify-center">
@@ -188,6 +206,12 @@ const distributeur = ref<string>('')
const phones = ref<string[]>([''])
const nomDistributeur = ref<string>('')
const nomCourtier = ref<string>('')
const fournisseur = ref<string>('')
const prestationChoice = ref<string | null>(null)
const prestationOptions = [
{label: 'Fond mouvant', value: 'fond-mouvant'},
{label: 'Benne', value: 'benne'},
]
function addPhoneInput() {
phones.value.push('')
@@ -0,0 +1,50 @@
<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>
+1
View File
@@ -45,6 +45,7 @@ export const navSections: SidebarSection[] = [
{label: 'Select Checkbox', to: '/composant/select/selectCheckbox'},
{label: 'Checkbox', to: '/composant/checkbox/checkbox'},
{label: 'Radio', to: '/composant/radio/radioButton'},
{label: 'Radio (groupe)', to: '/composant/radio/radioGroup'},
],
},
{
+1
View File
@@ -57,6 +57,7 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-44] MalioDate / MalioDateTime : event `update:rawValue` (string) exposant la saisie brute sur un canal séparé pour la validation back-autoritative — saisie invalide (non parsable ou hors `min`/`max`) → texte trimmé tel que tapé, saisie valide/vide + clear + sélection au calendrier → `''`. `modelValue` reste `string` ISO `| null` (la saisie invalide n'y transite jamais) ; le parent construit son payload via `valid ? modelValue : rawValue`.
* [#MUI-45] MalioDate : prop `markedDates` (`Record<"YYYY-MM-DD", 'success' | 'danger'>`) appliquant un fond tokenisé par jour dans la grille (générique, fourni par le consommateur ; précédence sélection/`today` > variante marquée > défaut) + event `month-change` (`{ month: 0-11, year }`) émis à l'ouverture du popover et à chaque navigation de mois. Sert l'écran *Heures* de SIRH (jours validés en vert, chargement du mois visible à la volée).
* Calendrier (Date/DateRange/DateTime/DateWeek) : sélecteur d'année (3ᵉ niveau de navigation — jours → mois → années) et grisage des mois et années hors `min`/`max`.
* [#MUI-radio-group] Création d'un composant radio group (message unique, alignement select)
### Changed
* Cohérence du mode **`disabled`** sur toute la famille formulaire (calqué sur InputText : texte + label grisés, `cursor-not-allowed`, aucune affordance interactive). Concrètement, quand `disabled` : le **bouton « + »** d'ajout disparaît (InputPhone, InputEmail), l'**œil** de révélation disparaît (InputPassword), le **chevron** disparaît (Select, SelectCheckbox, InputAutocomplete), la **croix d'effacement** reste masquée (date, upload, time), le **label** passe en `text-m-muted` (Select, SelectCheckbox, famille Date via CalendarField, TimePicker), et les **tags** du SelectCheckbox + la valeur du Select passent en gris. (InputText, InputAmount, InputNumber, InputTextArea, InputRichText, Checkbox, RadioButton, InputUpload étaient déjà conformes.)
+41
View File
@@ -499,6 +499,47 @@ Bouton radio (à utiliser en groupe avec le même `name`).
---
## 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` | `string` | `''` | Override `twMerge` du conteneur du groupe |
| `contentClass` | `string` | `''` | Override `twMerge` de la zone des radios (ex. `justify-between`) |
| `inputClass` | `string` | `''` | Override `twMerge` propagé à l'`input` de chaque radio |
| `labelClass` | `string` | `''` | Override `twMerge` du **label de groupe** (legend), pas des labels d'options |
**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>
```
---
## MalioDate
Sélecteur de date unique avec popover (grille de calendrier + vue mois/année).
+67 -1
View File
@@ -1,7 +1,9 @@
import {describe, expect, it} from 'vitest'
import {computed, ref} from 'vue'
import {describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import RadioButton from './RadioButton.vue'
import {radioGroupContextKey, type RadioGroupContext, type RadioValue} from './context'
type RadioButtonProps = {
id?: string
@@ -193,3 +195,67 @@ describe('MalioRadioButton', () => {
expect(wrapper.get('.radio-text').classes()).toContain('text-black')
})
})
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()
})
})
+47 -27
View File
@@ -1,15 +1,15 @@
<template>
<div :class="mergedGroupClass">
<div :class="mergedControlClass">
<label :for="inputId" class="radio-indicator relative flex cursor-pointer items-center p-3">
<label :for="inputId" :class="indicatorClass">
<input
:id="inputId"
:name="name"
:name="resolvedName"
:value="value"
:checked="isChecked"
:required="required"
:disabled="disabled"
:aria-invalid="!!error"
:required="resolvedRequired"
:disabled="resolvedDisabled"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:class="mergedInputClass"
v-bind="attrs"
@@ -29,7 +29,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
{{ label }}<MalioRequiredMark v-if="resolvedRequired" />
</label>
</div>
@@ -44,9 +44,10 @@
</template>
<script setup lang="ts">
import {computed, ref, useAttrs, useId} from 'vue'
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})
@@ -55,8 +56,8 @@ const props = withDefaults(
id?: string
label?: string
name?: string
modelValue?: string | number | boolean | null | undefined
value?: string | number | boolean | null | undefined
modelValue?: RadioValue
value?: RadioValue
inputClass?: string
labelClass?: string
groupClass?: string
@@ -87,27 +88,45 @@ const props = withDefaults(
const attrs = useAttrs()
const generatedId = useId()
const localValue = ref<string | number | boolean | null | undefined>(undefined)
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 isChecked = computed(() =>
isControlled.value ? props.modelValue === props.value : localValue.value === props.value,
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 hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const disabled = computed(() => props.disabled)
const shouldShowMessage = computed(() => !!(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-item mt-4 w-full',
props.groupClass,
'radio-indicator relative flex cursor-pointer items-center',
group ? 'px-3 py-2.5' : 'p-3',
),
)
@@ -116,7 +135,7 @@ const mergedControlClass = computed(() =>
'radio-control flex items-center',
hasError.value ? 'is-error' : '',
hasSuccess.value ? 'is-success' : '',
disabled.value ? 'is-disabled' : '',
resolvedDisabled.value ? 'is-disabled' : '',
),
)
@@ -133,7 +152,7 @@ const mergedLabelClass = computed(() =>
isChecked.value ? 'text-black' : 'text-m-muted',
hasError.value ? 'text-m-danger' : '',
hasSuccess.value ? 'text-m-success' : '',
disabled.value ? 'cursor-not-allowed text-black/60' : '',
resolvedDisabled.value ? 'cursor-not-allowed text-black/60' : '',
props.labelClass,
),
)
@@ -150,22 +169,27 @@ const mergedMessageClass = computed(() =>
)
const emit = defineEmits<{
(event: 'update:modelValue', value: string | number | boolean | null | undefined): void
(event: 'update:modelValue', value: RadioValue): void
}>()
const onClick = (event: MouseEvent) => {
if (!props.readonly) return
if (!resolvedReadonly.value) return
event.preventDefault()
}
const onChange = (event: Event) => {
if (props.readonly) {
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
}
@@ -205,8 +229,4 @@ const onChange = (event: Event) => {
cursor: not-allowed;
opacity: 0.6;
}
.radio-item:has(+ .radio-item) .radio-message {
display: none;
}
</style>
@@ -0,0 +1,114 @@
import {describe, expect, it} from 'vitest'
import {h} from 'vue'
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 (h-12 du select)', () => {
const wrapper = mountGroup({inline: true})
expect(wrapper.get('[role="radiogroup"]').classes()).toContain('min-h-[3rem]')
})
it('contentClass est fusionné sur la zone des radios', () => {
const wrapper = mountGroup({inline: true, contentClass: 'justify-between'})
expect(wrapper.get('[role="radiogroup"]').classes()).toContain('justify-between')
})
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)
})
})
+159
View File
@@ -0,0 +1,159 @@
<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="contentZoneClass"
>
<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
contentClass?: string
inputClass?: string
labelClass?: string
}>(),
{
modelValue: undefined,
options: () => [],
label: '',
name: '',
inline: false,
disabled: false,
readonly: false,
required: false,
hint: '',
error: '',
success: '',
reserveMessageSpace: true,
groupClass: '',
contentClass: '',
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 contentZoneClass = computed(() =>
twMerge(
props.inline
? 'flex flex-wrap items-center gap-x-6 min-h-[3rem]'
: 'flex flex-col gap-y-1',
props.contentClass,
),
)
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>
+17
View File
@@ -0,0 +1,17 @@
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')
+59
View File
@@ -0,0 +1,59 @@
<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>
File diff suppressed because it is too large Load Diff
@@ -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).