Skip to content

Commit 73919f7

Browse files
Merge pull request #520 from catho/QTM-495
feat(QTM-495): Navigating between items using letter keys
2 parents 2c137a2 + 6ab48e1 commit 73919f7

File tree

4 files changed

+165
-25
lines changed

4 files changed

+165
-25
lines changed

components/DropdownLight/DropdownLight.jsx

+32-20
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import Icon from '../Icon/Icon';
1717
import useKeyPress from './SubComponents/UseKeyPress';
1818
import { FieldGroup } from '../shared';
19+
import useKeyboardSearchItems from './SubComponents/UseKeyboardSearchItems';
1920

2021
const itemPropType = PropTypes.oneOfType([
2122
PropTypes.string,
@@ -202,33 +203,39 @@ const DropdownLight = ({
202203
selectedItem || '',
203204
);
204205

205-
const [cursor, setCursor] = useState(0);
206+
const [cursor, setCursor] = useState(-1);
206207
const buttonRef = useRef();
207208
const listOptions = useRef();
208209

209210
const downPress = useKeyPress('ArrowDown');
210211
const upPress = useKeyPress('ArrowUp');
211212
const enterPress = useKeyPress('Enter');
213+
const focusedItemIndex = useKeyboardSearchItems(items, cursor, isOpen);
212214
const EscapeKeyPressValue = 'Escape';
213215

214216
const handleToggleDropdown = () => {
215-
if (!enterPress) {
216-
setIsOpen(!isOpen);
217-
}
217+
setIsOpen(!isOpen);
218+
};
219+
220+
useEffect(() => {
221+
const hasItemSelected = typeof focusedItemIndex === 'number';
222+
setCursor(hasItemSelected ? focusedItemIndex : -1);
223+
}, [focusedItemIndex]);
218224

219-
if (isOpen) {
220-
listOptions.current.children[0].focus();
225+
useEffect(() => {
226+
if (isOpen && cursor >= 0) {
227+
listOptions.current.children[cursor].focus();
221228
}
222-
};
229+
}, [cursor, isOpen]);
223230

224231
const selectItem = (item) => {
225232
setSelectedOptionItem(item?.label || item);
226233
onChange(item);
234+
setCursor(-1);
227235
buttonRef.current.focus();
228236
};
229237

230238
const handleOnClickListItem = (item) => {
231-
setIsOpen(false);
232239
selectItem(item);
233240
};
234241

@@ -241,7 +248,7 @@ const DropdownLight = ({
241248
const handleEscPress = ({ key }) => {
242249
if (key === EscapeKeyPressValue) {
243250
setIsOpen(false);
244-
setCursor(0);
251+
setCursor(-1);
245252
buttonRef.current.focus();
246253
}
247254
};
@@ -250,15 +257,13 @@ const DropdownLight = ({
250257
if (isOpen && downPress) {
251258
const selectedCursor = cursor < items.length - 1 ? cursor + 1 : cursor;
252259
setCursor(selectedCursor);
253-
listOptions.current.children[selectedCursor].focus();
254260
}
255261
}, [downPress, items, isOpen]);
256262

257263
useEffect(() => {
258264
if (isOpen && upPress && cursor > 0) {
259265
const selectedCursor = cursor - 1;
260266
setCursor(selectedCursor);
261-
listOptions.current.children[selectedCursor].focus();
262267
}
263268
}, [upPress, items, isOpen]);
264269

@@ -272,22 +277,29 @@ const DropdownLight = ({
272277
}, []);
273278

274279
useEffect(() => {
275-
if (document.activeElement === buttonRef.current && enterPress) {
276-
setIsOpen(!isOpen);
280+
if (
281+
enterPress &&
282+
!isOpen &&
283+
document.activeElement === buttonRef.current &&
284+
selectedOptionItem
285+
) {
286+
const selectedItemIndex = items.findIndex(
287+
(item) => (item?.label || item) === selectedOptionItem,
288+
);
289+
setCursor(selectedItemIndex);
277290
}
278291

279292
if (
280-
document.activeElement !== buttonRef.current &&
281293
enterPress &&
282-
listOptions.current
294+
isOpen &&
295+
listOptions.current?.contains(document.activeElement)
283296
) {
284-
setIsOpen(false);
285-
286297
const itemsList = [...listOptions.current.children];
298+
const selectedItemIndex = itemsList.findIndex(
299+
(item) => item === document.activeElement,
300+
);
287301

288-
if (itemsList.some((element) => element === document.activeElement)) {
289-
selectItem(items[cursor]);
290-
}
302+
selectItem(items[selectedItemIndex]);
291303
}
292304
}, [enterPress]);
293305

components/DropdownLight/DropdownLight.unit.test.jsx

+40-5
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const INPUT_NAME = 'dropdown-name';
1414
const ARROW_DOWN_KEY_CODE = '{ArrowDown}';
1515
const ARROW_UP_KEY_CODE = '{ArrowUp}';
1616
const ENTER_KEY_CODE = '{Enter}';
17+
const A_KEY_CODE = '{a}';
1718
const ESCAPE_KEY_CODE = '{Escape}';
1819

1920
describe('<DropdownLight />', () => {
@@ -189,14 +190,48 @@ describe('<DropdownLight />', () => {
189190
expect(screen.getByRole('list')).toBeInTheDocument();
190191

191192
await userEvent.keyboard(
192-
`${ARROW_DOWN_KEY_CODE}${ARROW_DOWN_KEY_CODE}${ARROW_UP_KEY_CODE}${ENTER_KEY_CODE}`,
193+
`${ARROW_DOWN_KEY_CODE}${ARROW_DOWN_KEY_CODE}${ARROW_DOWN_KEY_CODE}${ARROW_UP_KEY_CODE}`,
193194
);
194195

195-
const bananaItem = itemsStringMock[1];
196+
const bananaItemMock = itemsStringMock[1];
197+
const bananaItem = screen.getByRole('option', { name: bananaItemMock });
198+
199+
expect(bananaItem).toHaveFocus();
200+
201+
await userEvent.keyboard(ENTER_KEY_CODE);
202+
const input = screen.getByRole('textbox', { hidden: true });
203+
204+
expect(input.value).toEqual(bananaItemMock);
205+
expect(screen.queryByRole('list')).not.toBeInTheDocument();
206+
});
207+
208+
it('should allow the User to navigate between items using the keys with the initial letter of each item', async () => {
209+
render(<DropdownLight items={itemsStringMock} />);
210+
211+
await userEvent.tab();
212+
await userEvent.keyboard(ENTER_KEY_CODE);
213+
214+
expect(screen.getByRole('list')).toBeInTheDocument();
215+
216+
await userEvent.keyboard(A_KEY_CODE);
217+
218+
const avocadoItemMock = itemsStringMock[4];
219+
const avocadoItem = screen.getByRole('option', { name: avocadoItemMock });
220+
221+
expect(avocadoItem).toHaveFocus();
222+
223+
await userEvent.keyboard(A_KEY_CODE);
224+
225+
const acaiItemMock = itemsStringMock[5];
226+
const acaiItem = screen.getByRole('option', { name: acaiItemMock });
227+
228+
expect(acaiItem).toHaveFocus();
229+
230+
await userEvent.keyboard(ENTER_KEY_CODE);
196231

197232
const input = screen.getByRole('textbox', { hidden: true });
198233

199-
expect(input.value).toEqual(bananaItem);
234+
expect(input.value).toEqual(acaiItemMock);
200235
expect(screen.queryByRole('list')).not.toBeInTheDocument();
201236
});
202237

@@ -212,8 +247,8 @@ describe('<DropdownLight />', () => {
212247
);
213248

214249
expect(onChangeMock).toHaveBeenCalledWith({
215-
label: 'Strawberry',
216-
value: 'Strawberry',
250+
label: 'Lime',
251+
value: 'Lime',
217252
});
218253
});
219254

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useState, useEffect } from 'react';
2+
3+
const useKeyboardSearchItems = (items = [], cursor = -1, isOpen = false) => {
4+
const [pressedKey, setPressedKey] = useState(null);
5+
const [selectedItemIndex, setSelectedItemIndex] = useState(null);
6+
7+
const isEqual = (item, itemToCompare) =>
8+
(item?.label || item) === (itemToCompare?.label || itemToCompare);
9+
10+
useEffect(() => {
11+
setSelectedItemIndex(cursor >= 0 ? cursor : null);
12+
}, [cursor]);
13+
14+
useEffect(() => {
15+
if (pressedKey && isOpen) {
16+
let indexItem = selectedItemIndex;
17+
18+
const itemsWithLetterPressed = items.filter((item) =>
19+
(item?.label || item)
20+
.toLowerCase()
21+
.startsWith(pressedKey.toLowerCase()),
22+
);
23+
24+
if (!itemsWithLetterPressed.length) {
25+
return;
26+
}
27+
28+
const selectedItemHasTheLetterPressed = itemsWithLetterPressed.some(
29+
(item) => isEqual(item, items[cursor]),
30+
);
31+
const firstItemInItemsWithLetterPressed = itemsWithLetterPressed[0];
32+
const indexOfFirstItemWithLetterPressedOnItems = items.findIndex((item) =>
33+
isEqual(item, firstItemInItemsWithLetterPressed),
34+
);
35+
const selectedItemIsBeforeTheFirstItemWithLetterPressed =
36+
cursor < indexOfFirstItemWithLetterPressedOnItems;
37+
const noItemSelected = typeof indexItem !== 'number';
38+
39+
if (
40+
noItemSelected ||
41+
(!selectedItemHasTheLetterPressed &&
42+
selectedItemIsBeforeTheFirstItemWithLetterPressed)
43+
) {
44+
setSelectedItemIndex(indexOfFirstItemWithLetterPressedOnItems);
45+
return;
46+
}
47+
48+
const selectedItem = items[indexItem];
49+
const selectedItemIndexInFilteredItem = itemsWithLetterPressed.findIndex(
50+
(item) => isEqual(item, selectedItem),
51+
);
52+
const nextItemInItemsWithLetterPressed =
53+
itemsWithLetterPressed[selectedItemIndexInFilteredItem + 1];
54+
55+
indexItem = items.findIndex((item) =>
56+
isEqual(
57+
item,
58+
nextItemInItemsWithLetterPressed || firstItemInItemsWithLetterPressed,
59+
),
60+
);
61+
62+
setSelectedItemIndex(indexItem);
63+
}
64+
}, [pressedKey]);
65+
66+
const downHandler = ({ key }) => {
67+
if (key.length === 1) {
68+
setPressedKey(key);
69+
}
70+
};
71+
72+
const upHandler = () => {
73+
setPressedKey(null);
74+
};
75+
76+
useEffect(() => {
77+
window.addEventListener('keydown', downHandler);
78+
window.addEventListener('keyup', upHandler);
79+
80+
return () => {
81+
window.removeEventListener('keydown', downHandler);
82+
window.removeEventListener('keyup', upHandler);
83+
};
84+
}, []);
85+
86+
return selectedItemIndex;
87+
};
88+
89+
export default useKeyboardSearchItems;

stories/DropdownLight/mock.js

+4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
export const itemsObjectMock = [
22
{ value: 'Lemon', label: 'Lemon' },
3+
{ value: 'Lime', label: 'Lime' },
34
{ value: 'Banana', label: 'Banana' },
5+
{ value: 'Açaí', label: 'Açaí' },
46
{ value: 'Strawberry', label: 'Strawberry' },
57
{ value: 'Orange', label: 'Orange' },
68
{ value: 'Avocado', label: 'Avocado' },
9+
{ value: 'Acerola', label: 'Acerola' },
710
];
811

912
export const itemsStringMock = [
@@ -12,6 +15,7 @@ export const itemsStringMock = [
1215
'Strawberry',
1316
'Orange',
1417
'Avocado',
18+
'Açaí',
1519
];
1620

1721
export const itemsWithImageMock = [

0 commit comments

Comments
 (0)