From ea8693e1ec4be524095d7e0857f27d88d9823c40 Mon Sep 17 00:00:00 2001 From: Ryan Gossiaux Date: Sat, 25 Dec 2021 15:57:35 -0800 Subject: [PATCH] Add remaining Switch tests --- .../components/switch/_ManagedSwitch.svelte | 11 + src/lib/components/switch/switch.test.ts | 315 ++++++++++++++++- src/lib/test-utils/interactions.ts | 328 ++++++++++++++++++ 3 files changed, 652 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/switch/_ManagedSwitch.svelte create mode 100644 src/lib/test-utils/interactions.ts diff --git a/src/lib/components/switch/_ManagedSwitch.svelte b/src/lib/components/switch/_ManagedSwitch.svelte new file mode 100644 index 0000000..55423df --- /dev/null +++ b/src/lib/components/switch/_ManagedSwitch.svelte @@ -0,0 +1,11 @@ + + + (state = e.detail)} on:change> + + diff --git a/src/lib/components/switch/switch.test.ts b/src/lib/components/switch/switch.test.ts index 90e4703..2cd4210 100644 --- a/src/lib/components/switch/switch.test.ts +++ b/src/lib/components/switch/switch.test.ts @@ -1,8 +1,12 @@ import { render } from "@testing-library/svelte"; import TestRenderer from "../../test-utils/TestRenderer.svelte"; -import { Switch } from "."; -import { assertSwitch, getSwitch, SwitchState } from "$lib/test-utils/accessibility-assertions"; +import { Switch, SwitchDescription, SwitchGroup, SwitchLabel } from "."; +import { assertActiveElement, assertSwitch, getSwitch, getSwitchLabel, SwitchState } from "$lib/test-utils/accessibility-assertions"; +import Button from "$lib/internal/elements/Button.svelte"; +import Div from "$lib/internal/elements/Div.svelte"; import Span from "$lib/internal/elements/Span.svelte"; +import ManagedSwitch from "./_ManagedSwitch.svelte"; +import { click, Keys, press } from "$lib/test-utils/interactions"; jest.mock('../../hooks/use-id') describe('Safe guards', () => { @@ -112,3 +116,310 @@ describe('Rendering', () => { }) }) }) + +describe('Render composition', () => { + it('should be possible to render a Switch.Group, Switch and Switch.Label', () => { + render(TestRenderer, { + allProps: [ + SwitchGroup, + {}, + [ + [Switch, + { checked: false, onChange: console.log } + ], + [SwitchLabel, + {}, + "Enable notifications" + ] + ] + ] + }) + + assertSwitch({ state: SwitchState.Off, label: 'Enable notifications' }) + }) + + it('should be possible to render a Switch.Group, Switch and Switch.Label (before the Switch)', () => { + render(TestRenderer, { + allProps: [ + SwitchGroup, + {}, + [ + [SwitchLabel, + {}, + "Label B"], + [Switch, + { checked: false, onChange: console.log }, + "Label A"] + ] + ] + }) + + // Warning! Using aria-label or aria-labelledby will hide any descendant content from assistive + // technologies. + // + // Thus: Label A should not be part of the "label" in this case + assertSwitch({ state: SwitchState.Off, label: 'Label B' }) + }) + + it('should be possible to render a Switch.Group, Switch and Switch.Label (after the Switch)', () => { + render(TestRenderer, { + allProps: [ + SwitchGroup, + {}, + [ + [Switch, + { checked: false, onChange: console.log }, + "Label A"], + [SwitchLabel, + {}, + "Label B"] + ] + ] + }) + + // Warning! Using aria-label or aria-labelledby will hide any descendant content from assistive + // technologies. + // + // Thus: Label A should not be part of the "label" in this case + assertSwitch({ state: SwitchState.Off, label: 'Label B' }) + }) + + it('should be possible to render a Switch.Group, Switch and Switch.Description (before the Switch)', async () => { + render(TestRenderer, { + allProps: [ + SwitchGroup, + {}, + [ + [SwitchDescription, + {}, + "This is an important feature"], + [Switch, + { checked: false, onChange: console.log }], + ] + ] + }) + + assertSwitch({ state: SwitchState.Off, description: 'This is an important feature' }) + }) + + it('should be possible to render a Switch.Group, Switch and Switch.Description (after the Switch)', () => { + render(TestRenderer, { + allProps: [ + SwitchGroup, + {}, + [ + [Switch, + { checked: false, onChange: console.log }], + [SwitchDescription, + {}, + "This is an important feature"], + ] + ] + }) + + assertSwitch({ state: SwitchState.Off, description: 'This is an important feature' }) + }) + + it('should be possible to render a Switch.Group, Switch, Switch.Label and Switch.Description', () => { + render(TestRenderer, { + allProps: [ + SwitchGroup, + {}, + [ + [SwitchLabel, + {}, + "Label A"], + [Switch, + { checked: false, onChange: console.log }], + [SwitchDescription, + {}, + "This is an important feature"], + ] + ] + }) + + assertSwitch({ + state: SwitchState.Off, + label: 'Label A', + description: 'This is an important feature', + }) + }) +}) + +describe('Keyboard interactions', () => { + describe('`Space` key', () => { + it('should be possible to toggle the Switch with Space', async () => { + render(TestRenderer, { + allProps: [ + ManagedSwitch, + {}, + ] + }) + + // Ensure checkbox is off + assertSwitch({ state: SwitchState.Off }) + + // Focus the switch + getSwitch()?.focus() + + // Toggle + await press(Keys.Space) + + // Ensure state is on + assertSwitch({ state: SwitchState.On }) + + // Toggle + await press(Keys.Space) + + // Ensure state is off + assertSwitch({ state: SwitchState.Off }) + }) + }) + + describe('`Enter` key', () => { + it('should not be possible to use Enter to toggle the Switch', async () => { + let handleChange = jest.fn() + render(TestRenderer, { + allProps: [ + ManagedSwitch, + { onChange: handleChange }, + ] + }) + + + // Ensure checkbox is off + assertSwitch({ state: SwitchState.Off }) + + // Focus the switch + getSwitch()?.focus() + + // Try to toggle + await press(Keys.Enter) + + expect(handleChange).not.toHaveBeenCalled() + }) + }) + + describe('`Tab` key', () => { + it('should be possible to tab away from the Switch', async () => { + render(TestRenderer, { + allProps: [ + Div, + {}, + [ + [Switch, + { checked: false, onChange: console.log }], + [Button, { id: "btn" }, "Other element"] + ] + ] + }) + + // Ensure checkbox is off + assertSwitch({ state: SwitchState.Off }) + + // Focus the switch + getSwitch()?.focus() + + // Expect the switch to be active + assertActiveElement(getSwitch()) + + // Toggle + await press(Keys.Tab) + + // Expect the button to be active + assertActiveElement(document.getElementById('btn')) + }) + }) +}) + +describe('Mouse interactions', () => { + it('should be possible to toggle the Switch with a click', async () => { + render(TestRenderer, { + allProps: [ + ManagedSwitch, + {}, + ] + }) + + // Ensure checkbox is off + assertSwitch({ state: SwitchState.Off }) + + // Toggle + await click(getSwitch()) + + // Ensure state is on + assertSwitch({ state: SwitchState.On }) + + // Toggle + await click(getSwitch()) + + // Ensure state is off + assertSwitch({ state: SwitchState.Off }) + }) + + it('should be possible to toggle the Switch with a click on the Label', async () => { + render(TestRenderer, { + allProps: [ + SwitchGroup, + {}, + [ + [ManagedSwitch, + {}, + ], + [SwitchLabel, + {}, + "The label"] + ] + ] + }) + + + + // Ensure checkbox is off + assertSwitch({ state: SwitchState.Off }) + + // Toggle + await click(getSwitchLabel()) + + // Ensure the switch is focused + assertActiveElement(getSwitch()) + + // Ensure state is on + assertSwitch({ state: SwitchState.On }) + + // Toggle + await click(getSwitchLabel()) + + // Ensure the switch is focused + assertActiveElement(getSwitch()) + + // Ensure state is off + assertSwitch({ state: SwitchState.Off }) + }) + + it('should not be possible to toggle the Switch with a click on the Label (passive)', async () => { + render(TestRenderer, { + allProps: [ + SwitchGroup, + {}, + [ + [ManagedSwitch, + {}, + ], + [SwitchLabel, + { passive: true }, + "The label"] + ] + ] + }) + + // Ensure checkbox is off + assertSwitch({ state: SwitchState.Off }) + + // Toggle + await click(getSwitchLabel()) + + // Ensure state is still off + assertSwitch({ state: SwitchState.Off }) + }) +}) diff --git a/src/lib/test-utils/interactions.ts b/src/lib/test-utils/interactions.ts new file mode 100644 index 0000000..9bc0ce0 --- /dev/null +++ b/src/lib/test-utils/interactions.ts @@ -0,0 +1,328 @@ +import { tick } from "svelte"; +import { fireEvent } from '@testing-library/svelte' + +export let Keys: Record> = { + Space: { key: ' ', keyCode: 32, charCode: 32 }, + Enter: { key: 'Enter', keyCode: 13, charCode: 13 }, + Escape: { key: 'Escape', keyCode: 27, charCode: 27 }, + Backspace: { key: 'Backspace', keyCode: 8 }, + + ArrowLeft: { key: 'ArrowLeft', keyCode: 37 }, + ArrowUp: { key: 'ArrowUp', keyCode: 38 }, + ArrowRight: { key: 'ArrowRight', keyCode: 39 }, + ArrowDown: { key: 'ArrowDown', keyCode: 40 }, + + Home: { key: 'Home', keyCode: 36 }, + End: { key: 'End', keyCode: 35 }, + + PageUp: { key: 'PageUp', keyCode: 33 }, + PageDown: { key: 'PageDown', keyCode: 34 }, + + Tab: { key: 'Tab', keyCode: 9, charCode: 9 }, +} + +export function shift(event: Partial) { + return { ...event, shiftKey: true } +} + +export function word(input: string): Partial[] { + return input.split('').map(key => ({ key })) +} + +let Default = Symbol() +let Ignore = Symbol() + +let cancellations: Record>> = { + [Default]: { + keydown: new Set(['keypress']), + keypress: new Set([]), + keyup: new Set([]), + }, + [Keys.Enter.key!]: { + keydown: new Set(['keypress', 'click']), + keypress: new Set(['click']), + keyup: new Set([]), + }, + [Keys.Space.key!]: { + keydown: new Set(['keypress', 'click']), + keypress: new Set([]), + keyup: new Set(['click']), + }, + [Keys.Tab.key!]: { + keydown: new Set(['keypress', 'blur', 'focus']), + keypress: new Set([]), + keyup: new Set([]), + }, +} + +let order: Record< + string | typeof Default, + (( + element: Element, + event: Partial + ) => Promise | typeof Ignore | Promise)[] +> = { + [Default]: [ + async function keydown(element, event) { + return await fireEvent.keyDown(element, event) + }, + async function keypress(element, event) { + return await fireEvent.keyPress(element, event) + }, + async function keyup(element, event) { + return await fireEvent.keyUp(element, event) + }, + ], + [Keys.Enter.key!]: [ + async function keydown(element, event) { + return await fireEvent.keyDown(element, event) + }, + async function keypress(element, event) { + return await fireEvent.keyPress(element, event) + }, + async function click(element, event) { + if (element instanceof HTMLButtonElement) return await fireEvent.click(element, event) + return Ignore + }, + async function keyup(element, event) { + return await fireEvent.keyUp(element, event) + }, + ], + [Keys.Space.key!]: [ + async function keydown(element, event) { + return await fireEvent.keyDown(element, event) + }, + async function keypress(element, event) { + return await fireEvent.keyPress(element, event) + }, + async function keyup(element, event) { + return await fireEvent.keyUp(element, event) + }, + async function click(element, event) { + if (element instanceof HTMLButtonElement) return await fireEvent.click(element, event) + return Ignore + }, + ], + [Keys.Tab.key!]: [ + async function keydown(element, event) { + return await fireEvent.keyDown(element, event) + }, + async function blurAndfocus(_element, event) { + return focusNext(event) + }, + async function keyup(element, event) { + return await fireEvent.keyUp(element, event) + }, + ], +} + +export async function type(events: Partial[], element = document.activeElement) { + jest.useFakeTimers() + + try { + if (element === null) return expect(element).not.toBe(null) + + for (let event of events) { + let skip = new Set() + let actions = order[event.key!] ?? order[Default as any] + for (let action of actions) { + let checks = action.name.split('And') + if (checks.some(check => skip.has(check))) continue + + let result: boolean | typeof Ignore | Element = await action(element, { + type: action.name, + charCode: event.key?.length === 1 ? event.key?.charCodeAt(0) : undefined, + ...event, + }) + if (result === Ignore) continue + if (result instanceof Element) { + element = result + } + + let cancelled = !result + if (cancelled) { + let skippablesForKey = cancellations[event.key!] ?? cancellations[Default as any] + let skippables = skippablesForKey?.[action.name] ?? new Set() + + for (let skippable of skippables) skip.add(skippable) + } + } + } + + // We don't want to actually wait in our tests, so let's advance + jest.runAllTimers() + + await tick() + } catch (err: any) { + Error.captureStackTrace(err, type) + throw err + } finally { + jest.useRealTimers() + } +} + +export async function press(event: Partial, element = document.activeElement) { + return type([event], element) +} + +export enum MouseButton { + Left = 0, + Right = 2, +} + +export async function click( + element: Document | Element | Window | null, + button = MouseButton.Left +) { + try { + if (element === null) return expect(element).not.toBe(null) + + let options = { button } + + if (button === MouseButton.Left) { + // Cancel in pointerDown cancels mouseDown, mouseUp + let cancelled = !fireEvent.pointerDown(element, options) + if (!cancelled) { + fireEvent.mouseDown(element, options) + } + + // Ensure to trigger a `focus` event if the element is focusable, or within a focusable element + let next: HTMLElement | null = element as HTMLElement | null + while (next !== null) { + if (next.matches(focusableSelector)) { + next.focus() + break + } + next = next.parentElement + } + + fireEvent.pointerUp(element, options) + if (!cancelled) { + fireEvent.mouseUp(element, options) + } + fireEvent.click(element, options) + } else if (button === MouseButton.Right) { + // Cancel in pointerDown cancels mouseDown, mouseUp + let cancelled = !fireEvent.pointerDown(element, options) + if (!cancelled) { + fireEvent.mouseDown(element, options) + } + + // Only in Firefox: + fireEvent.pointerUp(element, options) + if (!cancelled) { + fireEvent.mouseUp(element, options) + } + } + + await tick() + } catch (err: any) { + Error.captureStackTrace(err, click) + throw err + } +} + +export async function focus(element: Document | Element | Window | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + fireEvent.focus(element) + + await tick() + } catch (err: any) { + Error.captureStackTrace(err, focus) + throw err + } +} +export async function mouseEnter(element: Document | Element | Window | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + fireEvent.pointerOver(element) + fireEvent.pointerEnter(element) + fireEvent.mouseOver(element) + + await tick() + } catch (err: any) { + Error.captureStackTrace(err, mouseEnter) + throw err + } +} + +export async function mouseMove(element: Document | Element | Window | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + fireEvent.pointerMove(element) + fireEvent.mouseMove(element) + + await tick() + } catch (err: any) { + Error.captureStackTrace(err, mouseMove) + throw err + } +} + +export async function mouseLeave(element: Document | Element | Window | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + fireEvent.pointerOut(element) + fireEvent.pointerLeave(element) + fireEvent.mouseOut(element) + fireEvent.mouseLeave(element) + + await tick() + } catch (err: any) { + Error.captureStackTrace(err, mouseLeave) + throw err + } +} + +// --- + +function focusNext(event: Partial) { + let direction = event.shiftKey ? -1 : +1 + let focusableElements = getFocusableElements() + let total = focusableElements.length + + function innerFocusNext(offset = 0): Element { + let currentIdx = focusableElements.indexOf(document.activeElement as HTMLElement) + let next = focusableElements[(currentIdx + total + direction + offset) % total] as HTMLElement + + if (next) next?.focus({ preventScroll: true }) + + if (next !== document.activeElement) return innerFocusNext(offset + direction) + return next + } + + return innerFocusNext() +} + +// Credit: +// - https://stackoverflow.com/a/30753870 +let focusableSelector = [ + '[contentEditable=true]', + '[tabindex]', + 'a[href]', + 'area[href]', + 'button:not([disabled])', + 'iframe', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', +] + .map( + process.env.NODE_ENV === 'test' + ? // TODO: Remove this once JSDOM fixes the issue where an element that is + // "hidden" can be the document.activeElement, because this is not possible + // in real browsers. + selector => `${selector}:not([tabindex='-1']):not([style*='display: none'])` + : selector => `${selector}:not([tabindex='-1'])` + ) + .join(',') + +function getFocusableElements(container = document.body) { + if (!container) return [] + return Array.from(container.querySelectorAll(focusableSelector)) +}