Skip to content

Commit

Permalink
feat(keyboard): change radio group per arrow keys (#995)
Browse files Browse the repository at this point in the history
  • Loading branch information
ph-fritsche authored Jul 19, 2022
1 parent 73e4347 commit e1c22af
Show file tree
Hide file tree
Showing 4 changed files with 138 additions and 2 deletions.
27 changes: 25 additions & 2 deletions src/event/behavior/keydown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
moveSelection,
selectAll,
setSelectionRange,
walkRadio,
} from '../../utils'
import {BehaviorPlugin} from '.'
import {behavior} from './registry'
Expand All @@ -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 () => {
Expand Down
34 changes: 34 additions & 0 deletions src/utils/edit/walkRadio.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement & {type: 'radio'}>(
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')
}
}
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
78 changes: 78 additions & 0 deletions tests/event/behavior/keydown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,81 @@ cases(
},
},
)

cases(
'walk through radio group per arrow keys',
({focus, key, expectedTarget}) => {
const {getEvents, eventWasFired, xpathNode} = render(
`
<input type="radio" name="group" value="a"/>
<fieldset disabled>
<input type="radio" name="group" value="b"/>
</fieldset>
<input type="radio" name="solo"/>
<input type="radio" value="nameless1"/>
<input type="radio" name="" value="nameless2"/>
<input type="radio" name="group" value="c" disabled/>
<input type="radio" name="group" value="d"/>
<input type="radio" name="foo"/>
<input type="text" name="group"/>
`,
{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"]',
},
},
)

0 comments on commit e1c22af

Please sign in to comment.