Skip to content

Commit 29cd131

Browse files
committed
feat: add keyboard navigation to attributions and signals lists
- merge PackageCard and ListCard components - track keyboard selected item in list and grouped-list - add focus styling to package card closes #971 Signed-off-by: Maxim Stykow <maxim.stykow@tngtech.com>
1 parent d26f86d commit 29cd131

File tree

19 files changed

+599
-361
lines changed

19 files changed

+599
-361
lines changed

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

+7-3
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { changeSelectedAttributionOrOpenUnsavedPopup } from '../../../../state/a
1010
import { useAppDispatch } from '../../../../state/hooks';
1111
import { useAttributionIdsForReplacement } from '../../../../state/variables/use-attribution-ids-for-replacement';
1212
import { isPackageInfoIncomplete } from '../../../../util/is-important-attribution-information-missing';
13-
import { List } from '../../../List/List';
13+
import { List, ListItemContentProps } from '../../../List/List';
1414
import { PackageCard } from '../../../PackageCard/PackageCard';
1515
import { PackagesPanelChildrenProps } from '../../PackagesPanel/PackagesPanel';
1616

@@ -37,7 +37,10 @@ export const AttributionsList: React.FC<PackagesPanelChildrenProps> = ({
3737
/>
3838
);
3939

40-
function renderAttributionCard(attributionId: string) {
40+
function renderAttributionCard(
41+
attributionId: string,
42+
{ selected, focused }: ListItemContentProps,
43+
) {
4144
const attribution = attributions?.[attributionId];
4245

4346
if (!attribution) {
@@ -54,7 +57,8 @@ export const AttributionsList: React.FC<PackagesPanelChildrenProps> = ({
5457
);
5558
}}
5659
cardConfig={{
57-
selected: attributionId === selectedAttributionId,
60+
selected,
61+
focused,
5862
resolved: attributionIdsForReplacement.includes(attributionId),
5963
incomplete: isPackageInfoIncomplete(attribution),
6064
}}

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

+10-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import {
1414
getResolvedExternalAttributions,
1515
} from '../../../../state/selectors/resource-selectors';
1616
import { useAttributionIdsForReplacement } from '../../../../state/variables/use-attribution-ids-for-replacement';
17-
import { GroupedList } from '../../../GroupedList/GroupedList';
17+
import {
18+
GroupedList,
19+
GroupedListItemContentProps,
20+
} from '../../../GroupedList/GroupedList';
1821
import { SourceIcon } from '../../../Icons/Icons';
1922
import { PackageCard } from '../../../PackageCard/PackageCard';
2023
import { PackagesPanelChildrenProps } from '../../PackagesPanel/PackagesPanel';
@@ -79,7 +82,10 @@ export const SignalsList: React.FC<PackagesPanelChildrenProps> = ({
7982
/>
8083
);
8184

82-
function renderAttributionCard(attributionId: string) {
85+
function renderAttributionCard(
86+
attributionId: string,
87+
{ focused, selected }: GroupedListItemContentProps,
88+
) {
8389
const attribution = attributions?.[attributionId];
8490

8591
if (!attribution) {
@@ -96,7 +102,8 @@ export const SignalsList: React.FC<PackagesPanelChildrenProps> = ({
96102
);
97103
}}
98104
cardConfig={{
99-
selected: attributionId === selectedAttributionId,
105+
selected,
106+
focused,
100107
resolved: resolvedExternalAttributionIds.has(attributionId),
101108
}}
102109
packageInfo={attribution}

src/Frontend/Components/CardList/CardList.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import { styled } from '@mui/material';
66

77
import { OpossumColors } from '../../shared-styles';
88
import { List } from '../List/List';
9-
import { LIST_CARD_HEIGHT } from '../ListCard/ListCard';
9+
import { PACKAGE_CARD_HEIGHT } from '../PackageCard/PackageCard';
1010

1111
const MAX_NUMBER_OF_CARDS = 4;
1212

1313
export const CardList = styled(List)(({ data }) => {
1414
const height =
15-
Math.min(MAX_NUMBER_OF_CARDS, data?.length ?? 0) * (LIST_CARD_HEIGHT + 1) +
15+
Math.min(MAX_NUMBER_OF_CARDS, data?.length ?? 0) *
16+
(PACKAGE_CARD_HEIGHT + 1) +
1617
1;
1718

1819
return {

src/Frontend/Components/ConfirmDeletePopup/ConfirmDeletePopup.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export const ConfirmDeletePopup: React.FC<Props> = ({
118118
</MuiTypography>
119119
<CardList
120120
data={attributionIdsToDelete}
121-
renderItemContent={(attributionId, index) => {
121+
renderItemContent={(attributionId, { index }) => {
122122
if (!attributionId || !(attributionId in attributions)) {
123123
return null;
124124
}

src/Frontend/Components/ConfirmReplacePopup/ConfirmReplacePopup.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export const ConfirmReplacePopup = ({
9898
<CardList
9999
data={attributionIdsForReplacement}
100100
data-testid={'removed-attributions'}
101-
renderItemContent={(attributionId, index) => {
101+
renderItemContent={(attributionId, { index }) => {
102102
if (!attributionId || !(attributionId in attributions)) {
103103
return null;
104104
}

src/Frontend/Components/ConfirmSavePopup/ConfirmSavePopup.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export const ConfirmSavePopup: React.FC<Props> = ({
147147
</MuiTypography>
148148
<CardList
149149
data={attributionIdsToSave}
150-
renderItemContent={(attributionId, index) => {
150+
renderItemContent={(attributionId, { index }) => {
151151
if (!attributionId || !(attributionId in attributions)) {
152152
return null;
153153
}

src/Frontend/Components/GroupedList/GroupedList.tsx

+34-18
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ import { styled } from '@mui/material';
1010
import MuiBox from '@mui/material/Box';
1111
import MuiTooltip from '@mui/material/Tooltip';
1212
import { SxProps } from '@mui/system';
13-
import { defer } from 'lodash';
14-
import { useEffect, useMemo, useRef, useState } from 'react';
13+
import { useMemo, useState } from 'react';
1514
import {
1615
GroupedVirtuoso,
1716
GroupedVirtuosoHandle,
@@ -20,16 +19,26 @@ import {
2019

2120
import { text } from '../../../shared/text';
2221
import { OpossumColors } from '../../shared-styles';
22+
import { useVirtuosoRefs } from '../../util/use-virtuoso-refs';
2323
import { LoadingMask } from '../LoadingMask/LoadingMask';
2424
import { NoResults } from '../NoResults/NoResults';
2525
import { GroupContainer, StyledLinearProgress } from './GroupedList.style';
2626

27-
interface GroupedListProps {
27+
export interface GroupedListItemContentProps {
28+
index: number;
29+
selected: boolean;
30+
focused: boolean;
31+
}
32+
33+
export interface GroupedListProps {
2834
className?: string;
2935
grouped: Record<string, ReadonlyArray<string>> | null;
3036
loading?: boolean;
3137
renderGroupName?: (key: string) => React.ReactNode;
32-
renderItemContent: (datum: string, index: number) => React.ReactNode;
38+
renderItemContent: (
39+
datum: string,
40+
props: GroupedListItemContentProps,
41+
) => React.ReactNode;
3342
selected?: string;
3443
sx?: SxProps;
3544
testId?: string;
@@ -46,7 +55,6 @@ export function GroupedList({
4655
testId,
4756
...props
4857
}: GroupedListProps & Omit<GroupedVirtuosoProps<string, unknown>, 'selected'>) {
49-
const ref = useRef<GroupedVirtuosoHandle>(null);
5058
const [{ startIndex, endIndex }, setRange] = useState<{
5159
startIndex: number;
5260
endIndex: number;
@@ -63,20 +71,19 @@ export function GroupedList({
6371
ids: flattened,
6472
keys: Object.keys(grouped),
6573
counts: Object.values(grouped).map((group) => group.length),
66-
selectedIndex: flattened.findIndex((datum) => datum === selected),
6774
};
68-
}, [grouped, selected]);
75+
}, [grouped]);
6976

70-
useEffect(() => {
71-
if (groups?.selectedIndex !== undefined && groups.selectedIndex >= 0) {
72-
defer(() =>
73-
ref.current?.scrollIntoView({
74-
index: groups.selectedIndex,
75-
align: 'center',
76-
}),
77-
);
78-
}
79-
}, [groups?.selectedIndex]);
77+
const {
78+
ref,
79+
scrollerRef,
80+
focusedIndex,
81+
setIsVirtuosoFocused,
82+
selectedIndex,
83+
} = useVirtuosoRefs<GroupedVirtuosoHandle>({
84+
data: groups?.ids,
85+
selected,
86+
});
8087

8188
return (
8289
<LoadingMask
@@ -89,10 +96,13 @@ export function GroupedList({
8996
{groups && (
9097
<GroupedVirtuoso
9198
ref={ref}
99+
onFocus={() => setIsVirtuosoFocused(true)}
100+
onBlur={() => setIsVirtuosoFocused(false)}
92101
components={{
93102
EmptyPlaceholder:
94103
loading || groups.ids.length ? undefined : () => <NoResults />,
95104
}}
105+
scrollerRef={scrollerRef}
96106
rangeChanged={setRange}
97107
groupCounts={groups?.counts}
98108
groupContent={(index) => (
@@ -106,7 +116,13 @@ export function GroupedList({
106116
)}
107117
</GroupContainer>
108118
)}
109-
itemContent={(index) => renderItemContent(groups.ids[index], index)}
119+
itemContent={(index) =>
120+
renderItemContent(groups.ids[index], {
121+
index,
122+
selected: index === selectedIndex,
123+
focused: index === focusedIndex,
124+
})
125+
}
110126
{...props}
111127
/>
112128
)}

src/Frontend/Components/List/List.tsx

+32-25
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,27 @@
33
//
44
// SPDX-License-Identifier: Apache-2.0
55
import { SxProps } from '@mui/system';
6-
import { defer } from 'lodash';
7-
import { useEffect, useMemo, useRef } from 'react';
86
import { Virtuoso, VirtuosoHandle, VirtuosoProps } from 'react-virtuoso';
97

8+
import { useVirtuosoRefs } from '../../util/use-virtuoso-refs';
109
import { LoadingMask } from '../LoadingMask/LoadingMask';
1110
import { NoResults } from '../NoResults/NoResults';
1211
import { StyledLinearProgress } from './List.style';
1312

14-
interface ListProps {
13+
export interface ListItemContentProps {
14+
index: number;
15+
selected: boolean;
16+
focused: boolean;
17+
}
18+
19+
export interface ListProps {
1520
className?: string;
1621
data: ReadonlyArray<string> | null;
1722
loading?: boolean;
18-
renderItemContent: (datum: string, index: number) => React.ReactNode;
23+
renderItemContent: (
24+
datum: string,
25+
props: ListItemContentProps,
26+
) => React.ReactNode;
1927
selected?: string;
2028
sx?: SxProps;
2129
testId?: string;
@@ -31,26 +39,16 @@ export function List({
3139
testId,
3240
...props
3341
}: ListProps & Omit<VirtuosoProps<string, unknown>, 'data' | 'selected'>) {
34-
const ref = useRef<VirtuosoHandle>(null);
35-
36-
const selectedIndex = useMemo(() => {
37-
if (!data) {
38-
return undefined;
39-
}
40-
41-
return data.findIndex((datum) => datum === selected);
42-
}, [data, selected]);
43-
44-
useEffect(() => {
45-
if (selectedIndex !== undefined && selectedIndex >= 0) {
46-
defer(() =>
47-
ref.current?.scrollIntoView({
48-
index: selectedIndex,
49-
align: 'center',
50-
}),
51-
);
52-
}
53-
}, [selectedIndex]);
42+
const {
43+
focusedIndex,
44+
ref,
45+
scrollerRef,
46+
setIsVirtuosoFocused,
47+
selectedIndex,
48+
} = useVirtuosoRefs<VirtuosoHandle>({
49+
data,
50+
selected,
51+
});
5452

5553
return (
5654
<LoadingMask
@@ -63,12 +61,21 @@ export function List({
6361
{data && (
6462
<Virtuoso
6563
ref={ref}
64+
onFocus={() => setIsVirtuosoFocused(true)}
65+
onBlur={() => setIsVirtuosoFocused(false)}
6666
components={{
6767
EmptyPlaceholder:
6868
loading || data.length ? undefined : () => <NoResults />,
6969
}}
70+
scrollerRef={scrollerRef}
7071
data={data}
71-
itemContent={(index) => renderItemContent(data[index], index)}
72+
itemContent={(index) =>
73+
renderItemContent(data[index], {
74+
index,
75+
selected: index === selectedIndex,
76+
focused: index === focusedIndex,
77+
})
78+
}
7279
{...props}
7380
/>
7481
)}

0 commit comments

Comments
 (0)