Skip to content

Commit 6deb431

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>
1 parent 18d42f1 commit 6deb431

File tree

7 files changed

+64
-21
lines changed

7 files changed

+64
-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
@@ -38,7 +38,7 @@ export interface GroupedListProps {
3838
datum: string,
3939
props: GroupedListItemContentProps,
4040
) => React.ReactNode;
41-
selected?: string;
41+
selectedId?: string;
4242
sx?: SxProps;
4343
testId?: string;
4444
}
@@ -49,7 +49,7 @@ export function GroupedList({
4949
loading,
5050
renderGroupName,
5151
renderItemContent,
52-
selected,
52+
selectedId,
5353
sx,
5454
testId,
5555
...props
@@ -81,7 +81,7 @@ export function GroupedList({
8181
selectedIndex,
8282
} = useVirtuosoRefs<GroupedVirtuosoHandle>({
8383
data: groups?.ids,
84-
selected,
84+
selectedId,
8585
});
8686

8787
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(

0 commit comments

Comments
 (0)