diff --git a/packages/core/src/useSwitch/index.ts b/packages/core/src/useSwitch/index.ts index 0b88cb2c..08275f4f 100644 --- a/packages/core/src/useSwitch/index.ts +++ b/packages/core/src/useSwitch/index.ts @@ -1,166 +1 @@ -import { Ref, computed, shallowRef, toValue } from 'vue'; -import { - AriaDescribableProps, - AriaInputProps, - AriaLabelableProps, - InputBaseAttributes, - InputEvents, - Reactivify, - TypedSchema, -} from '../types'; -import { isEqual, normalizeProps, useUniqId, withRefCapture } from '../utils/common'; -import { useLabel } from '../a11y/useLabel'; -import { useFormField } from '../useFormField'; -import { FieldTypePrefixes } from '../constants'; - -export interface SwitchDomInputProps - extends InputBaseAttributes, - AriaLabelableProps, - InputBaseAttributes, - AriaDescribableProps, - InputEvents { - type: 'checkbox'; - role: 'switch'; -} - -export interface SwitchDOMProps extends AriaInputProps, AriaLabelableProps, AriaDescribableProps, InputEvents { - id: string; - tabindex: '0'; - role: 'switch'; - 'aria-checked'?: boolean; - - onClick: (e: Event) => void; -} - -export type SwitchProps = { - label?: string; - name?: string; - modelValue?: boolean; - - readonly?: boolean; - disabled?: boolean; - - trueValue?: unknown; - falseValue?: unknown; - - schema?: TypedSchema; -}; - -export function useSwitch(_props: Reactivify, elementRef?: Ref) { - const props = normalizeProps(_props, ['schema']); - const inputId = useUniqId(FieldTypePrefixes.Switch); - const inputRef = elementRef || shallowRef(); - const { labelProps, labelledByProps } = useLabel({ - for: inputId, - label: props.label, - targetRef: inputRef, - }); - - const { fieldValue, setValue, isTouched, setTouched } = useFormField({ - path: props.name, - initialValue: toValue(props.modelValue) ?? toValue(props.falseValue) ?? false, - disabled: props.disabled, - schema: props.schema, - }); - - /** - * Normalizes in the incoming value to be either one of the given toggled values or a boolean. - */ - function normalizeValue(nextValue: unknown) { - if (typeof nextValue === 'boolean') { - return nextValue ? (toValue(props.trueValue) ?? true) : (toValue(props.falseValue) ?? false); - } - - const trueValue = toValue(props.trueValue); - if (isEqual(nextValue, trueValue)) { - return trueValue; - } - - const falseValue = toValue(props.falseValue); - if (isEqual(nextValue, falseValue)) { - return falseValue; - } - - // Normalize the incoming value to a boolean - return !!nextValue; - } - - function setValueFromEvent(e: Event) { - setValue(normalizeValue((e.target as HTMLInputElement).checked)); - setTouched(true); - } - - const handlers: InputEvents = { - onKeydown: (evt: KeyboardEvent) => { - if (evt.code === 'Space' || evt.key === 'Enter') { - evt.preventDefault(); - togglePressed(); - setTouched(true); - } - }, - onChange: setValueFromEvent, - onInput: setValueFromEvent, - }; - - function onClick() { - togglePressed(); - setTouched(true); - } - - const isPressed = computed({ - get() { - return isEqual(fieldValue.value, toValue(props.trueValue) ?? true); - }, - set(value: boolean) { - setValue(normalizeValue(value)); - }, - }); - - function createBindings(isInput: boolean): SwitchDOMProps | SwitchDomInputProps { - const base = { - id: inputId, - ...labelledByProps.value, - [isInput ? 'checked' : 'aria-checked']: isPressed.value || false, - [isInput ? 'readonly' : 'aria-readonly']: toValue(props.readonly) || undefined, - [isInput ? 'disabled' : 'aria-disabled']: toValue(props.disabled) || undefined, - role: 'switch' as const, - }; - - if (isInput) { - return { - ...base, - ...handlers, - name: toValue(props.name), - type: 'checkbox', - }; - } - - return { - ...base, - onClick, - tabindex: '0', - onKeydown: handlers.onKeydown, - }; - } - - /** - * Use this if you are using a native input[type=checkbox] element. - */ - const inputProps = computed(() => - withRefCapture(createBindings(inputRef.value?.tagName === 'INPUT'), inputRef, elementRef), - ); - - function togglePressed(force?: boolean) { - isPressed.value = force ?? !isPressed.value; - } - - return { - fieldValue, - isPressed, - inputRef, - labelProps, - inputProps, - togglePressed, - isTouched, - }; -} +export * from './useSwitch'; diff --git a/packages/core/src/useSwitch/useSwitch.spec.ts b/packages/core/src/useSwitch/useSwitch.spec.ts new file mode 100644 index 00000000..20e0f177 --- /dev/null +++ b/packages/core/src/useSwitch/useSwitch.spec.ts @@ -0,0 +1,245 @@ +import { fireEvent, render, screen } from '@testing-library/vue'; +import { axe } from 'vitest-axe'; +import { useSwitch } from './useSwitch'; +import { flush } from '@test-utils/flush'; +import { describe } from 'vitest'; + +describe('with input as base element', () => { + test('should not have a11y errors', async () => { + await render({ + setup() { + const label = 'Subscribe to our newsletter'; + const { inputProps, labelProps, isPressed } = useSwitch({ + label, + }); + + return { + inputProps, + labelProps, + isPressed, + label, + }; + }, + template: ` +
+ + +
+ `, + }); + + await flush(); + vi.useRealTimers(); + expect(await axe(screen.getByTestId('fixture'))).toHaveNoViolations(); + vi.useFakeTimers(); + }); + + test('clicking toggles the value', async () => { + const label = 'Subscribe to our newsletter'; + await render({ + setup() { + const { inputProps, labelProps, isPressed, fieldValue } = useSwitch({ + label, + }); + + return { + inputProps, + labelProps, + isPressed, + label, + fieldValue, + }; + }, + template: ` +
+ + +
{{ fieldValue }}
+
+ `, + }); + + expect(screen.getByTestId('value')).toHaveTextContent('false'); + expect(screen.getByLabelText(label)).not.toBeChecked(); + await fireEvent.click(screen.getByLabelText(label)); + expect(screen.getByTestId('value')).toHaveTextContent('true'); + expect(screen.getByLabelText(label)).toBeChecked(); + await fireEvent.click(screen.getByLabelText(label)); + expect(screen.getByTestId('value')).toHaveTextContent('false'); + expect(screen.getByLabelText(label)).not.toBeChecked(); + }); + + test('Space key or Enter toggles the value', async () => { + const label = 'Subscribe to our newsletter'; + await render({ + setup() { + const { inputProps, labelProps, isPressed, fieldValue } = useSwitch({ + label, + }); + + return { + inputProps, + labelProps, + isPressed, + label, + fieldValue, + }; + }, + template: ` +
+ + +
{{ fieldValue }}
+
+ `, + }); + + expect(screen.getByTestId('value')).toHaveTextContent('false'); + expect(screen.getByLabelText(label)).not.toBeChecked(); + await fireEvent.keyDown(screen.getByLabelText(label), { key: 'Enter' }); + expect(screen.getByTestId('value')).toHaveTextContent('true'); + expect(screen.getByLabelText(label)).toBeChecked(); + await fireEvent.keyDown(screen.getByLabelText(label), { key: 'Space' }); + expect(screen.getByTestId('value')).toHaveTextContent('false'); + expect(screen.getByLabelText(label)).not.toBeChecked(); + }); + + test('Can toggle between two custom values', async () => { + const label = 'Subscribe to our newsletter'; + const trueValue = { yes: true }; + const falseValue = 'nay'; + await render({ + setup() { + const { inputProps, labelProps, isPressed, fieldValue } = useSwitch({ + label, + trueValue, + falseValue, + }); + + return { + inputProps, + labelProps, + isPressed, + label, + fieldValue, + }; + }, + template: ` +
+ + +
{{ fieldValue }}
+
+ `, + }); + + expect(screen.getByTestId('value')).toHaveTextContent('nay'); + await fireEvent.click(screen.getByLabelText(label)); + expect(screen.getByTestId('value')).toContainHTML('"yes": true'); + await fireEvent.click(screen.getByLabelText(label)); + expect(screen.getByTestId('value')).toHaveTextContent('nay'); + await fireEvent.click(screen.getByLabelText(label)); + expect(screen.getByTestId('value')).toContainHTML('"yes": true'); + }); +}); + +describe('with custom base element', () => { + test('should not have a11y errors with custom base element implementation', async () => { + await render({ + setup() { + const label = 'Subscribe to our newsletter'; + const { inputProps, labelProps, isPressed } = useSwitch({ + label, + }); + + return { + inputProps, + labelProps, + isPressed, + label, + }; + }, + template: ` +
+
+
{{ label }}
+
+ `, + }); + + await flush(); + vi.useRealTimers(); + expect(await axe(screen.getByTestId('fixture'))).toHaveNoViolations(); + vi.useFakeTimers(); + }); + + test('clicking toggles the value', async () => { + const label = 'Subscribe to our newsletter'; + await render({ + setup() { + const { inputProps, labelProps, isPressed, fieldValue } = useSwitch({ + label, + }); + + return { + inputProps, + labelProps, + isPressed, + label, + fieldValue, + }; + }, + template: ` +
+
+
{{ label }}
+
{{ fieldValue }}
+
+ `, + }); + + expect(screen.getByTestId('value')).toHaveTextContent('false'); + expect(screen.getByLabelText(label)).toHaveAttribute('aria-checked', 'false'); + await fireEvent.click(screen.getByLabelText(label)); + expect(screen.getByTestId('value')).toHaveTextContent('true'); + expect(screen.getByLabelText(label)).toHaveAttribute('aria-checked', 'true'); + await fireEvent.click(screen.getByLabelText(label)); + expect(screen.getByTestId('value')).toHaveTextContent('false'); + expect(screen.getByLabelText(label)).toHaveAttribute('aria-checked', 'false'); + }); + + test('Space key or Enter toggles the value', async () => { + const label = 'Subscribe to our newsletter'; + await render({ + setup() { + const { inputProps, labelProps, isPressed, fieldValue } = useSwitch({ + label, + }); + + return { + inputProps, + labelProps, + isPressed, + label, + fieldValue, + }; + }, + template: ` +
+
+
{{ label }}
+
{{ fieldValue }}
+
+ `, + }); + + expect(screen.getByTestId('value')).toHaveTextContent('false'); + expect(screen.getByLabelText(label)).toHaveAttribute('aria-checked', 'false'); + await fireEvent.keyDown(screen.getByLabelText(label), { key: 'Enter' }); + expect(screen.getByTestId('value')).toHaveTextContent('true'); + expect(screen.getByLabelText(label)).toHaveAttribute('aria-checked', 'true'); + await fireEvent.keyDown(screen.getByLabelText(label), { key: 'Space' }); + expect(screen.getByTestId('value')).toHaveTextContent('false'); + expect(screen.getByLabelText(label)).toHaveAttribute('aria-checked', 'false'); + }); +}); diff --git a/packages/core/src/useSwitch/useSwitch.ts b/packages/core/src/useSwitch/useSwitch.ts new file mode 100644 index 00000000..c2d723c6 --- /dev/null +++ b/packages/core/src/useSwitch/useSwitch.ts @@ -0,0 +1,166 @@ +import { Ref, computed, shallowRef, toValue } from 'vue'; +import { + AriaDescribableProps, + AriaInputProps, + AriaLabelableProps, + InputBaseAttributes, + InputEvents, + Reactivify, + TypedSchema, +} from '../types'; +import { isEqual, normalizeProps, useUniqId, withRefCapture } from '../utils/common'; +import { useLabel } from '../a11y/useLabel'; +import { useFormField } from '../useFormField'; +import { FieldTypePrefixes } from '../constants'; + +export interface SwitchDomInputProps + extends InputBaseAttributes, + AriaLabelableProps, + InputBaseAttributes, + AriaDescribableProps, + InputEvents { + type: 'checkbox'; + role: 'switch'; +} + +export interface SwitchDOMProps extends AriaInputProps, AriaLabelableProps, AriaDescribableProps, InputEvents { + id: string; + tabindex: '0'; + role: 'switch'; + 'aria-checked'?: boolean; + + onClick: (e: Event) => void; +} + +export type SwitchProps = { + label?: string; + name?: string; + modelValue?: boolean; + + readonly?: boolean; + disabled?: boolean; + + trueValue?: unknown; + falseValue?: unknown; + + schema?: TypedSchema; +}; + +export function useSwitch(_props: Reactivify, elementRef?: Ref) { + const props = normalizeProps(_props, ['schema']); + const inputId = useUniqId(FieldTypePrefixes.Switch); + const inputRef = elementRef || shallowRef(); + const { labelProps, labelledByProps } = useLabel({ + for: inputId, + label: props.label, + targetRef: inputRef, + }); + + const { fieldValue, setValue, isTouched, setTouched } = useFormField({ + path: props.name, + initialValue: toValue(props.modelValue) ?? toValue(props.falseValue) ?? false, + disabled: props.disabled, + schema: props.schema, + }); + + /** + * Normalizes in the incoming value to be either one of the given toggled values or a boolean. + */ + function normalizeValue(nextValue: unknown) { + if (typeof nextValue === 'boolean') { + return nextValue ? (toValue(props.trueValue) ?? true) : (toValue(props.falseValue) ?? false); + } + + const trueValue = toValue(props.trueValue); + if (isEqual(nextValue, trueValue)) { + return trueValue; + } + + const falseValue = toValue(props.falseValue); + if (isEqual(nextValue, falseValue)) { + return falseValue; + } + + // Normalize the incoming value to a boolean + return !!nextValue; + } + + function setValueFromEvent(e: Event) { + setValue(normalizeValue((e.target as HTMLInputElement).checked)); + setTouched(true); + } + + const handlers: InputEvents = { + onKeydown: (evt: KeyboardEvent) => { + if (evt.key === 'Space' || evt.key === 'Enter') { + evt.preventDefault(); + togglePressed(); + setTouched(true); + } + }, + onChange: setValueFromEvent, + onInput: setValueFromEvent, + }; + + function onClick() { + togglePressed(); + setTouched(true); + } + + const isPressed = computed({ + get() { + return isEqual(fieldValue.value, toValue(props.trueValue) ?? true); + }, + set(value: boolean) { + setValue(normalizeValue(value)); + }, + }); + + function createBindings(isInput: boolean): SwitchDOMProps | SwitchDomInputProps { + const base = { + id: inputId, + ...labelledByProps.value, + [isInput ? 'checked' : 'aria-checked']: isPressed.value || false, + [isInput ? 'readonly' : 'aria-readonly']: toValue(props.readonly) || undefined, + [isInput ? 'disabled' : 'aria-disabled']: toValue(props.disabled) || undefined, + role: 'switch' as const, + }; + + if (isInput) { + return { + ...base, + ...handlers, + name: toValue(props.name), + type: 'checkbox', + }; + } + + return { + ...base, + onClick, + tabindex: '0', + onKeydown: handlers.onKeydown, + }; + } + + /** + * Use this if you are using a native input[type=checkbox] element. + */ + const inputProps = computed(() => + withRefCapture(createBindings(inputRef.value?.tagName === 'INPUT'), inputRef, elementRef), + ); + + function togglePressed(force?: boolean) { + isPressed.value = force ?? !isPressed.value; + } + + return { + fieldValue, + isPressed, + inputRef, + labelProps, + inputProps, + togglePressed, + isTouched, + }; +}