Skip to content

Commit

Permalink
feat: improved keyboard navigation for SingleSelectionBoxes
Browse files Browse the repository at this point in the history
 - Trigger validation whenever focus leaves component
 - Ignore key events fired by held-down keys
  • Loading branch information
superskip committed Sep 11, 2023
1 parent 4a600bd commit b44e8ce
Show file tree
Hide file tree
Showing 7 changed files with 118 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,11 @@ type Props = {

class BooleanFieldPlain extends React.Component<Props> {
render() {
const { onBlur, ...passOnProps } = this.props;
return (
// $FlowFixMe[cannot-spread-inexact] automated comment
<UIBooleanField
onSelect={onBlur}
{...passOnProps}
onSelect={this.props.onBlur}
{...this.props}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import React, { Component } from 'react';
import i18n from '@dhis2/d2-i18n';
import { SelectionBoxes } from '../SelectionBoxes/SelectionBoxes.component';
import type { OptionRendererInputData } from '../internal/SelectionBoxes/selectBoxes.types';
import { withKeyboardNavigation } from './withKeyboardNavigation';

const SelectionBoxesWrapped = withKeyboardNavigation()(SelectionBoxes);

type Props = {
allowMultiple?: boolean,
Expand Down Expand Up @@ -38,7 +41,7 @@ export class BooleanField extends Component<Props> {
return (
<div>
{/* $FlowFixMe[cannot-spread-inexact] automated comment */}
<SelectionBoxes
<SelectionBoxesWrapped
options={this.options}
multiSelect={allowMultiple}
{...passOnProps}
Expand Down
2 changes: 2 additions & 0 deletions src/core_modules/capture-ui/BooleanField/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// @flow
export type { KeyboardManager } from './withKeyboardNavigation';
38 changes: 38 additions & 0 deletions src/core_modules/capture-ui/BooleanField/withKeyboardNavigation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// @flow
import React, { type ComponentType, useRef, useMemo } from 'react';

const managedKeys = ['Tab', ' ', 'Enter', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'F5'];

export type KeyboardManager = {
keyDown: (key: string) => boolean,
keyUp: (key: string) => void,
clear: () => void,
};

export const withKeyboardNavigation = () => (InnerComponent: ComponentType<any>) => (props: any) => {
const pressedKeys = useRef(new Set);

const keyboardManager = useMemo(() => ({
keyDown: (key: string) => {
if (!managedKeys.includes(key)) {
return false;
}
const count = pressedKeys.current.size;
pressedKeys.current.add(key);
return count === 0;
},
keyUp: (key: string) => {
pressedKeys.current.delete(key);
},
clear: () => {
pressedKeys.current.clear();
},
}), [pressedKeys]);

return (
<InnerComponent
keyboardManager={keyboardManager}
{...props}
/>
);
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @flow
import * as React from 'react';
import classNames from 'classnames';
import type { KeyboardManager } from 'capture-ui/BooleanField';
import defaultClasses from './singleSelectBox.module.css';
import type { OptionRendererInputData } from '../../selectBoxes.types';

Expand All @@ -10,47 +11,62 @@ type Props = {
groupId: string,
children: React.Node,
onSelect: (value: any) => void,
onBlur: () => void,
inputRef?: (instance: ?HTMLInputElement) => void,
inFocus?: ?boolean,
focusClass?: string,
unFocusClass?: string,
keyboardManager: KeyboardManager,
disabled?: ?boolean,
};

const keyboardKeys = {
const keys = {
TAB: 'Tab',
SPACE: ' ',
ENTER: 'Enter',
ARROW_LEFT: 'ArrowLeft',
ARROW_RIGHT: 'ArrowRight',
ARROW_UP: 'ArrowUp',
ARROW_DOWN: 'ArrowDown',
};

let ignoreFlag = false;

export class SingleSelectBox extends React.Component<Props> {
isSpaceClickWhenSelected: ?boolean; // Pressing space when the radio is already selected, triggers both onKeyPress and onClick. This variable is used to prevent the onClick event in these circumstances.
handleSelect = () => {
if (this.isSpaceClickWhenSelected) {
this.isSpaceClickWhenSelected = false;
return;
}

const { onSelect, optionData, isSelected } = this.props;
if (isSelected) {
onSelect(null);
return;
if (ignoreFlag) {
ignoreFlag = false;
} else {
onSelect(isSelected ? null : optionData.value);
}
onSelect(optionData.value);
}

handleKeyPress = (event: SyntheticKeyboardEvent<HTMLInputElement>) => {
if ([keyboardKeys.SPACE, keyboardKeys.ENTER].includes(event.key)) {
handleKeyDown = (event: SyntheticKeyboardEvent<HTMLInputElement>) => {
const { keyboardManager } = this.props;
const handleKeyPress = keyboardManager.keyDown(event.key);
if (!handleKeyPress) {
event.preventDefault();
return;
}
if (event.key == keys.TAB) {
keyboardManager.clear();
ignoreFlag = false;
} else if ([keys.SPACE, keys.ENTER].includes(event.key)) {
const { onSelect, optionData, isSelected } = this.props;
if (isSelected) {
this.isSpaceClickWhenSelected = event.key === keyboardKeys.SPACE;
onSelect(null);
return;
onSelect(isSelected ? null : optionData.value);
if (event.key === keys.SPACE) {
ignoreFlag = true;
}
this.isSpaceClickWhenSelected = false;
onSelect(optionData.value);
} else {
ignoreFlag = true;
}
}

handleKeyUp = (event: SyntheticKeyboardEvent<HTMLInputElement>) => {
this.props.keyboardManager.keyUp(event.key);
}

render() {
const {
optionData,
Expand Down Expand Up @@ -82,8 +98,9 @@ export class SingleSelectBox extends React.Component<Props> {
checked={isSelected}
value={optionData.value}
onClick={this.handleSelect}
onKeyDown={this.handleKeyDown}
onKeyUp={this.handleKeyUp}
onChange={() => {}}
onKeyPress={this.handleKeyPress}
disabled={disabled}
{...passOnProps}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import * as React from 'react';

type Props = {
setInputRef: (element: HTMLInputElement) => void,
onSetFocus?: () => void,
onRemoveFocus?: () => void,
inFocus: boolean,
Expand Down Expand Up @@ -66,10 +67,11 @@ export const withFocusHandler = () => (InnerComponent: React.ComponentType<any>)

setInputInstance = (instance: HTMLInputElement) => {
this.inputInstance = instance;
this.props.setInputRef(instance);
}

render() {
const { onSetFocus, onRemoveFocus, inFocus, ...passOnProps } = this.props;
const { setInputRef, onSetFocus, onRemoveFocus, inFocus, ...passOnProps } = this.props;
return (
// $FlowFixMe[cannot-spread-inexact] automated comment
<InnerComponent
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @flow
import * as React from 'react';
import classNames from 'classnames';
import type { KeyboardManager } from 'capture-ui/BooleanField';
import { SingleSelectionCheckedIcon, SingleSelectionUncheckedIcon } from '../../../Icons';
import { SingleSelectBox } from './SingleSelectBox/SingleSelectBox.component';
import { withFocusHandler } from './SingleSelectBox/withFocusHandler';
Expand All @@ -26,16 +27,37 @@ type Props = {
unFocus?: string,
},
onSelect: (value: any) => void,
onBlur: () => void;
onSetFocus?: () => void,
onRemoveFocus?: () => void,
keyboardManager: KeyboardManager,
disabled?: ?boolean,
};

export class SingleSelectionBoxes extends React.Component<Props> {
type State = {
refList: Array<HTMLInputElement>,
};

export class SingleSelectionBoxes extends React.Component<Props, State> {
static getFocusClass(classes: Object, isSelected: boolean) {
return isSelected ? classes.focusSelected : classes.focusUnselected;
}

refList: Array<HTMLInputElement>;
constructor(props: Props) {
super(props);
this.state = {
refList: [],
};
}

onBlur = (event: SyntheticFocusEvent<HTMLInputElement>) => {
if (!this.state.refList.includes(event.relatedTarget)) {
this.props.keyboardManager.clear();
this.props.onBlur();
}
}

getCheckedClass = (iconSelected: ?string, iconDisabled?: string, isDisabled: ?boolean) => classNames(
iconSelected,
iconDisabled && { [iconDisabled]: isDisabled },
Expand Down Expand Up @@ -85,29 +107,36 @@ export class SingleSelectionBoxes extends React.Component<Props> {
}

getOption(optionData: OptionRendererInputData, isSelected: boolean, index: number) {
const { orientation, id: groupId, value, onSelect, classes, onSetFocus, onRemoveFocus, disabled } = this.props;
const { orientation, id: groupId, value, onSelect, classes, onSetFocus, onRemoveFocus, keyboardManager, disabled } = this.props;
const containerClass = orientation === orientations.HORIZONTAL ?
defaultClasses.optionContainerHorizontal : defaultClasses.optionContainerVertical;
const tabIndex = isSelected || (index === 0 && !value && value !== false && value !== 0) ? 0 : -1;
const IconElement = this.getIconElement(optionData, isSelected);
const setInputRef = (element: HTMLInputElement) => {
this.setState((state) => {
state.refList[index] = element;
});
};

return (
<div
className={containerClass}
key={optionData.id || optionData.name}
>
{ /* $FlowSuppress */ }
{/* $FlowFixMe[prop-missing] automated comment */}
<SingleSelectBoxWrapped
setInputRef={setInputRef}
optionData={optionData}
isSelected={isSelected}
tabIndex={tabIndex}
groupId={groupId}
onSelect={onSelect}
onBlur={this.onBlur}
focusClass={classes && SingleSelectionBoxes.getFocusClass(classes, isSelected)}
unFocusClass={classes && classes.unFocus}
onSetFocus={onSetFocus}
onRemoveFocus={onRemoveFocus}
keyboardManager={keyboardManager}
disabled={disabled}
>
{IconElement}
Expand Down

0 comments on commit b44e8ce

Please sign in to comment.