Skip to content

Commit

Permalink
feat: add keyboard support for cyclic button and allow keyboard navig…
Browse files Browse the repository at this point in the history
…ation to more button (#1569)

* feat: introduce keyboard support for cyclic button

* fix: wip

* feat: add keyboard support for cyclic button and allow keyboard navigation to more button

* fix: add break

* fix: add undefined

* fix: bring blur

* fix: change onClick

* fix: focus back to list box container

* fix: focus cyclic button at first space

* fix: focus on last-focused row

* fix: close toolbar on escape

* fix: failed test case

* fix: return focus to listbox container when there is nothing to focus

* fix: add unit test

* fix: remove unnecessary code

* fix: try to restore the old behavior

* fix: keyboard nav in global selection

* fix: blur back to listbox

* fix: restore the old logic inSelection

* fix: add check for detached toolbar case

* fix: pr comment

* fix: unit test

* fix: remove unused input

* fix: support in mashup

* fix: faild test case

* fix: failed test case

* fix: add integration test

* fix: failed test case

* fix: update snapshot
  • Loading branch information
linhnguyen-qlik authored Jul 4, 2024
1 parent ae7370d commit 22c64bc
Show file tree
Hide file tree
Showing 16 changed files with 320 additions and 92 deletions.
1 change: 1 addition & 0 deletions apis/nucleus/src/components/listbox/ListBoxInline.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ function ListBoxInline({ options, layout }) {
appSelections,
constraints,
isModal: isModalMode,
selections,
}),
[
keyboard,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ const StyledButton = styled(Button)(() => ({
padding: 0,
}));

function DimensionIcon({ iconData, translator, iconStyle, disabled }) {
function DimensionIcon({ iconData, translator, iconStyle, disabled, keyboard }) {
if (!iconData) return undefined;

const { icon, tooltip, onClick } = iconData;
const { icon, tooltip, onClick = undefined, onKeyDown = undefined } = iconData;
const Icon = icon;
const title = translator.get(tooltip);
const isButton = onClick;
Expand All @@ -22,11 +22,14 @@ function DimensionIcon({ iconData, translator, iconStyle, disabled }) {
<StyledButton
variant="outlined"
onClick={onClick}
tabIndex={-1}
tabIndex={keyboard.innerTabStops ? 0 : -1}
title={title}
size="large"
disableRipple
disabled={disabled}
onKeyDown={onKeyDown}
className="listbox-cyclic-button"
data-testid="listbox-cyclic-button"
>
<Icon style={iconStyle} />
</StyledButton>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export default function ListBoxHeader({
selections,
isPopover,
active: !constraints?.active,
keyboard,
});
const showUnlock = showLock && isLocked;
const showLockIcon = !showLock && isLocked; // shows instead of the cover button when field/dim is locked.
Expand Down Expand Up @@ -216,6 +217,7 @@ export default function ListBoxHeader({
iconStyle={iconStyle}
disabled={selections.isActive() && isPopover}
translator={translator}
keyboard={keyboard}
/>
</Grid>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import CyclicIcon from '@nebula.js/ui/icons/cyclic';
import DrillDownIcon from '@nebula.js/ui/icons/drill-down';
import ReloadIcon from '@nebula.js/ui/icons/reload';
import KEYS from '../../../../keys';
import { blur, focusRow, focusSearch } from '../../interactions/keyboard-navigation/keyboard-nav-methods';

const dimensionTypes = {
single: 'N',
drillDown: 'H',
cyclic: 'C',
};

const createDimensionIconData = ({ dimInfo, app, selections, isPopover, active }) => {
const createDimensionIconData = ({ dimInfo, app, selections, isPopover, active, keyboard }) => {
switch (dimInfo.qGrouping) {
case dimensionTypes.drillDown:
return {
Expand All @@ -18,25 +20,56 @@ const createDimensionIconData = ({ dimInfo, app, selections, isPopover, active }
};
case dimensionTypes.cyclic: {
const clickable = app && active;
const stepToNextField = () => {
if (!isPopover) {
selections.confirm();
}
app
.getDimension(dimInfo.qLibraryId)
.then((dimensionModel) => {
if (!dimensionModel.stepCycle) {
// eslint-disable-next-line no-console
console.log("engine api spec version doesn't have support for function stepCycle");
return;
}
dimensionModel.stepCycle(1);
})
.catch(() => null);
};
return {
icon: clickable ? ReloadIcon : CyclicIcon,
tooltip: 'Listbox.Cyclic',
onClick: clickable
? () => {
if (!isPopover) {
selections.confirm();
}
app
.getDimension(dimInfo.qLibraryId)
.then((dimensionModel) => {
if (!dimensionModel.stepCycle) {
// eslint-disable-next-line no-console
console.log("engine api spec version doesn't have support for function stepCycle");
return;
onClick: clickable ? stepToNextField : undefined,
onKeyDown: clickable
? (event) => {
const container = event.currentTarget.closest('.listbox-container');
switch (event.keyCode) {
case KEYS.SPACE:
case KEYS.ENTER:
stepToNextField();
break;
case KEYS.TAB:
{
const useDefaultBrowserSupport = !keyboard.enabled;
if (useDefaultBrowserSupport) {
return;
}
let focused;
if (event.shiftKey) {
focused = keyboard.focusSelection();
} else {
focused = focusSearch(container) || focusRow(container);
}
if (!focused) {
blur(event, keyboard);
}
}
dimensionModel.stepCycle(1);
})
.catch(() => null);
break;
default:
return;
}
event.preventDefault();
event.stopPropagation();
}
: undefined,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ describe('check keyboard navigation rendering with multiple rows in the in-built

it('focus should move from row to confirm button when tabbing', async () => {
data.isModal.mockReturnValue(true);
data.keyboard = useTempKeyboard({ containerRef, enabled: true });
render(
<ThemeProvider theme={theme}>
<div className="actions-toolbar-default-actions">
Expand Down
21 changes: 4 additions & 17 deletions apis/nucleus/src/components/listbox/components/ListBoxSearch.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Close from '@nebula.js/ui/icons/close';
import InstanceContext from '../../../contexts/InstanceContext';
import useDataStore from '../hooks/useDataStore';
import { CELL_PADDING_LEFT } from '../constants';
import { focusCyclicButton, focusRow } from '../interactions/keyboard-navigation/keyboard-nav-methods';

const MAX_SEARCH_LENGTH = 64000;
const TREE_PATH = '/qListObjectDef';
Expand Down Expand Up @@ -174,12 +175,6 @@ export default function ListBoxSearch({
return response;
};

function focusRow(container) {
const row = container?.querySelector('.last-focused') || container?.querySelector('[role="row"]:first-child');
row?.setAttribute('tabIndex', 0);
row?.focus();
}

const onKeyDown = async (e) => {
const { currentTarget } = e;
const container = currentTarget.closest('.listbox-container');
Expand All @@ -197,17 +192,14 @@ export default function ListBoxSearch({
}
case 'Tab': {
if (e.shiftKey) {
keyboard.focusSelection();
if (!focusCyclicButton(container)) {
keyboard.focusSelection();
}
} else if (clearSearchRef.current) {
clearSearchRef.current.focus();
} else {
// Focus the row we last visited or the first one.
focusRow(container);

// Clean up.
container?.querySelectorAll('.last-focused').forEach((elm) => {
elm.classList.remove('last-focused');
});
}
break;
}
Expand Down Expand Up @@ -252,11 +244,6 @@ export default function ListBoxSearch({
} else {
// Focus the row we last visited or the first one.
focusRow(container);

// Clean up.
container?.querySelectorAll('.last-focused').forEach((elm) => {
elm.classList.remove('last-focused');
});
}
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import DrillDownIcon from '@nebula.js/ui/icons/drill-down';
import ReloadIcon from '@nebula.js/ui/icons/reload';
import CyclicIcon from '@nebula.js/ui/icons/cyclic';
import utils from '../ListBoxHeader/icon-utils';
import KEYS from '../../../../keys';
import * as keyboardNavMethod from '../../interactions/keyboard-navigation/keyboard-nav-methods';

describe('icon-utils', () => {
it('should return no icon data for single dimension', () => {
Expand Down Expand Up @@ -47,4 +49,94 @@ describe('icon-utils', () => {
onClick: undefined,
});
});

describe('keyboard support for cyclic button', () => {
const dimInfo = { qGrouping: 'C' };
const dimensionModel = { stepCycle: jest.fn() };
const app = { getDimension: jest.fn().mockResolvedValue(dimensionModel) };
const selections = { confirm: jest.fn() };
const flushPromises = () => new Promise(process.nextTick);
const inputs = {
dimInfo,
app,
selections,
isPopover: false,
active: true,
keyboard: {
enabled: true,
focusSelection: jest.fn(),
blur: jest.fn(),
},
};

const event = {
currentTarget: {
closest: () => {},
},
target: {
classList: {
contains: () => true,
},
},
keyCode: KEYS.SPACE,
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
};

it('should have keyboard support', () => {
const result = utils.createDimensionIconData(inputs);
expect(result).toMatchObject({
icon: ReloadIcon,
tooltip: 'Listbox.Cyclic',
onClick: expect.any(Function),
onKeyDown: expect.any(Function),
});
});

it('should step to next field of cyclic dimension when pressing SPACE or ENTER', async () => {
const result = utils.createDimensionIconData(inputs);
event.keyCode = KEYS.ENTER;
result.onKeyDown(event);
event.keyCode = KEYS.ENTER;
result.onKeyDown(event);
await flushPromises();
expect(selections.confirm).toHaveBeenCalledTimes(2);
expect(dimensionModel.stepCycle).toHaveBeenCalledTimes(2);
});

it('press TAB should focus on search input if it is visible', async () => {
const result = utils.createDimensionIconData(inputs);
event.keyCode = KEYS.TAB;

jest.spyOn(keyboardNavMethod, 'focusSearch').mockReturnValue({});
jest.spyOn(keyboardNavMethod, 'focusRow').mockReturnValue(null);

result.onKeyDown(event);
expect(keyboardNavMethod.focusSearch).toHaveBeenCalledTimes(1);
expect(keyboardNavMethod.focusRow).toHaveBeenCalledTimes(0);
});

it('press TAB should focus on row if search input is not visible', async () => {
const result = utils.createDimensionIconData(inputs);
event.keyCode = KEYS.TAB;

jest.spyOn(keyboardNavMethod, 'focusSearch').mockReturnValue(null);
jest.spyOn(keyboardNavMethod, 'focusRow').mockReturnValue({});

result.onKeyDown(event);
expect(keyboardNavMethod.focusSearch).toHaveBeenCalledTimes(1);
expect(keyboardNavMethod.focusRow).toHaveBeenCalledTimes(1);
});

it('should return focus to listbox container if cannot focus anything', async () => {
const result = utils.createDimensionIconData(inputs);
event.keyCode = KEYS.TAB;

jest.spyOn(keyboardNavMethod, 'focusSearch').mockReturnValue(null);
jest.spyOn(keyboardNavMethod, 'focusRow').mockReturnValue(null);

result.onKeyDown(event);
expect(inputs.keyboard.blur).toHaveBeenCalledTimes(1);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function removeLastFocused(container) {
}

export function getVizCell(container) {
return container?.closest('.njs-cell') || container?.closest('.qv-gridcell');
return container?.closest('.njs-cell') || container?.closest('.qv-gridcell') || container?.closest('.qv-gs-listbox');
}

// Emulate the keyboard hook, until we support it in the Listbox.
Expand All @@ -39,6 +39,7 @@ export default function useTempKeyboard({ containerRef, enabled }) {
if (resetFocus && vizCell) {
// Move focus to the viz's cell.
vizCell.setAttribute('tabIndex', 0);
containerRef.current.setAttribute('tabIndex', -1);
vizCell.focus();
}
},
Expand All @@ -53,16 +54,20 @@ export default function useTempKeyboard({ containerRef, enabled }) {
const firstRowElement = c?.querySelector('.value.selector, .value');
const confirmButton = c?.querySelector('.actions-toolbar-default-actions .actions-toolbar-confirm');
const unlockCoverButton = c?.querySelector('#listbox-unlock-button');
const elementToFocus = searchField || lastSelectedRow || firstRowElement || unlockCoverButton || confirmButton;
const cyclicButton = c?.querySelector('.listbox-cyclic-button');
const elementToFocus =
cyclicButton || searchField || lastSelectedRow || firstRowElement || unlockCoverButton || confirmButton;
elementToFocus?.setAttribute('tabIndex', 0);
elementToFocus?.focus();
},
focusSelection() {
const unlockCoverButton = document.querySelector('#listbox-unlock-button');
const confirmButton = document.querySelector('.actions-toolbar-default-actions .actions-toolbar-confirm');
const btnToFocus = unlockCoverButton || confirmButton;
const moreButton = document.querySelector('.actions-toolbar-more');
const btnToFocus = unlockCoverButton || confirmButton || moreButton;
btnToFocus?.setAttribute('tabIndex', 0);
btnToFocus?.focus();
return btnToFocus;
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('keyboard navigation', () => {
let currentScrollIndex;
let keyboard;
let isModal;
let selections;

afterEach(() => {
jest.restoreAllMocks();
Expand All @@ -37,6 +38,10 @@ describe('keyboard navigation', () => {
hovering = { current: true };
updateKeyScroll = jest.fn();
currentScrollIndex = { start: 0, stop: 10 };
selections = {
isActive: jest.fn(() => true),
cancel: jest.fn(),
};

({
handleKeyDown: handleKeyDownForListbox,
Expand All @@ -52,6 +57,7 @@ describe('keyboard navigation', () => {
currentScrollIndex,
keyboard,
isModal,
selections,
}));
});

Expand Down
Loading

0 comments on commit 22c64bc

Please sign in to comment.