feat(radio) : contexte de groupe injectable pour RadioButton

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 16:21:34 +02:00
parent 2da30a9138
commit 9207d7bb95
3 changed files with 131 additions and 28 deletions
+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 {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue' import type {DefineComponent} from 'vue'
import RadioButton from './RadioButton.vue' import RadioButton from './RadioButton.vue'
import {radioGroupContextKey, type RadioGroupContext, type RadioValue} from './context'
type RadioButtonProps = { type RadioButtonProps = {
id?: string id?: string
@@ -193,3 +195,67 @@ describe('MalioRadioButton', () => {
expect(wrapper.get('.radio-text').classes()).toContain('text-black') 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> <template>
<div :class="mergedGroupClass"> <div :class="mergedGroupClass">
<div :class="mergedControlClass"> <div :class="mergedControlClass">
<label :for="inputId" class="radio-indicator relative flex cursor-pointer items-center p-3"> <label :for="inputId" :class="indicatorClass">
<input <input
:id="inputId" :id="inputId"
:name="name" :name="resolvedName"
:value="value" :value="value"
:checked="isChecked" :checked="isChecked"
:required="required" :required="resolvedRequired"
:disabled="disabled" :disabled="resolvedDisabled"
:aria-invalid="!!error" :aria-invalid="hasError"
:aria-describedby="describedBy" :aria-describedby="describedBy"
:class="mergedInputClass" :class="mergedInputClass"
v-bind="attrs" v-bind="attrs"
@@ -29,7 +29,7 @@
:for="inputId" :for="inputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }}<MalioRequiredMark v-if="required" /> {{ label }}<MalioRequiredMark v-if="resolvedRequired" />
</label> </label>
</div> </div>
@@ -44,9 +44,10 @@
</template> </template>
<script setup lang="ts"> <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 {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue' import MalioRequiredMark from '../shared/RequiredMark.vue'
import {radioGroupContextKey, type RadioValue} from './context'
defineOptions({name: 'MalioRadioButton', inheritAttrs: false}) defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
@@ -55,8 +56,8 @@ const props = withDefaults(
id?: string id?: string
label?: string label?: string
name?: string name?: string
modelValue?: string | number | boolean | null | undefined modelValue?: RadioValue
value?: string | number | boolean | null | undefined value?: RadioValue
inputClass?: string inputClass?: string
labelClass?: string labelClass?: string
groupClass?: string groupClass?: string
@@ -87,27 +88,45 @@ const props = withDefaults(
const attrs = useAttrs() const attrs = useAttrs()
const generatedId = useId() 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 inputId = computed(() => props.id?.toString() || `malio-radio-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined) 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(() => { const describedBy = computed(() => {
if (group) return group.describedBy.value
if (!shouldShowMessage.value) return undefined if (!shouldShowMessage.value) return undefined
return `${inputId.value}-describedby` return `${inputId.value}-describedby`
}) })
const mergedGroupClass = computed(() => const mergedGroupClass = computed(() =>
twMerge(group ? 'radio-item w-auto' : 'radio-item mt-4 w-full', props.groupClass),
)
const indicatorClass = computed(() =>
twMerge( twMerge(
'radio-item mt-4 w-full', 'radio-indicator relative flex cursor-pointer items-center',
props.groupClass, group ? 'px-3 py-2.5' : 'p-3',
), ),
) )
@@ -116,7 +135,7 @@ const mergedControlClass = computed(() =>
'radio-control flex items-center', 'radio-control flex items-center',
hasError.value ? 'is-error' : '', hasError.value ? 'is-error' : '',
hasSuccess.value ? 'is-success' : '', 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', isChecked.value ? 'text-black' : 'text-m-muted',
hasError.value ? 'text-m-danger' : '', hasError.value ? 'text-m-danger' : '',
hasSuccess.value ? 'text-m-success' : '', hasSuccess.value ? 'text-m-success' : '',
disabled.value ? 'cursor-not-allowed text-black/60' : '', resolvedDisabled.value ? 'cursor-not-allowed text-black/60' : '',
props.labelClass, props.labelClass,
), ),
) )
@@ -150,22 +169,27 @@ const mergedMessageClass = computed(() =>
) )
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'update:modelValue', value: string | number | boolean | null | undefined): void (event: 'update:modelValue', value: RadioValue): void
}>() }>()
const onClick = (event: MouseEvent) => { const onClick = (event: MouseEvent) => {
if (!props.readonly) return if (!resolvedReadonly.value) return
event.preventDefault() event.preventDefault()
} }
const onChange = (event: Event) => { const onChange = (event: Event) => {
if (props.readonly) { if (resolvedReadonly.value) {
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement
target.checked = isChecked.value target.checked = isChecked.value
return return
} }
if (group) {
group.select(props.value)
return
}
if (!isControlled.value) { if (!isControlled.value) {
localValue.value = props.value localValue.value = props.value
} }
@@ -205,8 +229,4 @@ const onChange = (event: Event) => {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.6; opacity: 0.6;
} }
.radio-item:has(+ .radio-item) .radio-message {
display: none;
}
</style> </style>
+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')