diff --git a/packages/core/src/useCheckbox/useCheckbox.ts b/packages/core/src/useCheckbox/useCheckbox.ts index db5b2ee9..023b7a73 100644 --- a/packages/core/src/useCheckbox/useCheckbox.ts +++ b/packages/core/src/useCheckbox/useCheckbox.ts @@ -1,5 +1,5 @@ import { Ref, computed, inject, nextTick, ref, toValue } from 'vue'; -import { isEqual, normalizeProps, useUniqId, withRefCapture } from '../utils/common'; +import { isEqual, isInputElement, normalizeProps, useUniqId, withRefCapture } from '../utils/common'; import { AriaLabelableProps, Reactivify, InputBaseAttributes, RovingTabIndex, TypedSchema } from '../types'; import { useLabel } from '../a11y/useLabel'; import { CheckboxGroupContext, CheckboxGroupKey } from './useCheckboxGroup'; @@ -165,7 +165,7 @@ export function useCheckbox( }); const inputProps = computed(() => - withRefCapture(createBindings(inputRef.value?.tagName === 'INPUT'), inputRef, elementRef), + withRefCapture(createBindings(isInputElement(inputRef.value)), inputRef, elementRef), ); function setChecked(force?: boolean) { diff --git a/packages/core/src/useRadio/useRadio.spec.ts b/packages/core/src/useRadio/useRadio.spec.ts new file mode 100644 index 00000000..fe1ec7e1 --- /dev/null +++ b/packages/core/src/useRadio/useRadio.spec.ts @@ -0,0 +1,9 @@ +import { renderSetup } from '@test-utils/renderSetup'; +import { useRadio } from './useRadio'; + +test('warns if no radio group is present', async () => { + const warn = vi.spyOn(console, 'warn'); + await renderSetup(() => useRadio({ label: 'Radio', value: 'test' })); + expect(warn).toHaveBeenCalledOnce(); + warn.mockRestore(); +}); diff --git a/packages/core/src/useRadio/useRadio.ts b/packages/core/src/useRadio/useRadio.ts index 691f0d68..7de0b6bf 100644 --- a/packages/core/src/useRadio/useRadio.ts +++ b/packages/core/src/useRadio/useRadio.ts @@ -1,5 +1,5 @@ import { Ref, computed, inject, nextTick, ref, toValue } from 'vue'; -import { isEqual, normalizeProps, useUniqId, withRefCapture } from '../utils/common'; +import { isEqual, isInputElement, normalizeProps, useUniqId, warn, withRefCapture } from '../utils/common'; import { AriaInputProps, AriaLabelableProps, InputBaseAttributes, Reactivify, RovingTabIndex } from '../types'; import { useLabel } from '../a11y/useLabel'; import { RadioGroupContext, RadioGroupKey } from './useRadioGroup'; @@ -36,6 +36,12 @@ export function useRadio( targetRef: inputRef, }); + if (!group) { + warn( + 'A Radio component must be a part of a Radio Group. Make sure you have called useRadioGroup at a parent component', + ); + } + function createHandlers(isInput: boolean) { const baseHandlers = { onClick() { @@ -51,7 +57,7 @@ export function useRadio( return; } - if (e.code === 'Space') { + if (e.key === 'Space') { e.preventDefault(); group?.setValue(toValue(props.value) as TValue); group?.setTouched(true); @@ -88,7 +94,7 @@ export function useRadio( ...labelledByProps.value, ...createHandlers(isInput), id: inputId, - [isInput ? 'checked' : 'aria-checked']: checked.value || undefined, + [isInput ? 'checked' : 'aria-checked']: checked.value, [isInput ? 'readonly' : 'aria-readonly']: group?.readonly || undefined, [isInput ? 'disabled' : 'aria-disabled']: isDisabled() || undefined, [isInput ? 'required' : 'aria-required']: group?.required, @@ -124,7 +130,7 @@ export function useRadio( }); const inputProps = computed(() => - withRefCapture(createBindings(inputRef.value?.tagName === 'INPUT'), inputRef, elementRef), + withRefCapture(createBindings(isInputElement(inputRef.value)), inputRef, elementRef), ); return { diff --git a/packages/core/src/useRadio/useRadioGroup.spec.ts b/packages/core/src/useRadio/useRadioGroup.spec.ts new file mode 100644 index 00000000..58276afc --- /dev/null +++ b/packages/core/src/useRadio/useRadioGroup.spec.ts @@ -0,0 +1,372 @@ +import { RadioGroupProps, useRadioGroup } from './useRadioGroup'; +import { type Component, defineComponent } from 'vue'; +import { RadioProps, useRadio } from './useRadio'; +import { fireEvent, render, screen } from '@testing-library/vue'; +import { axe } from 'vitest-axe'; +import { describe } from 'vitest'; + +const createGroup = (props: RadioGroupProps): Component => { + return defineComponent({ + setup() { + const group = useRadioGroup(props); + + return { + ...props, + ...group, + }; + }, + template: ` +
+ {{ label }} + +
{{ errorMessage }}
+
{{ description }}
+
{{ fieldValue }}
+
+ `, + }); +}; + +const InputBase: string = ` +
+ + +
+`; + +const CustomBase: string = ` +
+
+
{{ label }}
+
+`; + +const createRadio = (template = InputBase): Component => { + return defineComponent({ + template, + inheritAttrs: false, + setup(props: RadioProps, { attrs }) { + const radio = useRadio({ ...props, ...attrs }); + + return { + ...props, + ...attrs, + ...radio, + }; + }, + }); +}; + +describe('has no a11y violations', () => { + test('with input as base element', async () => { + const RadioGroup = createGroup({ label: 'Group' }); + const RadioInput = createRadio(); + + await render({ + components: { RadioGroup, RadioInput }, + template: ` + + + + + `, + }); + + vi.useRealTimers(); + expect(await axe(screen.getByTestId('fixture'))).toHaveNoViolations(); + vi.useFakeTimers(); + }); + + test('with custom elements as base', async () => { + const RadioGroup = createGroup({ label: 'Group' }); + const RadioInput = createRadio(CustomBase); + + await render({ + components: { RadioGroup, RadioInput }, + template: ` + + + + + `, + }); + + vi.useRealTimers(); + expect(await axe(screen.getByTestId('fixture'))).toHaveNoViolations(); + vi.useFakeTimers(); + }); +}); + +describe('click behavior', () => { + test('with input as base element', async () => { + const RadioGroup = createGroup({ label: 'Group' }); + const RadioInput = createRadio(); + + await render({ + components: { RadioGroup, RadioInput }, + template: ` + + + + + `, + }); + + await fireEvent.click(screen.getByLabelText('First')); + expect(screen.getByTestId('value')).toHaveTextContent('1'); + await fireEvent.click(screen.getByLabelText('Second')); + expect(screen.getByTestId('value')).toHaveTextContent('2'); + }); + + test('with custom elements as base', async () => { + const RadioGroup = createGroup({ label: 'Group' }); + const RadioInput = createRadio(CustomBase); + + await render({ + components: { RadioGroup, RadioInput }, + template: ` + + + + + `, + }); + + await fireEvent.click(screen.getByLabelText('First')); + expect(screen.getByTestId('value')).toHaveTextContent('1'); + await fireEvent.click(screen.getByLabelText('Second')); + expect(screen.getByTestId('value')).toHaveTextContent('2'); + }); +}); + +describe('Space key selects the radio', () => { + test('with input as base element', async () => { + const RadioGroup = createGroup({ label: 'Group' }); + const RadioInput = createRadio(); + + await render({ + components: { RadioGroup, RadioInput }, + template: ` + + + + + `, + }); + + await fireEvent.keyDown(screen.getByLabelText('First'), { key: 'Space' }); + expect(screen.getByTestId('value')).toHaveTextContent('1'); + await fireEvent.click(screen.getByLabelText('Second'), { key: 'Space' }); + expect(screen.getByTestId('value')).toHaveTextContent('2'); + }); + + test('with custom elements as base', async () => { + const RadioGroup = createGroup({ label: 'Group' }); + const RadioInput = createRadio(CustomBase); + + await render({ + components: { RadioGroup, RadioInput }, + template: ` + + + + + `, + }); + + await fireEvent.keyDown(screen.getByLabelText('First'), { key: 'Space' }); + expect(screen.getByTestId('value')).toHaveTextContent('1'); + await fireEvent.click(screen.getByLabelText('Second'), { key: 'Space' }); + expect(screen.getByTestId('value')).toHaveTextContent('2'); + }); + + test('disabled radio cannot be selected', async () => { + const RadioGroup = createGroup({ label: 'Group' }); + const RadioInput = createRadio(); + + await render({ + components: { RadioGroup, RadioInput }, + template: ` + + + + + `, + }); + + await fireEvent.keyDown(screen.getByLabelText('First'), { key: 'Space' }); + expect(screen.getByTestId('value')).toHaveTextContent(''); + await fireEvent.click(screen.getByLabelText('Second'), { key: 'Space' }); + expect(screen.getByTestId('value')).toHaveTextContent(''); + }); +}); + +describe('Arrow keys behavior', () => { + describe('LTR', () => { + async function renderTest() { + const RadioGroup = createGroup({ label: 'Group' }); + const RadioInput = createRadio(); + + await render({ + components: { RadioGroup, RadioInput }, + template: ` + + + + + + `, + }); + } + + test('arrow down moves forward', async () => { + await renderTest(); + + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowDown' }); + expect(screen.getByTestId('value')).toHaveTextContent('1'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowDown' }); + expect(screen.getByTestId('value')).toHaveTextContent('2'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowDown' }); + expect(screen.getByTestId('value')).toHaveTextContent('3'); + }); + + test('arrow up moves backward', async () => { + await renderTest(); + + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowUp' }); + expect(screen.getByTestId('value')).toHaveTextContent('1'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowUp' }); + expect(screen.getByTestId('value')).toHaveTextContent('3'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowUp' }); + expect(screen.getByTestId('value')).toHaveTextContent('2'); + }); + + test('arrow right moves forward', async () => { + await renderTest(); + + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowRight' }); + expect(screen.getByTestId('value')).toHaveTextContent('1'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowRight' }); + expect(screen.getByTestId('value')).toHaveTextContent('2'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowRight' }); + expect(screen.getByTestId('value')).toHaveTextContent('3'); + }); + + test('arrow left moves backward', async () => { + await renderTest(); + + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowLeft' }); + expect(screen.getByTestId('value')).toHaveTextContent('1'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowLeft' }); + expect(screen.getByTestId('value')).toHaveTextContent('3'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowLeft' }); + expect(screen.getByTestId('value')).toHaveTextContent('2'); + }); + }); + + describe('RTL', () => { + async function renderTest() { + const RadioGroup = createGroup({ label: 'Group', dir: 'rtl' }); + const RadioInput = createRadio(); + + await render({ + components: { RadioGroup, RadioInput }, + template: ` + + + + + + `, + }); + } + + test('arrow down moves forward', async () => { + await renderTest(); + + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowDown' }); + expect(screen.getByTestId('value')).toHaveTextContent('1'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowDown' }); + expect(screen.getByTestId('value')).toHaveTextContent('2'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowDown' }); + expect(screen.getByTestId('value')).toHaveTextContent('3'); + }); + + test('arrow up moves backward', async () => { + await renderTest(); + + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowUp' }); + expect(screen.getByTestId('value')).toHaveTextContent('1'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowUp' }); + expect(screen.getByTestId('value')).toHaveTextContent('3'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowUp' }); + expect(screen.getByTestId('value')).toHaveTextContent('2'); + }); + + test('arrow left moves forward', async () => { + await renderTest(); + + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowLeft' }); + expect(screen.getByTestId('value')).toHaveTextContent('1'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowLeft' }); + expect(screen.getByTestId('value')).toHaveTextContent('2'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowLeft' }); + expect(screen.getByTestId('value')).toHaveTextContent('3'); + }); + + test('arrow right moves backward', async () => { + await renderTest(); + + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowRight' }); + expect(screen.getByTestId('value')).toHaveTextContent('1'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowRight' }); + expect(screen.getByTestId('value')).toHaveTextContent('3'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowRight' }); + expect(screen.getByTestId('value')).toHaveTextContent('2'); + }); + }); + + test('skips disabled buttons', async () => { + const RadioGroup = createGroup({ label: 'Group' }); + const RadioInput = createRadio(); + + await render({ + components: { RadioGroup, RadioInput }, + template: ` + + + + + + `, + }); + + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowDown' }); + expect(screen.getByTestId('value')).toHaveTextContent('1'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowDown' }); + expect(screen.getByTestId('value')).toHaveTextContent('2'); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowDown' }); + expect(screen.getByTestId('value')).toHaveTextContent('1'); + }); + + test('does not affect disabled groups', async () => { + const RadioGroup = createGroup({ label: 'Group', disabled: true }); + const RadioInput = createRadio(); + + await render({ + components: { RadioGroup, RadioInput }, + template: ` + + + + + + `, + }); + + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowDown' }); + expect(screen.getByTestId('value')).toHaveTextContent(''); + await fireEvent.keyDown(screen.getByLabelText('Group'), { key: 'ArrowDown' }); + expect(screen.getByTestId('value')).toHaveTextContent(''); + }); +}); diff --git a/packages/core/src/useRadio/useRadioGroup.ts b/packages/core/src/useRadio/useRadioGroup.ts index 07456a7d..d28949b3 100644 --- a/packages/core/src/useRadio/useRadioGroup.ts +++ b/packages/core/src/useRadio/useRadioGroup.ts @@ -97,7 +97,7 @@ export function useRadioGroup(_props: Reactivify(_props: Reactivify(() => { + const groupProps = computed(() => { return { ...labelledByProps.value, dir: toValue(props.dir) ?? direction.value, role: 'radiogroup', 'aria-describedby': describedBy(), - 'aria-invalid': errorMessage.value ? true : undefined, + 'aria-invalid': !isValid.value ? true : undefined, onKeydown(e: KeyboardEvent) { if (toValue(props.disabled)) { return; @@ -206,7 +206,7 @@ export function useRadioGroup(_props: Reactivify, elementRef? * Use this if you are using a native input[type=checkbox] element. */ const inputProps = computed(() => - withRefCapture(createBindings(inputRef.value?.tagName === 'INPUT'), inputRef, elementRef), + withRefCapture(createBindings(isInputElement(inputRef.value)), inputRef, elementRef), ); function togglePressed(force?: boolean) { diff --git a/packages/core/src/utils/common.ts b/packages/core/src/utils/common.ts index f2224da4..43397151 100644 --- a/packages/core/src/utils/common.ts +++ b/packages/core/src/utils/common.ts @@ -1,6 +1,6 @@ import { MaybeRefOrGetter, Ref, toValue, useId } from 'vue'; import { klona } from 'klona/full'; -import { AriaDescriptionProps, Arrayable, NormalizedProps } from '../types'; +import { AriaDescriptionProps, Arrayable, Maybe, NormalizedProps } from '../types'; import { AsyncReturnType } from 'type-fest'; export function useUniqId(prefix?: string) { @@ -284,3 +284,11 @@ export function warn(message: string) { console.warn(`[Formwerk]: ${message}`); } } + +export function isInputElement(el: Maybe) { + if (!el) { + return false; + } + + return ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName); +} diff --git a/packages/core/src/validation/useInputValidity.ts b/packages/core/src/validation/useInputValidity.ts index dfd8cbab..f974237c 100644 --- a/packages/core/src/validation/useInputValidity.ts +++ b/packages/core/src/validation/useInputValidity.ts @@ -3,7 +3,7 @@ import { useEventListener } from '../helpers/useEventListener'; import { FormKey } from '../useForm'; import { Maybe, ValidationResult } from '../types'; import { FormField } from '../useFormField'; -import { cloneDeep, normalizeArrayable } from '../utils/common'; +import { cloneDeep, isInputElement, normalizeArrayable } from '../utils/common'; import { FormGroupKey } from '../useFormGroup'; interface InputValidityOptions { @@ -21,6 +21,20 @@ export function useInputValidity(opts: InputValidityOptions) { useMessageCustomValiditySync(errorMessage, opts.inputRef); function validateNative(mutate?: boolean): ValidationResult { + const baseReturns: Omit = { + type: 'FIELD', + path: (formGroup ? getName() : getPath()) || '', + output: cloneDeep(fieldValue.value), + }; + + if (!isInputElement(opts.inputRef?.value)) { + return { + ...baseReturns, + isValid: true, + errors: [{ messages: [], path: getPath() || '' }], + }; + } + validityDetails.value = opts.inputRef?.value?.validity; const messages = normalizeArrayable(opts.inputRef?.value?.validationMessage || ([] as string[])).filter(Boolean); if (mutate) { @@ -28,9 +42,7 @@ export function useInputValidity(opts: InputValidityOptions) { } return { - type: 'FIELD', - path: (formGroup ? getName() : getPath()) || '', - output: cloneDeep(fieldValue.value), + ...baseReturns, isValid: !messages.length, errors: [{ messages, path: getPath() || '' }], };