Skip to content

Commit

Permalink
refactor(Listbox): Keyboard navigation (#778)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
johanlahti committed Mar 11, 2022
1 parent 11a705c commit 6d23e24
Show file tree
Hide file tree
Showing 8 changed files with 401 additions and 66 deletions.
11 changes: 10 additions & 1 deletion apis/nucleus/src/components/listbox/ListBox.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}}
Expand Down
13 changes: 12 additions & 1 deletion apis/nucleus/src/components/listbox/ListBoxRowColumn.jsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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%',
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});

0 comments on commit 6d23e24

Please sign in to comment.