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))
+}