Skip to content

Commit 1e37f43

Browse files
committed
add useScrollToTargetItem hook
1 parent 765f255 commit 1e37f43

File tree

8 files changed

+181
-65
lines changed

8 files changed

+181
-65
lines changed

web_ui/src/pages/annotator/components/sidebar/dataset/dataset-list-item-details.component.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@ export const DatasetListItemDetails = ({
7878
</View>
7979
<Flex direction={'column'} width={'100%'}>
8080
<TooltipTrigger placement={'bottom'}>
81-
<PressableElement UNSAFE_className={classes.itemMediaName}>{name}</PressableElement>
81+
<PressableElement isTruncated UNSAFE_className={classes.itemMediaName}>
82+
{name}
83+
</PressableElement>
8284
<Tooltip>{name}</Tooltip>
8385
</TooltipTrigger>
8486

web_ui/src/pages/annotator/components/sidebar/dataset/dataset-list.component.tsx

Lines changed: 2 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
// Copyright (C) 2022-2025 Intel Corporation
22
// LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
33

4-
import { useEffect, useMemo, useRef } from 'react';
4+
import { useMemo } from 'react';
55

66
import { Flex } from '@adobe/react-spectrum';
77
import { Loading } from '@geti/ui';
88
import { InfiniteData, UseInfiniteQueryResult } from '@tanstack/react-query';
99
import { isEmpty } from 'lodash-es';
10-
import { VirtuosoGridHandle } from 'react-virtuoso';
1110

1211
import { MediaAdvancedFilterResponse, MediaItem, MediaItemResponse } from '../../../../../core/media/media.interface';
13-
import { usePrevious } from '../../../../../hooks/use-previous/use-previous.hook';
1412
import { MediaItemsList } from '../../../../../shared/components/media-items-list/media-items-list.component';
1513
import { ViewModes } from '../../../../../shared/components/media-view-modes/utils';
1614
import { NotFound } from '../../../../../shared/components/not-found/not-found.component';
@@ -54,15 +52,11 @@ export const DatasetList = ({
5452
selectedMediaItem,
5553
isInActiveMode = false,
5654
isMediaFilterEmpty = false,
57-
previouslySelectedMediaItem,
5855
shouldShowAnnotationIndicator,
5956
}: DatasetListProps): JSX.Element => {
60-
const ref = useRef<VirtuosoGridHandle | null>(null);
61-
6257
const { hasNextPage, isPending: isMediaItemsLoading, isFetchingNextPage, fetchNextPage, data } = mediaItemsQuery;
6358

6459
const mediaItems = useMemo(() => data?.pages?.flatMap(({ media }) => media) ?? [], [data?.pages]);
65-
const prevViewMode = usePrevious(viewMode);
6660

6761
const loadNextMedia = async () => {
6862
if (isInActiveMode) {
@@ -77,32 +71,6 @@ export const DatasetList = ({
7771
const groupedMediaItems = useGroupedMediaItems(mediaItems);
7872
const mediaItemIndex = useSelectedMediaItemIndex(mediaItems, selectedMediaItem, isInActiveMode);
7973

80-
useEffect(() => {
81-
let timeoutId: ReturnType<typeof setTimeout> | null = null;
82-
const mediaItemChanged = previouslySelectedMediaItem?.identifier !== selectedMediaItem?.identifier;
83-
const viewModeChanged = prevViewMode !== viewMode;
84-
85-
// We want to automatically scroll to the previously selected media.
86-
// But only if the media item or view mode is different.
87-
if ((mediaItemChanged || viewModeChanged) && ref.current) {
88-
timeoutId = setTimeout(() => {
89-
ref.current?.scrollToIndex({
90-
index: mediaItemIndex,
91-
behavior: 'smooth',
92-
align: 'center',
93-
});
94-
// we don't want to scroll immediately
95-
// in case of changed view mode we have to scroll once view is rendered
96-
}, 500);
97-
}
98-
99-
return () => {
100-
timeoutId && clearTimeout(timeoutId);
101-
};
102-
103-
// eslint-disable-next-line react-hooks/exhaustive-deps
104-
}, [viewMode, selectedMediaItem?.identifier, previouslySelectedMediaItem?.identifier, prevViewMode]);
105-
10674
const allPagesAreEmpty = data?.pages.every((page) => isEmpty(page.media));
10775

10876
const shouldShowNotFound = allPagesAreEmpty && !isMediaItemsLoading && !isMediaFilterEmpty && !isInActiveMode;
@@ -136,6 +104,7 @@ export const DatasetList = ({
136104
getTextValue={(item) => item.name}
137105
mediaItems={groupedMediaItems}
138106
viewModeSettings={viewModeSettings}
107+
scrollToIndex={mediaItemIndex}
139108
itemContent={(mediaItem) => {
140109
return (
141110
<DatasetItemFactory

web_ui/src/pages/project-details/components/project-media/media-content-bucket.component.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
import { MediaDropBoxHeader } from '../../../../shared/components/media-drop/media-drop-box-header.component';
2323
import { MediaDropBox } from '../../../../shared/components/media-drop/media-drop-box.component';
2424
import { MediaItemsList } from '../../../../shared/components/media-items-list/media-items-list.component';
25-
import { INITIAL_VIEW_MODE } from '../../../../shared/components/media-view-modes/utils';
25+
import { INITIAL_VIEW_MODE, VIEW_MODE_SETTINGS, ViewModes } from '../../../../shared/components/media-view-modes/utils';
2626
import { TutorialCardBuilder } from '../../../../shared/components/tutorial-card/tutorial-card-builder.component';
2727
import { VALID_MEDIA_TYPES_DISPLAY } from '../../../../shared/media-utils';
2828
import { idMatchingFormat } from '../../../../test-utils/id-utils';
@@ -61,6 +61,11 @@ export interface MediaContentBucketProps {
6161
footerInfo?: ReactNode;
6262
}
6363

64+
const VIEW_MODE_SETTINGS_ANOMALY = {
65+
...VIEW_MODE_SETTINGS,
66+
[ViewModes.LARGE]: { minItemSize: 180, gap: 8, maxColumns: 2 },
67+
};
68+
6469
export const MediaContentBucket = ({
6570
header,
6671
description,
@@ -200,6 +205,7 @@ export const MediaContentBucket = ({
200205
getTextValue={(item) => item.name}
201206
mediaItems={media}
202207
viewMode={viewMode}
208+
viewModeSettings={isAnomalyProject ? VIEW_MODE_SETTINGS_ANOMALY : VIEW_MODE_SETTINGS}
203209
itemContent={(item) => (
204210
<MediaItemFactory
205211
mediaItem={item}

web_ui/src/pages/project-details/components/project-test/test-media-items-list.component.tsx

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
// Copyright (C) 2022-2025 Intel Corporation
22
// LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
33

4-
import { useEffect, useMemo, useRef } from 'react';
4+
import { useMemo } from 'react';
55

66
import { Flex, IllustratedMessage, Tooltip, TooltipTrigger, View } from '@adobe/react-spectrum';
77
import { Loading } from '@geti/ui';
88
import { InfiniteData, UseInfiniteQueryResult } from '@tanstack/react-query';
99
import { isEmpty } from 'lodash-es';
10-
import { VirtuosoGridHandle } from 'react-virtuoso';
1110

1211
import { MEDIA_TYPE } from '../../../../core/media/base-media.interface';
1312
import { MediaItem } from '../../../../core/media/media.interface';
@@ -61,7 +60,6 @@ export const TestMediaItemsList = ({
6160
loadNextMedia,
6261
selectMediaItem,
6362
}: TestMediaItemsListProps): JSX.Element => {
64-
const ref = useRef<VirtuosoGridHandle | null>(null);
6563
const { isLoading: isMediaItemsLoading, isFetchingNextPage, data } = mediaItemsQuery;
6664
const mediaItems = useMemo(() => data?.pages?.flatMap(({ media }) => media) ?? [], [data?.pages]);
6765

@@ -71,28 +69,6 @@ export const TestMediaItemsList = ({
7169

7270
const mediaItemIndex = useSelectedMediaItemIndex(mediaItems, selectedMediaItem, false, true);
7371

74-
useEffect(() => {
75-
let timeoutId: ReturnType<typeof setTimeout> | null = null;
76-
77-
if (ref.current && selectedMediaItem !== undefined) {
78-
timeoutId = setTimeout(() => {
79-
ref.current?.scrollToIndex({
80-
index: mediaItemIndex,
81-
behavior: 'smooth',
82-
align: 'center',
83-
});
84-
// we don't want to scroll immediately
85-
// in case of changed view mode we have to scroll once view is rendered
86-
}, 500);
87-
}
88-
89-
return () => {
90-
timeoutId && clearTimeout(timeoutId);
91-
};
92-
93-
// eslint-disable-next-line react-hooks/exhaustive-deps
94-
}, [selectedMediaItem]);
95-
9672
if (isMediaItemsLoading) {
9773
return (
9874
<Flex position={'relative'} alignItems={'center'} justifyContent={'center'} height={'100%'}>
@@ -124,6 +100,7 @@ export const TestMediaItemsList = ({
124100
viewModeSettings={viewModeSettings}
125101
idFormatter={getTestMediaItemId}
126102
getTextValue={(item) => item.media.name}
103+
scrollToIndex={mediaItemIndex}
127104
itemContent={(mediaItem) => {
128105
const mediaImageItem = mediaItem as unknown as TestImageMediaItem;
129106
const handleSelectMediaItem = () => selectMediaItem(mediaItem);

web_ui/src/shared/components/media-items-list/media-items-list.component.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from 'react-aria-components';
1717

1818
import { VIEW_MODE_SETTINGS, ViewModes } from '../media-view-modes/utils';
19+
import { useScrollToTargetItem } from './use-scroll-to-target-item.hook';
1920

2021
import classes from './media-items-list.module.scss';
2122

@@ -26,6 +27,7 @@ interface MediaItemsListProps<T> {
2627
viewMode: ViewModes;
2728
mediaItems: T[];
2829
height?: Responsive<DimensionValue>;
30+
scrollToIndex?: number;
2931
viewModeSettings?: ViewModeSettings;
3032
endReached?: () => void;
3133
itemContent: (item: T) => ReactNode;
@@ -38,6 +40,7 @@ export const MediaItemsList = <T extends object>({
3840
height,
3941
viewMode,
4042
mediaItems,
43+
scrollToIndex,
4144
ariaLabel = 'media items list',
4245
viewModeSettings = VIEW_MODE_SETTINGS,
4346
itemContent,
@@ -49,9 +52,6 @@ export const MediaItemsList = <T extends object>({
4952
const isDetails = viewMode === ViewModes.DETAILS;
5053
const layout = isDetails ? 'stack' : 'grid';
5154

52-
const ref = useRef<HTMLDivElement | null>(null);
53-
useLoadMore({ onLoadMore: endReached }, ref);
54-
5555
const layoutOptions = isDetails
5656
? {
5757
gap: config.gap,
@@ -64,6 +64,19 @@ export const MediaItemsList = <T extends object>({
6464
preserveAspectRatio: true,
6565
};
6666

67+
const ref = useRef<HTMLDivElement | null>(null);
68+
useLoadMore({ onLoadMore: endReached }, ref);
69+
70+
const container = ref?.current?.firstElementChild;
71+
72+
useScrollToTargetItem({
73+
gap: config.gap,
74+
container,
75+
targetIndex: scrollToIndex,
76+
dependencies: [scrollToIndex, viewMode, container],
77+
callback: (top) => ref.current?.scrollTo({ top, behavior: 'smooth' }),
78+
});
79+
6780
return (
6881
<View id={id} UNSAFE_className={classes.mainContainer} height={height}>
6982
<Virtualizer layout={isDetails ? ListLayout : GridLayout} layoutOptions={layoutOptions}>
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (C) 2022-2025 Intel Corporation
2+
// LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
3+
4+
import { renderHook } from '@testing-library/react';
5+
6+
import { useScrollToTargetItem } from './use-scroll-to-target-item.hook';
7+
8+
jest.useFakeTimers();
9+
10+
describe('useScrollToTargetItem', () => {
11+
const mockCallback = jest.fn();
12+
13+
beforeEach(() => {
14+
jest.clearAllMocks();
15+
jest.clearAllTimers();
16+
});
17+
18+
it('should not call callback when container is not provided', () => {
19+
renderHook(() =>
20+
useScrollToTargetItem({
21+
gap: 10,
22+
container: null,
23+
targetIndex: 5,
24+
callback: mockCallback,
25+
})
26+
);
27+
28+
jest.advanceTimersByTime(500);
29+
30+
expect(mockCallback).not.toHaveBeenCalled();
31+
});
32+
33+
it('calls callback with correct scroll position', () => {
34+
const gap = 0;
35+
const childWidth = 100;
36+
const childHeight = 100;
37+
const targetIndex = 12;
38+
const containerWidth = 200;
39+
40+
const container = document.createElement('div');
41+
Object.defineProperty(container, 'clientWidth', { value: containerWidth });
42+
43+
const child = document.createElement('div');
44+
Object.defineProperty(child, 'clientWidth', { value: childWidth });
45+
Object.defineProperty(child, 'clientHeight', { value: childHeight });
46+
47+
container.appendChild(child);
48+
49+
const itemsPerRow = Math.floor(containerWidth / childWidth); // 2
50+
const targetRow = Math.floor(targetIndex / itemsPerRow); // 6
51+
const expectedScrollPos = (childHeight + gap) * targetRow; // 600
52+
53+
renderHook(() =>
54+
useScrollToTargetItem({
55+
gap,
56+
container,
57+
targetIndex,
58+
callback: mockCallback,
59+
})
60+
);
61+
62+
jest.advanceTimersByTime(500);
63+
64+
expect(mockCallback).toHaveBeenCalledWith(expectedScrollPos);
65+
});
66+
67+
it('return zero when container has no children', () => {
68+
const container = document.createElement('div');
69+
Object.defineProperty(container, 'clientWidth', { value: 1000 });
70+
71+
renderHook(() =>
72+
useScrollToTargetItem({
73+
gap: 10,
74+
container,
75+
targetIndex: 5,
76+
callback: mockCallback,
77+
})
78+
);
79+
80+
jest.advanceTimersByTime(500);
81+
82+
expect(mockCallback).toHaveBeenCalledWith(0);
83+
});
84+
85+
describe('should not call callback with invalid index', () => {
86+
it.each([undefined, null, -1, 1.5, NaN])('targetIndex: %p', (invalidIndex) => {
87+
renderHook(() =>
88+
useScrollToTargetItem({
89+
gap: 10,
90+
container: document.createElement('div'),
91+
targetIndex: invalidIndex as number,
92+
callback: mockCallback,
93+
})
94+
);
95+
96+
jest.advanceTimersByTime(500);
97+
98+
expect(mockCallback).not.toHaveBeenCalled();
99+
});
100+
});
101+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (C) 2022-2025 Intel Corporation
2+
// LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
3+
4+
import { DependencyList, useEffect } from 'react';
5+
6+
import { isNil } from 'lodash-es';
7+
8+
interface useScrollToTargetItemProps {
9+
gap: number;
10+
dependencies?: DependencyList;
11+
container?: Element | null;
12+
targetIndex?: number;
13+
callback: (scrollTo: number) => void;
14+
}
15+
16+
const isValidIndex = (index?: number): index is number => !isNil(index) && Number.isInteger(index) && index >= 0;
17+
18+
export const useScrollToTargetItem = ({
19+
gap,
20+
container,
21+
targetIndex,
22+
dependencies = [],
23+
callback,
24+
}: useScrollToTargetItemProps) => {
25+
useEffect(() => {
26+
const timeoutId = setTimeout(() => {
27+
if (!container || !isValidIndex(targetIndex)) {
28+
return;
29+
}
30+
31+
const containerWidth = container.clientWidth;
32+
const childrenWidth = container.firstElementChild?.clientWidth ?? 1;
33+
const childrenHeight = container.firstElementChild?.clientHeight ?? 1;
34+
const childrenPreRow = Math.floor(containerWidth / childrenWidth);
35+
const targetRow = Math.floor(targetIndex / childrenPreRow);
36+
const scrollTo = (childrenHeight + gap) * targetRow;
37+
38+
callback(scrollTo);
39+
// we don't want to scroll immediately
40+
// in case of changed view mode we have to scroll once view is rendered
41+
}, 500);
42+
43+
return () => {
44+
timeoutId && clearTimeout(timeoutId);
45+
};
46+
// eslint-disable-next-line react-hooks/exhaustive-deps
47+
}, dependencies);
48+
};

web_ui/src/shared/components/media-view-modes/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ export const INITIAL_VIEW_MODE = ViewModes.MEDIUM;
1212
export const VIEW_MODE_LABEL = 'View mode';
1313

1414
export const VIEW_MODE_SETTINGS = {
15-
[ViewModes.SMALL]: { minItemSize: 112, gap: 4, maxColumns: 11 },
15+
[ViewModes.LARGE]: { minItemSize: 300, gap: 8, maxColumns: 4 },
1616
[ViewModes.MEDIUM]: { minItemSize: 150, gap: 8, maxColumns: 8 },
17-
[ViewModes.LARGE]: { minItemSize: 300, gap: 12, maxColumns: 4 },
17+
[ViewModes.SMALL]: { minItemSize: 112, gap: 4, maxColumns: 11 },
1818
[ViewModes.DETAILS]: { size: 81, gap: 0 },
1919
};

0 commit comments

Comments
 (0)