refactor(Listbox): Keyboard navigation (#778)

* fix: trim space when parsing engine URLs

* refactor: add support for keyboard navigation

* refactor: move key handling to own file
test: cover new functionality with unit tests

* refactor: rename and fix focus

* refactor: improve filling the range

* fix: adaptations to merge conflicts

* refactor: restore style overrides for focus
This commit is contained in:
Johan Lahti
2022-03-11 14:27:54 +01:00
committed by GitHub
parent 11a705ce42
commit 6d23e245bd
8 changed files with 401 additions and 66 deletions

View File

@@ -43,7 +43,11 @@ export default function ListBox({
const [layout] = useLayout(model);
const [pages, setPages] = useState(null);
const [isLoadingData, setIsLoadingData] = useState(false);
const { instantPages = [], interactionEvents } = useSelectionsInteractions({
const {
instantPages = [],
interactionEvents,
select,
} = useSelectionsInteractions({
layout,
selections,
pages,
@@ -186,6 +190,11 @@ export default function ListBox({
checkboxes,
dense,
frequencyMode,
actions: {
select,
confirm: () => selections && selections.confirm.call(selections),
cancel: () => selections && selections.cancel.call(selections),
},
frequencyMax,
histogram,
}}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { FormControlLabel, Grid, Typography } from '@material-ui/core';
@@ -8,6 +8,7 @@ import Lock from '@nebula.js/ui/icons/lock';
import Tick from '@nebula.js/ui/icons/tick';
import ListBoxCheckbox from './ListBoxCheckbox';
import getSegmentsFromRanges from './listbox-highlight';
import getKeyboardNavigation from './listbox-keyboard-navigation';
const ellipsis = {
width: '100%',
@@ -35,6 +36,9 @@ const useStyles = makeStyles((theme) => ({
'&:focus': {
boxShadow: `inset 0 0 0 2px ${theme.palette.custom.focusBorder} !important`,
},
'&:focus-visible': {
outline: 'none',
},
},
// The interior wrapper for all field content.
@@ -153,10 +157,13 @@ export default function RowColumn({ index, style, data, column = false }) {
checkboxes = false,
dense = false,
frequencyMode = 'N',
actions,
frequencyMax = '',
histogram = false,
} = data;
const handleKeyDownCallback = useCallback(getKeyboardNavigation(actions), [actions]);
const [isSelected, setSelected] = useState(false);
const [cell, setCell] = useState();
@@ -295,11 +302,15 @@ export default function RowColumn({ index, style, data, column = false }) {
container
spacing={0}
className={joinClassNames(['value', ...classArr])}
classes={{
root: classes.fieldRoot,
}}
style={style}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseEnter={onMouseEnter}
onKeyDown={handleKeyDownCallback}
role={column ? 'column' : 'row'}
tabIndex={0}
data-n={cell && cell.qElemNumber}

View File

@@ -0,0 +1,142 @@
import getKeyboardNavigation from '../listbox-keyboard-navigation';
describe('keyboard navigation', () => {
let actions;
let sandbox;
let handleKeyDown;
before(() => {
global.document = {};
sandbox = sinon.createSandbox();
actions = {
select: sandbox.stub(),
cancel: sandbox.stub(),
confirm: sandbox.stub(),
};
});
afterEach(() => {
sandbox.reset();
});
after(() => {
sandbox.restore();
});
beforeEach(() => {
handleKeyDown = getKeyboardNavigation(actions);
});
it('select values with Space', () => {
expect(actions.select).not.called;
expect(actions.confirm).not.called;
expect(actions.cancel).not.called;
// Space should select values
const event = {
nativeEvent: { keyCode: 32 },
currentTarget: { getAttribute: sandbox.stub().withArgs('data-n').returns(1) },
preventDefault: sandbox.stub(),
stopPropagation: sandbox.stub(),
};
handleKeyDown(event);
expect(actions.select).calledOnce.calledWithExactly([1]);
expect(event.preventDefault).calledOnce;
expect(event.stopPropagation).not.called;
});
it('confirm selections with Enter', () => {
const eventConfirm = {
nativeEvent: { keyCode: 13 },
preventDefault: sandbox.stub(),
};
handleKeyDown(eventConfirm);
expect(actions.confirm).calledOnce;
});
it('cancel selections with Escape', () => {
const eventCancel = {
nativeEvent: { keyCode: 27 },
preventDefault: sandbox.stub(),
};
const blur = sandbox.stub();
global.document.activeElement = { blur };
handleKeyDown(eventCancel);
expect(actions.cancel).calledOnce;
expect(blur).calledOnce;
});
it('arrow up should move focus upwards', () => {
const focus = sandbox.stub();
const eventArrowUp = {
nativeEvent: { keyCode: 38 },
currentTarget: {
parentElement: {
previousElementSibling: {
querySelector: () => ({ focus }),
},
},
},
preventDefault: sandbox.stub(),
};
handleKeyDown(eventArrowUp);
expect(focus).calledOnce;
});
it('arrow down should move focus downwards', () => {
const focus = sandbox.stub();
const eventArrowDown = {
nativeEvent: { keyCode: 40 },
currentTarget: {
parentElement: {
nextElementSibling: {
querySelector: () => ({ focus }),
},
},
},
preventDefault: sandbox.stub(),
};
handleKeyDown(eventArrowDown);
expect(focus).calledOnce;
});
it('arrow down with Shift should range select values downwards (current and next element)', () => {
const focus = sandbox.stub();
expect(actions.select).not.called;
const eventArrowDown = {
nativeEvent: { keyCode: 40, shiftKey: true },
currentTarget: {
parentElement: {
nextElementSibling: {
querySelector: () => ({ focus, getAttribute: sandbox.stub().withArgs('data-n').returns(2) }),
},
},
getAttribute: sandbox.stub().withArgs('data-n').returns(1),
},
preventDefault: sandbox.stub(),
};
handleKeyDown(eventArrowDown);
expect(focus).calledOnce;
expect(actions.select).calledOnce.calledWithExactly([2], true);
});
it('arrow up with Shift should range select values upwards (current and previous element)', () => {
const focus = sandbox.stub();
expect(actions.select).not.called;
const eventArrowUp = {
nativeEvent: { keyCode: 38, shiftKey: true },
currentTarget: {
getAttribute: sandbox.stub().withArgs('data-n').returns(2),
parentElement: {
previousElementSibling: {
querySelector: () => ({ focus, getAttribute: sandbox.stub().withArgs('data-n').returns(1) }),
},
},
},
preventDefault: sandbox.stub(),
};
handleKeyDown(eventArrowUp);
expect(focus).calledOnce;
expect(actions.select).calledOnce.calledWithExactly([1], true);
});
});

View File

@@ -3,6 +3,7 @@ import renderer from 'react-test-renderer';
import { Grid, Typography } from '@material-ui/core';
import Lock from '@nebula.js/ui/icons/lock';
import ListBoxCheckbox from '../ListBoxCheckbox';
import * as getKeyboardNavigation from '../listbox-keyboard-navigation';
const [{ default: ListBoxRowColumn }] = aw.mock(
[
@@ -31,6 +32,25 @@ async function render(content) {
}
describe('<ListBoxRowColumn />', () => {
let sandbox;
let actions;
let handleKeyDown;
before(() => {
global.document = {};
sandbox = sinon.createSandbox();
handleKeyDown = sandbox.stub(getKeyboardNavigation, 'default').returns(() => 'handle-key-down-callback');
actions = 'actions';
});
afterEach(() => {
sandbox.reset();
});
after(() => {
sandbox.restore();
});
describe('as row', () => {
const rowCol = 'row';
@@ -38,12 +58,14 @@ describe('<ListBoxRowColumn />', () => {
const index = 0;
const style = {};
const data = {
onMouseDown: sinon.spy(),
onMouseUp: sinon.spy(),
onMouseEnter: sinon.spy(),
onClick: sinon.spy(),
onMouseDown: sandbox.spy(),
onMouseUp: sandbox.spy(),
onMouseEnter: sandbox.spy(),
onClick: sandbox.spy(),
pages: [],
actions,
};
expect(handleKeyDown).not.called;
const testRenderer = await render(
<ListBoxRowColumn index={index} style={style} data={data} column={rowCol === 'column'} />
);
@@ -54,6 +76,7 @@ describe('<ListBoxRowColumn />', () => {
expect(type.props.spacing).to.equal(0);
expect(type.props.style).to.deep.equal({});
expect(type.props.role).to.equal(rowCol);
expect(type.props.onKeyDown).to.be.a('function');
expect(type.props.onMouseDown.callCount).to.equal(0);
expect(type.props.onMouseUp.callCount).to.equal(0);
expect(type.props.onMouseEnter.callCount).to.equal(0);
@@ -67,16 +90,20 @@ describe('<ListBoxRowColumn />', () => {
const cbs = testInstance.findAllByType(ListBoxCheckbox);
expect(cbs).to.have.length(0);
await testRenderer.unmount();
expect(handleKeyDown).calledOnce.calledWith('actions');
});
it('should have css class `value`', async () => {
const index = 0;
const style = {};
const data = {
onMouseDown: sinon.spy(),
onMouseUp: sinon.spy(),
onMouseEnter: sinon.spy(),
onClick: sinon.spy(),
onMouseDown: sandbox.spy(),
onMouseUp: sandbox.spy(),
onMouseEnter: sandbox.spy(),
onClick: sandbox.spy(),
pages: [],
actions,
};
const testRenderer = await render(
<ListBoxRowColumn index={index} style={style} data={data} column={rowCol === 'column'} />
@@ -89,16 +116,18 @@ describe('<ListBoxRowColumn />', () => {
expect(className.split(' ')).to.include('value');
await testRenderer.unmount();
});
it('should render with checkboxes', async () => {
const index = 0;
const style = {};
const data = {
checkboxes: true,
onMouseDown: sinon.spy(),
onMouseUp: sinon.spy(),
onMouseEnter: sinon.spy(),
onClick: sinon.spy(),
onMouseDown: sandbox.spy(),
onMouseUp: sandbox.spy(),
onMouseEnter: sandbox.spy(),
onClick: sandbox.spy(),
pages: [],
actions,
};
const testRenderer = await render(
<ListBoxRowColumn index={index} style={style} data={data} column={rowCol === 'column'} />
@@ -130,10 +159,11 @@ describe('<ListBoxRowColumn />', () => {
const style = {};
const data = {
isLocked: true,
onMouseDown: sinon.spy(),
onMouseUp: sinon.spy(),
onMouseEnter: sinon.spy(),
onClick: sinon.spy(),
onMouseDown: sandbox.spy(),
onMouseUp: sandbox.spy(),
onMouseEnter: sandbox.spy(),
onClick: sandbox.spy(),
actions,
pages: [
{
qArea: {
@@ -163,14 +193,16 @@ describe('<ListBoxRowColumn />', () => {
expect(type.props.size).to.equal('small');
await testRenderer.unmount();
});
it('should set selected', async () => {
const index = 0;
const style = {};
const data = {
onMouseDown: sinon.spy(),
onMouseUp: sinon.spy(),
onMouseEnter: sinon.spy(),
onClick: sinon.spy(),
onMouseDown: sandbox.spy(),
onMouseUp: sandbox.spy(),
onMouseEnter: sandbox.spy(),
onClick: sandbox.spy(),
actions,
pages: [
{
qArea: {
@@ -197,14 +229,16 @@ describe('<ListBoxRowColumn />', () => {
expect(type.props.className).to.include('selected');
await testRenderer.unmount();
});
it('should set alternative', async () => {
const index = 0;
const style = {};
const data = {
onMouseDown: sinon.spy(),
onMouseUp: sinon.spy(),
onMouseEnter: sinon.spy(),
onClick: sinon.spy(),
onMouseDown: sandbox.spy(),
onMouseUp: sandbox.spy(),
onMouseEnter: sandbox.spy(),
onClick: sandbox.spy(),
actions,
pages: [
{
qArea: {
@@ -231,14 +265,16 @@ describe('<ListBoxRowColumn />', () => {
expect(type.props.className).to.include('alternative');
await testRenderer.unmount();
});
it('should set excluded - qState X', async () => {
const index = 0;
const style = {};
const data = {
onMouseDown: sinon.spy(),
onMouseUp: sinon.spy(),
onMouseEnter: sinon.spy(),
onClick: sinon.spy(),
onMouseDown: sandbox.spy(),
onMouseUp: sandbox.spy(),
onMouseEnter: sandbox.spy(),
onClick: sandbox.spy(),
actions,
pages: [
{
qArea: {
@@ -265,14 +301,16 @@ describe('<ListBoxRowColumn />', () => {
expect(type.props.className).to.include('excluded');
await testRenderer.unmount();
});
it('should set excluded - qState XS', async () => {
const index = 0;
const style = {};
const data = {
onMouseDown: sinon.spy(),
onMouseUp: sinon.spy(),
onMouseEnter: sinon.spy(),
onClick: sinon.spy(),
onMouseDown: sandbox.spy(),
onMouseUp: sandbox.spy(),
onMouseEnter: sandbox.spy(),
onClick: sandbox.spy(),
actions,
pages: [
{
qArea: {
@@ -299,14 +337,16 @@ describe('<ListBoxRowColumn />', () => {
expect(type.props.className).to.include('excluded');
await testRenderer.unmount();
});
it('should set excluded - qState XL', async () => {
const index = 0;
const style = {};
const data = {
onMouseDown: sinon.spy(),
onMouseUp: sinon.spy(),
onMouseEnter: sinon.spy(),
onClick: sinon.spy(),
onMouseDown: sandbox.spy(),
onMouseUp: sandbox.spy(),
onMouseEnter: sandbox.spy(),
onClick: sandbox.spy(),
actions,
pages: [
{
qArea: {
@@ -333,14 +373,16 @@ describe('<ListBoxRowColumn />', () => {
expect(type.props.className).to.include('excluded');
await testRenderer.unmount();
});
it('should highlight ranges', async () => {
const index = 0;
const style = {};
const data = {
onMouseDown: sinon.spy(),
onMouseUp: sinon.spy(),
onMouseEnter: sinon.spy(),
onClick: sinon.spy(),
onMouseDown: sandbox.spy(),
onMouseUp: sandbox.spy(),
onMouseEnter: sandbox.spy(),
onClick: sandbox.spy(),
actions,
pages: [
{
qArea: {
@@ -373,14 +415,16 @@ describe('<ListBoxRowColumn />', () => {
expect(types[1].props.children.props.children).to.equal(' ftw');
await testRenderer.unmount();
});
it('should highlight ranges', async () => {
const index = 0;
const style = {};
const data = {
onMouseDown: sinon.spy(),
onMouseUp: sinon.spy(),
onMouseEnter: sinon.spy(),
onClick: sinon.spy(),
onMouseDown: sandbox.spy(),
onMouseUp: sandbox.spy(),
onMouseEnter: sandbox.spy(),
onClick: sandbox.spy(),
actions,
pages: [
{
qArea: {
@@ -415,14 +459,16 @@ describe('<ListBoxRowColumn />', () => {
expect(hits).to.have.length(2);
await testRenderer.unmount();
});
it('should highlight ranges', async () => {
const index = 0;
const style = {};
const data = {
onMouseDown: sinon.spy(),
onMouseUp: sinon.spy(),
onMouseEnter: sinon.spy(),
onClick: sinon.spy(),
onMouseDown: sandbox.spy(),
onMouseUp: sandbox.spy(),
onMouseEnter: sandbox.spy(),
onClick: sandbox.spy(),
actions,
pages: [
{
qArea: {
@@ -456,14 +502,16 @@ describe('<ListBoxRowColumn />', () => {
expect(types[2].props.children.props.children).to.equal(' buddy');
await testRenderer.unmount();
});
it('should show frequency when enabled', async () => {
const index = 0;
const style = {};
const data = {
onMouseDown: sinon.spy(),
onMouseUp: sinon.spy(),
onMouseEnter: sinon.spy(),
onClick: sinon.spy(),
onMouseDown: sandbox.spy(),
onMouseUp: sandbox.spy(),
onMouseEnter: sandbox.spy(),
onClick: sandbox.spy(),
actions,
frequencyMode: 'value',
pages: [
{
@@ -497,6 +545,7 @@ describe('<ListBoxRowColumn />', () => {
const style = {};
const data = {
checkboxes: true,
actions,
pages: [
{
qArea: {
@@ -541,11 +590,12 @@ describe('<ListBoxRowColumn />', () => {
const index = 0;
const style = {};
const data = {
onMouseDown: sinon.spy(),
onMouseUp: sinon.spy(),
onMouseEnter: sinon.spy(),
onClick: sinon.spy(),
onMouseDown: sandbox.spy(),
onMouseUp: sandbox.spy(),
onMouseEnter: sandbox.spy(),
onClick: sandbox.spy(),
pages: [],
actions,
};
const testRenderer = await render(
<ListBoxRowColumn index={index} style={style} data={data} column={rowCol === 'column'} />
@@ -571,15 +621,17 @@ describe('<ListBoxRowColumn />', () => {
expect(cbs).to.have.length(0);
await testRenderer.unmount();
});
it('should have css class `value`', async () => {
const index = 0;
const style = {};
const data = {
onMouseDown: sinon.spy(),
onMouseUp: sinon.spy(),
onMouseEnter: sinon.spy(),
onClick: sinon.spy(),
onMouseDown: sandbox.spy(),
onMouseUp: sandbox.spy(),
onMouseEnter: sandbox.spy(),
onClick: sandbox.spy(),
pages: [],
actions,
};
const testRenderer = await render(
<ListBoxRowColumn index={index} style={style} data={data} column={rowCol === 'column'} />

View File

@@ -88,26 +88,39 @@ describe('use-listbox-interactions', () => {
it('Without range', async () => {
await render();
const arg0 = ref.current.result;
expect(Object.keys(arg0).sort()).to.deep.equal(['instantPages', 'interactionEvents']);
expect(Object.keys(arg0).sort()).to.deep.equal(['instantPages', 'interactionEvents', 'select']);
expect(arg0.instantPages).to.deep.equal([]);
expect(Object.keys(arg0.interactionEvents).sort()).to.deep.equal(['onMouseDown', 'onMouseUp']);
});
it('With range', async () => {
await render({ rangeSelect: true });
const arg0 = ref.current.result;
expect(Object.keys(arg0).sort()).to.deep.equal(['instantPages', 'interactionEvents']);
expect(Object.keys(arg0).sort()).to.deep.equal(['instantPages', 'interactionEvents', 'select']);
expect(arg0.instantPages).to.deep.equal([]);
expect(Object.keys(arg0.interactionEvents).sort()).to.deep.equal(['onMouseDown', 'onMouseEnter', 'onMouseUp']);
});
it('With checkboxes', async () => {
await render({ checkboxes: true });
const arg0 = ref.current.result;
expect(Object.keys(arg0).sort()).to.deep.equal(['instantPages', 'interactionEvents']);
expect(Object.keys(arg0).sort()).to.deep.equal(['instantPages', 'interactionEvents', 'select']);
expect(arg0.instantPages).to.deep.equal([]);
expect(Object.keys(arg0.interactionEvents).sort()).to.deep.equal(['onClick']);
});
});
it('Should manually pre-select and select values when calling the manual select method', async () => {
await render();
const args = ref.current.result;
expect(listboxSelections.selectValues).not.called;
args.select([1]);
expect(listboxSelections.selectValues).calledOnce;
expect(listboxSelections.selectValues.args[0][0]).to.deep.equal({
elemNumbers: [1],
isSingleSelect: false,
selections: { key: 'selections' },
});
});
it('should select a value', async () => {
await render();
const arg0 = ref.current.result;
@@ -184,7 +197,7 @@ describe('use-listbox-interactions', () => {
it('should return expected stuff', async () => {
await render({ rangeSelect: true });
const arg0 = ref.current.result;
expect(Object.keys(arg0)).to.deep.equal(['instantPages', 'interactionEvents']);
expect(Object.keys(arg0)).to.deep.equal(['instantPages', 'interactionEvents', 'select']);
expect(arg0.instantPages).to.deep.equal([]);
expect(Object.keys(arg0.interactionEvents).sort()).to.deep.equal(['onMouseDown', 'onMouseEnter', 'onMouseUp']);
});

View File

@@ -0,0 +1,65 @@
import KEYS from '../../keys';
const getElement = (elm, next = false) => {
const parentElm = elm && elm.parentElement[next ? 'nextElementSibling' : 'previousElementSibling'];
return parentElm && parentElm.querySelector('[role]');
};
export default function getKeyboardNavigation({ select, confirm, cancel }) {
let startedRange = false;
const setStartedRange = (val) => {
startedRange = val;
};
const handleKeyDown = (event) => {
let elementToFocus;
const { keyCode, shiftKey = false } = event.nativeEvent;
switch (keyCode) {
case KEYS.SHIFT:
// This is to ensure we include the first value when starting a range selection.
setStartedRange(true);
break;
case KEYS.SPACE:
select([+event.currentTarget.getAttribute('data-n')]);
break;
case KEYS.ARROW_DOWN:
case KEYS.ARROW_RIGHT:
elementToFocus = getElement(event.currentTarget, true);
if (shiftKey && elementToFocus) {
if (startedRange) {
select([+event.currentTarget.getAttribute('data-n')], true);
setStartedRange(false);
}
select([+elementToFocus.getAttribute('data-n')], true);
}
break;
case KEYS.ARROW_UP:
case KEYS.ARROW_LEFT:
elementToFocus = getElement(event.currentTarget, false);
if (shiftKey && elementToFocus) {
if (startedRange) {
select([+event.currentTarget.getAttribute('data-n')], true);
setStartedRange(false);
}
select([+elementToFocus.getAttribute('data-n')], true);
}
break;
case KEYS.ENTER:
confirm();
break;
case KEYS.ESCAPE:
cancel();
if (document.activeElement) {
document.activeElement.blur();
}
break;
default:
}
if (elementToFocus) {
elementToFocus.focus();
}
event.preventDefault();
};
return handleKeyDown;
}

View File

@@ -53,6 +53,14 @@ export default function useSelectionsInteractions({
});
};
const selectManually = (elementIds = [], additive = false) => {
setMouseDown(true);
preSelect(elementIds, additive || isRangeSelection);
const p = select(elementIds, additive || isRangeSelection);
setMouseDown(false);
return p;
};
const onClick = useCallback(
(event) => {
if (selectingValues || selectDisabled()) {
@@ -169,5 +177,6 @@ export default function useSelectionsInteractions({
return {
instantPages,
interactionEvents,
select: selectManually, // preselect and select without having to trigger an event
};
}

34
apis/nucleus/src/keys.js Normal file
View File

@@ -0,0 +1,34 @@
const KEYS = Object.freeze({
ENTER: 13,
ESCAPE: 27,
SPACE: 32,
TAB: 9,
BACKSPACE: 8,
DELETE: 46,
ALT: 18,
CTRL: 17,
SHIFT: 16,
ARROW_UP: 38,
ARROW_DOWN: 40,
ARROW_LEFT: 37,
ARROW_RIGHT: 39,
PAGE_DOWN: 34,
PAGE_UP: 33,
HOME: 36,
END: 35,
F10: 121,
A: 65,
F: 70,
ZERO: 48,
NINE: 57,
NUMPAD_ZERO: 96,
NUMPAD_NINE: 105,
SUBTRACTION: 189,
DECIMAL: 190,
NUMPAD_DECIMAL: 110,
isArrow: (key) =>
key === KEYS.ARROW_UP || key === KEYS.ARROW_DOWN || key === KEYS.ARROW_LEFT || key === KEYS.ARROW_RIGHT,
});
export default KEYS;