From e1c22af5875155306b201d8d7ad6ce8ac05a8d56 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Tue, 19 Jul 2022 15:48:29 +0200 Subject: [PATCH] feat(keyboard): change radio group per arrow keys (#995) --- src/event/behavior/keydown.ts | 27 +++++++++++- src/utils/edit/walkRadio.ts | 34 ++++++++++++++ src/utils/index.ts | 1 + tests/event/behavior/keydown.ts | 78 +++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 src/utils/edit/walkRadio.ts diff --git a/src/event/behavior/keydown.ts b/src/event/behavior/keydown.ts index 1192424a..ac43781a 100644 --- a/src/event/behavior/keydown.ts +++ b/src/event/behavior/keydown.ts @@ -13,6 +13,7 @@ import { moveSelection, selectAll, setSelectionRange, + walkRadio, } from '../../utils' import {BehaviorPlugin} from '.' import {behavior} from './registry' @@ -27,8 +28,30 @@ behavior.keydown = (event, target, config) => { const keydownBehavior: { [key: string]: BehaviorPlugin<'keydown'> | undefined } = { - ArrowLeft: (event, target) => () => moveSelection(target, -1), - ArrowRight: (event, target) => () => moveSelection(target, 1), + ArrowDown: (event, target, config) => { + /* istanbul ignore else */ + if (isElementType(target, 'input', {type: 'radio'} as const)) { + return () => walkRadio(config, target, -1) + } + }, + ArrowLeft: (event, target, config) => { + if (isElementType(target, 'input', {type: 'radio'} as const)) { + return () => walkRadio(config, target, -1) + } + return () => moveSelection(target, -1) + }, + ArrowRight: (event, target, config) => { + if (isElementType(target, 'input', {type: 'radio'} as const)) { + return () => walkRadio(config, target, 1) + } + return () => moveSelection(target, 1) + }, + ArrowUp: (event, target, config) => { + /* istanbul ignore else */ + if (isElementType(target, 'input', {type: 'radio'} as const)) { + return () => walkRadio(config, target, 1) + } + }, Backspace: (event, target, config) => { if (isEditable(target)) { return () => { diff --git a/src/utils/edit/walkRadio.ts b/src/utils/edit/walkRadio.ts new file mode 100644 index 00000000..2a12171a --- /dev/null +++ b/src/utils/edit/walkRadio.ts @@ -0,0 +1,34 @@ +import {dispatchUIEvent} from '../../event' +import {Config} from '../../setup' +import {focus} from '../focus/focus' +import {getWindow} from '../misc/getWindow' +import {isDisabled} from '../misc/isDisabled' + +export function walkRadio( + config: Config, + el: HTMLInputElement & {type: 'radio'}, + direction: -1 | 1, +) { + const window = getWindow(el) + const group = Array.from( + el.ownerDocument.querySelectorAll( + el.name + ? `input[type="radio"][name="${window.CSS.escape(el.name)}"]` + : `input[type="radio"][name=""], input[type="radio"]:not([name])`, + ), + ) + for (let i = group.findIndex(e => e === el) + direction; ; i += direction) { + if (!group[i]) { + i = direction > 0 ? 0 : group.length - 1 + } + if (group[i] === el) { + return + } + if (isDisabled(group[i])) { + continue + } + + focus(group[i]) + dispatchUIEvent(config, group[i], 'click') + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 4c132869..72eebad1 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -10,6 +10,7 @@ export * from './edit/input' export * from './edit/isContentEditable' export * from './edit/isEditable' export * from './edit/setFiles' +export * from './edit/walkRadio' export * from './focus/blur' export * from './focus/copySelection' diff --git a/tests/event/behavior/keydown.ts b/tests/event/behavior/keydown.ts index b05a3c72..8cd2aae6 100644 --- a/tests/event/behavior/keydown.ts +++ b/tests/event/behavior/keydown.ts @@ -299,3 +299,81 @@ cases( }, }, ) + +cases( + 'walk through radio group per arrow keys', + ({focus, key, expectedTarget}) => { + const {getEvents, eventWasFired, xpathNode} = render( + ` + +
+ +
+ + + + + + + + `, + {focus}, + ) + + const active = document.activeElement as Element + dispatchUIEvent(createConfig(), active, 'keydown', {key}) + + if (expectedTarget) { + const target = xpathNode(expectedTarget) + expect(getEvents('click')[0]).toHaveProperty('target', target) + expect(getEvents('input')[0]).toHaveProperty('target', target) + expect(target).toHaveFocus() + expect(target).toBeChecked() + } else { + expect(eventWasFired('click')).toBe(false) + expect(eventWasFired('input')).toBe(false) + expect(active).toHaveFocus() + } + }, + { + 'per ArrowDown': { + focus: '//input[@value="a"]', + key: 'ArrowDown', + expectedTarget: '//input[@value="d"]', + }, + 'per ArrowLeft': { + focus: '//input[@value="d"]', + key: 'ArrowLeft', + expectedTarget: '//input[@value="a"]', + }, + 'per ArrowRight': { + focus: '//input[@value="a"]', + key: 'ArrowRight', + expectedTarget: '//input[@value="d"]', + }, + 'per ArrowUp': { + focus: '//input[@value="d"]', + key: 'ArrowUp', + expectedTarget: '//input[@value="a"]', + }, + 'forward around the corner': { + focus: '//input[@value="d"]', + key: 'ArrowRight', + expectedTarget: '//input[@value="a"]', + }, + 'backward around the corner': { + focus: '//input[@value="a"]', + key: 'ArrowUp', + expectedTarget: '//input[@value="d"]', + }, + 'do nothing on single radio': { + focus: '//input[@name="solo"]', + key: 'ArrowRight', + }, + 'on radios without name': { + focus: '//input[@value="nameless1"]', + key: 'ArrowRight', + expectedTarget: '//input[@value="nameless2"]', + }, + }, +)