Skip to content

Commit 91bf5fa

Browse files
committed
feat: add keyboard navigation for resource tree
- keep track of focused ID instead of focused index in Virtuoso - add focus state for tree nodes and define keyboard actions closes #2572 Signed-off-by: Maxim Stykow <maxim.stykow@tngtech.com> Signed-off-by: Markus Obendrauf <markus.obendrauf@tngtech.com>
1 parent 50a0462 commit 91bf5fa

File tree

8 files changed

+149
-21
lines changed

8 files changed

+149
-21
lines changed

src/Frontend/Components/AttributionPanels/AttributionsPanel/AttributionsList/AttributionsList.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const AttributionsList: React.FC<PackagesPanelChildrenProps> = ({
3131
<List
3232
renderItemContent={renderAttributionCard}
3333
data={activeAttributionIds}
34-
selected={selectedAttributionId}
34+
selectedId={selectedAttributionId}
3535
loading={loading}
3636
sx={{ transition: TRANSITION, height: contentHeight }}
3737
/>

src/Frontend/Components/AttributionPanels/SignalsPanel/SignalsList/SignalsList.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export const SignalsList: React.FC<PackagesPanelChildrenProps> = ({
6969
return (
7070
<GroupedList
7171
grouped={groupedIds}
72-
selected={selectedAttributionId}
72+
selectedId={selectedAttributionId}
7373
renderItemContent={renderAttributionCard}
7474
renderGroupName={(sourceName) => (
7575
<>

src/Frontend/Components/GroupedList/GroupedList.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export interface GroupedListProps {
3939
datum: string,
4040
props: GroupedListItemContentProps,
4141
) => React.ReactNode;
42-
selected?: string;
42+
selectedId?: string;
4343
sx?: SxProps;
4444
testId?: string;
4545
}
@@ -50,7 +50,7 @@ export function GroupedList({
5050
loading,
5151
renderGroupName,
5252
renderItemContent,
53-
selected,
53+
selectedId,
5454
sx,
5555
testId,
5656
...props
@@ -82,7 +82,7 @@ export function GroupedList({
8282
selectedIndex,
8383
} = useVirtuosoRefs<GroupedVirtuosoHandle>({
8484
data: groups?.ids,
85-
selected,
85+
selectedId,
8686
});
8787

8888
return (

src/Frontend/Components/List/List.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export interface ListProps {
2424
datum: string,
2525
props: ListItemContentProps,
2626
) => React.ReactNode;
27-
selected?: string;
27+
selectedId?: string;
2828
sx?: SxProps;
2929
testId?: string;
3030
}
@@ -34,7 +34,7 @@ export function List({
3434
data,
3535
loading,
3636
renderItemContent,
37-
selected,
37+
selectedId,
3838
sx,
3939
testId,
4040
...props
@@ -47,7 +47,7 @@ export function List({
4747
selectedIndex,
4848
} = useVirtuosoRefs<VirtuosoHandle>({
4949
data,
50-
selected,
50+
selectedId,
5151
});
5252

5353
return (

src/Frontend/Components/VirtualizedTree/VirtualizedTree.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -57,18 +57,19 @@ export function VirtualizedTree({
5757
return (
5858
<List
5959
data={resourceIds.length ? Object.keys(treeNodes) : []}
60-
renderItemContent={(nodeId, { selected }) => (
60+
renderItemContent={(nodeId, { selected, focused }) => (
6161
<VirtualizedTreeNode
6262
TreeNodeLabel={TreeNodeLabel}
6363
isExpandedNode={expandedIds.includes(nodeId)}
6464
onToggle={onToggle}
6565
onSelect={onSelect}
6666
readOnly={readOnly}
6767
selected={selected}
68+
focused={focused}
6869
{...treeNodes[nodeId]}
6970
/>
7071
)}
71-
selected={selectedNodeId}
72+
selectedId={selectedNodeId}
7273
testId={testId}
7374
sx={{
7475
height: '100%',

src/Frontend/Components/VirtualizedTree/VirtualizedTreeNode/VirtualizedTreeNode.tsx

+35-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
66
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
77
import MuiBox from '@mui/material/Box';
8+
import { useEffect, useRef } from 'react';
89

910
import { Resources } from '../../../../shared/shared-types';
1011
import { OpossumColors } from '../../../shared-styles';
@@ -26,6 +27,12 @@ const classes = {
2627
'&:hover .tree-node-selected-indicator': {
2728
display: 'block',
2829
},
30+
'&:focus .tree-node-selected-indicator': {
31+
display: 'block',
32+
},
33+
'&:focus': {
34+
outline: 'none',
35+
},
2936
},
3037
clickableIcon: {
3138
width: '16px',
@@ -74,6 +81,7 @@ interface VirtualizedTreeNodeProps extends TreeNode {
7481
onToggle: (nodeIdsToExpand: Array<string>) => void;
7582
readOnly?: boolean;
7683
selected: boolean;
84+
focused: boolean;
7785
}
7886

7987
export function VirtualizedTreeNode({
@@ -86,6 +94,7 @@ export function VirtualizedTreeNode({
8694
onToggle,
8795
readOnly,
8896
selected,
97+
focused,
8998
}: VirtualizedTreeNodeProps) {
9099
const isExpandable = node !== 1 && Object.keys(node).length !== 0;
91100
const marginRight =
@@ -96,6 +105,14 @@ export function VirtualizedTreeNode({
96105
? SIMPLE_FOLDER_EXTRA_INDENT
97106
: SIMPLE_NODE_EXTRA_INDENT);
98107

108+
const ref = useRef<HTMLDivElement>(null);
109+
110+
useEffect(() => {
111+
if (focused) {
112+
ref.current?.focus();
113+
}
114+
}, [focused]);
115+
99116
const handleClick = readOnly
100117
? undefined
101118
: () => {
@@ -106,7 +123,24 @@ export function VirtualizedTreeNode({
106123
};
107124

108125
return (
109-
<MuiBox sx={classes.listNode} onClick={handleClick}>
126+
<MuiBox
127+
sx={classes.listNode}
128+
onClick={handleClick}
129+
tabIndex={0}
130+
ref={ref}
131+
onKeyDown={(event) => {
132+
if (['Enter', 'Space'].includes(event.code)) {
133+
event.preventDefault();
134+
handleClick?.();
135+
} else if (event.code === 'ArrowRight' && !isExpandedNode) {
136+
event.preventDefault();
137+
onToggle?.([nodeId]);
138+
} else if (event.code === 'ArrowLeft' && isExpandedNode) {
139+
event.preventDefault();
140+
onToggle?.([nodeId]);
141+
}
142+
}}
143+
>
110144
<MuiBox sx={classes.treeNodeSpacer} style={{ width: marginRight }} />
111145
{renderExpandableNodeIcon()}
112146
<MuiBox

src/Frontend/util/use-virtuoso-refs.ts

+18-10
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,41 @@ import { VirtuosoHandle } from 'react-virtuoso';
88

99
export function useVirtuosoRefs<T extends VirtuosoHandle>({
1010
data,
11-
selected,
11+
selectedId,
1212
}: {
1313
data: ReadonlyArray<string> | null | undefined;
14-
selected: string | undefined;
14+
selectedId: string | undefined;
1515
}) {
1616
const ref = useRef<T>(null);
1717
const listRef = useRef<Window | HTMLElement>();
1818
const [isVirtuosoFocused, setIsVirtuosoFocused] = useState(false);
19-
const [focusedIndex, setFocusedIndex] = useState<number>();
19+
const [focusedId, setFocusedId] = useState<string>();
2020

2121
const selectedIndex = useMemo(() => {
2222
if (!data) {
2323
return undefined;
2424
}
2525

26-
return data.findIndex((datum) => datum === selected);
27-
}, [data, selected]);
26+
return data.findIndex((datum) => datum === selectedId);
27+
}, [data, selectedId]);
28+
29+
const focusedIndex = useMemo(() => {
30+
if (!data) {
31+
return undefined;
32+
}
33+
34+
return data.findIndex((datum) => datum === focusedId);
35+
}, [data, focusedId]);
2836

2937
useEffect(() => {
3038
if (isVirtuosoFocused) {
31-
setFocusedIndex(selectedIndex);
39+
setFocusedId(selectedId);
3240
}
3341

3442
return () => {
35-
setFocusedIndex(undefined);
43+
setFocusedId(undefined);
3644
};
37-
}, [isVirtuosoFocused, selectedIndex]);
45+
}, [isVirtuosoFocused, selectedId]);
3846

3947
useEffect(() => {
4048
if (selectedIndex !== undefined && selectedIndex >= 0) {
@@ -68,12 +76,12 @@ export function useVirtuosoRefs<T extends VirtuosoHandle>({
6876
index,
6977
behavior: 'auto',
7078
});
71-
setFocusedIndex(index);
79+
setFocusedId(data[index]);
7280
event.preventDefault();
7381
}
7482
}
7583
},
76-
[data?.length, focusedIndex],
84+
[data, focusedIndex],
7785
);
7886

7987
const scrollerRef = useCallback(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates
2+
// SPDX-FileCopyrightText: TNG Technology Consulting GmbH <https://www.tngtech.com>
3+
//
4+
// SPDX-License-Identifier: Apache-2.0
5+
import { faker, test } from '../utils';
6+
7+
const [resourceName1, resourceName2, resourceName3] =
8+
faker.opossum.resourceNames({ count: 3 });
9+
const [attributionId1, packageInfo1] = faker.opossum.rawAttribution({
10+
packageName: 'a',
11+
});
12+
const [attributionId2, packageInfo2] = faker.opossum.rawAttribution({
13+
packageName: 'b',
14+
});
15+
const [attributionId3, packageInfo3] = faker.opossum.rawAttribution({
16+
packageName: 'c',
17+
});
18+
19+
test.use({
20+
data: {
21+
inputData: faker.opossum.inputData({
22+
resources: faker.opossum.resources({
23+
[resourceName1]: {
24+
[resourceName2]: 1,
25+
},
26+
[resourceName3]: 1,
27+
}),
28+
}),
29+
outputData: faker.opossum.outputData({
30+
manualAttributions: faker.opossum.rawAttributions({
31+
[attributionId1]: packageInfo1,
32+
[attributionId2]: packageInfo2,
33+
[attributionId3]: packageInfo3,
34+
}),
35+
resourcesToAttributions: faker.opossum.resourcesToAttributions({
36+
[faker.opossum.folderPath(resourceName1)]: [attributionId1],
37+
[faker.opossum.filePath(resourceName2)]: [attributionId1],
38+
[faker.opossum.filePath(resourceName3)]: [attributionId3],
39+
}),
40+
}),
41+
},
42+
});
43+
44+
test('allows navigating up and down the resource tree by keyboard', async ({
45+
resourcesTree,
46+
attributionDetails,
47+
window,
48+
}) => {
49+
await resourcesTree.goto(resourceName1);
50+
await resourcesTree.goto(resourceName2);
51+
await attributionDetails.attributionForm.assert.matchesPackageInfo(
52+
packageInfo1,
53+
);
54+
55+
await window.keyboard.press('ArrowDown');
56+
await window.keyboard.press('Enter');
57+
await attributionDetails.attributionForm.assert.matchesPackageInfo(
58+
packageInfo3,
59+
);
60+
61+
await window.keyboard.press('ArrowUp');
62+
await window.keyboard.press('ArrowUp');
63+
await window.keyboard.press('Space');
64+
await attributionDetails.attributionForm.assert.matchesPackageInfo(
65+
packageInfo1,
66+
);
67+
});
68+
69+
test('allows expanding and collapsing folders in the resource tree by keyboard', async ({
70+
resourcesTree,
71+
window,
72+
}) => {
73+
await resourcesTree.goto(resourceName3);
74+
await window.keyboard.press('ArrowUp');
75+
await resourcesTree.assert.resourceIsHidden(resourceName2);
76+
77+
await window.keyboard.press('ArrowRight');
78+
await resourcesTree.assert.resourceIsVisible(resourceName2);
79+
80+
await window.keyboard.press('ArrowLeft');
81+
await resourcesTree.assert.resourceIsHidden(resourceName2);
82+
83+
await window.keyboard.press('Enter');
84+
await resourcesTree.assert.resourceIsVisible(resourceName2);
85+
});

0 commit comments

Comments
 (0)