feat(radio) : contexte de groupe injectable pour RadioButton
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')
|
||||||
Reference in New Issue
Block a user