Skip to content

Commit e1c22af

Browse files
authored
feat(keyboard): change radio group per arrow keys (#995)
1 parent 73e4347 commit e1c22af

File tree

4 files changed

+138
-2
lines changed

4 files changed

+138
-2
lines changed

src/event/behavior/keydown.ts

+25-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
moveSelection,
1414
selectAll,
1515
setSelectionRange,
16+
walkRadio,
1617
} from '../../utils'
1718
import {BehaviorPlugin} from '.'
1819
import {behavior} from './registry'
@@ -27,8 +28,30 @@ behavior.keydown = (event, target, config) => {
2728
const keydownBehavior: {
2829
[key: string]: BehaviorPlugin<'keydown'> | undefined
2930
} = {
30-
ArrowLeft: (event, target) => () => moveSelection(target, -1),
31-
ArrowRight: (event, target) => () => moveSelection(target, 1),
31+
ArrowDown: (event, target, config) => {
32+
/* istanbul ignore else */
33+
if (isElementType(target, 'input', {type: 'radio'} as const)) {
34+
return () => walkRadio(config, target, -1)
35+
}
36+
},
37+
ArrowLeft: (event, target, config) => {
38+
if (isElementType(target, 'input', {type: 'radio'} as const)) {
39+
return () => walkRadio(config, target, -1)
40+
}
41+
return () => moveSelection(target, -1)
42+
},
43+
ArrowRight: (event, target, config) => {
44+
if (isElementType(target, 'input', {type: 'radio'} as const)) {
45+
return () => walkRadio(config, target, 1)
46+
}
47+
return () => moveSelection(target, 1)
48+
},
49+
ArrowUp: (event, target, config) => {
50+
/* istanbul ignore else */
51+
if (isElementType(target, 'input', {type: 'radio'} as const)) {
52+
return () => walkRadio(config, target, 1)
53+
}
54+
},
3255
Backspace: (event, target, config) => {
3356
if (isEditable(target)) {
3457
return () => {

src/utils/edit/walkRadio.ts

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {dispatchUIEvent} from '../../event'
2+
import {Config} from '../../setup'
3+
import {focus} from '../focus/focus'
4+
import {getWindow} from '../misc/getWindow'
5+
import {isDisabled} from '../misc/isDisabled'
6+
7+
export function walkRadio(
8+
config: Config,
9+
el: HTMLInputElement & {type: 'radio'},
10+
direction: -1 | 1,
11+
) {
12+
const window = getWindow(el)
13+
const group = Array.from(
14+
el.ownerDocument.querySelectorAll<HTMLInputElement & {type: 'radio'}>(
15+
el.name
16+
? `input[type="radio"][name="${window.CSS.escape(el.name)}"]`
17+
: `input[type="radio"][name=""], input[type="radio"]:not([name])`,
18+
),
19+
)
20+
for (let i = group.findIndex(e => e === el) + direction; ; i += direction) {
21+
if (!group[i]) {
22+
i = direction > 0 ? 0 : group.length - 1
23+
}
24+
if (group[i] === el) {
25+
return
26+
}
27+
if (isDisabled(group[i])) {
28+
continue
29+
}
30+
31+
focus(group[i])
32+
dispatchUIEvent(config, group[i], 'click')
33+
}
34+
}

src/utils/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export * from './edit/input'
1010
export * from './edit/isContentEditable'
1111
export * from './edit/isEditable'
1212
export * from './edit/setFiles'
13+
export * from './edit/walkRadio'
1314

1415
export * from './focus/blur'
1516
export * from './focus/copySelection'

tests/event/behavior/keydown.ts

+78
Original file line numberDiff line numberDiff line change
@@ -299,3 +299,81 @@ cases(
299299
},
300300
},
301301
)
302+
303+
cases(
304+
'walk through radio group per arrow keys',
305+
({focus, key, expectedTarget}) => {
306+
const {getEvents, eventWasFired, xpathNode} = render(
307+
`
308+
<input type="radio" name="group" value="a"/>
309+
<fieldset disabled>
310+
<input type="radio" name="group" value="b"/>
311+
</fieldset>
312+
<input type="radio" name="solo"/>
313+
<input type="radio" value="nameless1"/>
314+
<input type="radio" name="" value="nameless2"/>
315+
<input type="radio" name="group" value="c" disabled/>
316+
<input type="radio" name="group" value="d"/>
317+
<input type="radio" name="foo"/>
318+
<input type="text" name="group"/>
319+
`,
320+
{focus},
321+
)
322+
323+
const active = document.activeElement as Element
324+
dispatchUIEvent(createConfig(), active, 'keydown', {key})
325+
326+
if (expectedTarget) {
327+
const target = xpathNode(expectedTarget)
328+
expect(getEvents('click')[0]).toHaveProperty('target', target)
329+
expect(getEvents('input')[0]).toHaveProperty('target', target)
330+
expect(target).toHaveFocus()
331+
expect(target).toBeChecked()
332+
} else {
333+
expect(eventWasFired('click')).toBe(false)
334+
expect(eventWasFired('input')).toBe(false)
335+
expect(active).toHaveFocus()
336+
}
337+
},
338+
{
339+
'per ArrowDown': {
340+
focus: '//input[@value="a"]',
341+
key: 'ArrowDown',
342+
expectedTarget: '//input[@value="d"]',
343+
},
344+
'per ArrowLeft': {
345+
focus: '//input[@value="d"]',
346+
key: 'ArrowLeft',
347+
expectedTarget: '//input[@value="a"]',
348+
},
349+
'per ArrowRight': {
350+
focus: '//input[@value="a"]',
351+
key: 'ArrowRight',
352+
expectedTarget: '//input[@value="d"]',
353+
},
354+
'per ArrowUp': {
355+
focus: '//input[@value="d"]',
356+
key: 'ArrowUp',
357+
expectedTarget: '//input[@value="a"]',
358+
},
359+
'forward around the corner': {
360+
focus: '//input[@value="d"]',
361+
key: 'ArrowRight',
362+
expectedTarget: '//input[@value="a"]',
363+
},
364+
'backward around the corner': {
365+
focus: '//input[@value="a"]',
366+
key: 'ArrowUp',
367+
expectedTarget: '//input[@value="d"]',
368+
},
369+
'do nothing on single radio': {
370+
focus: '//input[@name="solo"]',
371+
key: 'ArrowRight',
372+
},
373+
'on radios without name': {
374+
focus: '//input[@value="nameless1"]',
375+
key: 'ArrowRight',
376+
expectedTarget: '//input[@value="nameless2"]',
377+
},
378+
},
379+
)

0 commit comments

Comments
 (0)