Skip to content

Commit 73d18f4

Browse files
authored
feat(issues): Add scrollCarousel component (#76033)
1 parent 8532531 commit 73d18f4

File tree

7 files changed

+440
-88
lines changed

7 files changed

+440
-88
lines changed

static/app/components/carousel.tsx

Lines changed: 9 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import {useCallback, useEffect, useRef, useState} from 'react';
1+
import {useCallback, useRef} from 'react';
22
import styled from '@emotion/styled';
33

44
import {Button} from 'sentry/components/button';
55
import {IconArrow} from 'sentry/icons';
66
import {t} from 'sentry/locale';
77
import {space} from 'sentry/styles/space';
8+
import {useRefChildrenVisibility} from 'sentry/utils/useRefChildrenVisibility';
89

910
interface CarouselProps {
1011
children?: React.ReactNode;
@@ -23,55 +24,16 @@ interface CarouselProps {
2324
}
2425

2526
function Carousel({children, visibleRatio = 0.8}: CarouselProps) {
26-
const ref = useRef<HTMLDivElement | null>(null);
27-
28-
// The visibility match up to the elements list. Visibility of elements is
29-
// true if visible in the scroll container, false if outside.
30-
const [childrenEls, setChildrenEls] = useState<HTMLElement[]>([]);
31-
const [visibility, setVisibility] = useState<boolean[]>([]);
27+
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
28+
const {visibility, childrenEls} = useRefChildrenVisibility({
29+
children,
30+
scrollContainerRef,
31+
visibleRatio,
32+
});
3233

3334
const isAtStart = visibility[0];
3435
const isAtEnd = visibility[visibility.length - 1];
3536

36-
// Update list of children element
37-
useEffect(
38-
() => setChildrenEls(Array.from(ref.current?.children ?? []) as HTMLElement[]),
39-
[children]
40-
);
41-
42-
// Update the threshold list. This
43-
useEffect(() => {
44-
if (!ref.current) {
45-
return () => {};
46-
}
47-
48-
const observer = new IntersectionObserver(
49-
entries =>
50-
setVisibility(currentVisibility =>
51-
// Compute visibility list of the elements
52-
childrenEls.map((child, idx) => {
53-
const entry = entries.find(e => e.target === child);
54-
55-
// NOTE: When the intersection observer fires, only elements that
56-
// have passed a threshold will be included in the entries list.
57-
// This is why we fallback to the currentThreshold value if there
58-
// was no entry for the child.
59-
return entry !== undefined
60-
? entry.intersectionRatio > visibleRatio
61-
: currentVisibility[idx] ?? false;
62-
})
63-
),
64-
{
65-
root: ref.current,
66-
threshold: [visibleRatio],
67-
}
68-
);
69-
70-
childrenEls.map(child => observer.observe(child));
71-
72-
return () => observer.disconnect();
73-
}, [childrenEls, visibleRatio]);
74-
7537
const scrollLeft = useCallback(
7638
() =>
7739
childrenEls[visibility.findIndex(Boolean) - 1].scrollIntoView({
@@ -94,7 +56,7 @@ function Carousel({children, visibleRatio = 0.8}: CarouselProps) {
9456

9557
return (
9658
<CarouselContainer>
97-
<CarouselItems ref={ref}>{children}</CarouselItems>
59+
<CarouselItems ref={scrollContainerRef}>{children}</CarouselItems>
9860
{!isAtStart && (
9961
<StyledArrowButton
10062
onClick={scrollLeft}

static/app/components/events/highlights/highlightsIconSummary.tsx

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import styled from '@emotion/styled';
33

44
import {getOrderedContextItems} from 'sentry/components/events/contexts';
55
import {getContextIcon, getContextSummary} from 'sentry/components/events/contexts/utils';
6+
import {ScrollCarousel} from 'sentry/components/scrollCarousel';
67
import {space} from 'sentry/styles/space';
78
import type {Event} from 'sentry/types/event';
89
import {SectionDivider} from 'sentry/views/issueDetails/streamline/interimSection';
@@ -31,36 +32,24 @@ export function HighlightsIconSummary({event}: HighlightsIconSummaryProps) {
3132
return items.length ? (
3233
<Fragment>
3334
<IconBar>
34-
{items.map((item, index) => (
35-
<IconSummary key={index}>
36-
<IconWrapper>{item.icon}</IconWrapper>
37-
<IconTitle>{item.title}</IconTitle>
38-
<IconSubtitle>{item.subtitle}</IconSubtitle>
39-
</IconSummary>
40-
))}
35+
<ScrollCarousel gap={4}>
36+
{items.map((item, index) => (
37+
<IconSummary key={index}>
38+
<IconWrapper>{item.icon}</IconWrapper>
39+
<IconTitle>{item.title}</IconTitle>
40+
<IconSubtitle>{item.subtitle}</IconSubtitle>
41+
</IconSummary>
42+
))}
43+
</ScrollCarousel>
4144
</IconBar>
4245
<SectionDivider />
4346
</Fragment>
4447
) : null;
4548
}
4649

4750
const IconBar = styled('div')`
48-
display: flex;
49-
gap: ${space(4)};
50-
align-items: center;
51-
overflow-x: auto;
52-
overflow-y: hidden;
53-
margin: ${space(2)} ${space(0.75)};
5451
position: relative;
55-
&:after {
56-
position: sticky;
57-
height: 100%;
58-
padding: ${space(2)};
59-
content: '';
60-
inset: 0;
61-
left: 90%;
62-
background-image: linear-gradient(90deg, transparent, ${p => p.theme.background});
63-
}
52+
padding: ${space(2)} ${space(0.5)};
6453
`;
6554

6655
const IconSummary = styled('div')`
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
2+
3+
import {ScrollCarousel} from 'sentry/components/scrollCarousel';
4+
5+
describe('ScrollCarousel', function () {
6+
let intersectionOnbserverCb: (entries: Partial<IntersectionObserverEntry>[]) => void =
7+
jest.fn();
8+
9+
window.IntersectionObserver = class IntersectionObserver {
10+
root = null;
11+
rootMargin = '';
12+
thresholds = [];
13+
takeRecords = jest.fn();
14+
15+
constructor(cb: IntersectionObserverCallback) {
16+
// @ts-expect-error The callback wants just way too much stuff for our simple mock
17+
intersectionOnbserverCb = cb;
18+
}
19+
observe() {}
20+
unobserve() {}
21+
disconnect() {}
22+
};
23+
24+
it('hides arrows if content does not overflow in x', function () {
25+
render(
26+
<ScrollCarousel>
27+
<div data-test-id="child-1" />
28+
</ScrollCarousel>
29+
);
30+
31+
// Child is visible
32+
act(() =>
33+
intersectionOnbserverCb([
34+
{target: screen.getByTestId('child-1'), intersectionRatio: 1},
35+
])
36+
);
37+
38+
expect(screen.queryByRole('button', {name: 'Scroll left'})).not.toBeInTheDocument();
39+
expect(screen.queryByRole('button', {name: 'Scroll right'})).not.toBeInTheDocument();
40+
});
41+
42+
it('shows right arrow when elements exist to the right', async function () {
43+
render(
44+
<ScrollCarousel>
45+
<div data-test-id="child-1" />
46+
<div data-test-id="child-2" />
47+
<div data-test-id="child-3" />
48+
</ScrollCarousel>
49+
);
50+
51+
const elements = [
52+
screen.getByTestId('child-1'),
53+
screen.getByTestId('child-2'),
54+
screen.getByTestId('child-3'),
55+
];
56+
57+
// Element on the right is not visible
58+
act(() =>
59+
intersectionOnbserverCb([
60+
{target: elements[0], intersectionRatio: 1},
61+
{target: elements[1], intersectionRatio: 0.5},
62+
{target: elements[2], intersectionRatio: 0},
63+
])
64+
);
65+
66+
const rightButton = screen.getByRole('button', {name: 'Scroll right'});
67+
expect(screen.queryByRole('button', {name: 'Scroll left'})).not.toBeInTheDocument();
68+
69+
// Test scroll into view, the last element should have its 'scrollIntoView' called
70+
elements.at(-1)!.scrollIntoView = jest.fn();
71+
await userEvent.click(rightButton);
72+
expect(elements.at(-1)!.scrollIntoView).toHaveBeenCalled();
73+
});
74+
75+
it('shows left arrow when elements exist to the left', async function () {
76+
render(
77+
<ScrollCarousel>
78+
<div data-test-id="child-1" />
79+
<div data-test-id="child-2" />
80+
<div data-test-id="child-3" />
81+
</ScrollCarousel>
82+
);
83+
84+
const elements = [
85+
screen.getByTestId('child-1'),
86+
screen.getByTestId('child-2'),
87+
screen.getByTestId('child-3'),
88+
];
89+
90+
// Element on the left is not visible
91+
act(() =>
92+
intersectionOnbserverCb([
93+
{target: elements[0], intersectionRatio: 0},
94+
{target: elements[1], intersectionRatio: 1},
95+
{target: elements[2], intersectionRatio: 1},
96+
])
97+
);
98+
99+
const leftButton = await screen.findByRole('button', {name: 'Scroll left'});
100+
expect(screen.queryByRole('button', {name: 'Scroll right'})).not.toBeInTheDocument();
101+
102+
// Test scroll into view, the 1st element should have its 'scrollIntoView' called
103+
elements[0].scrollIntoView = jest.fn();
104+
await userEvent.click(leftButton);
105+
expect(elements[0].scrollIntoView).toHaveBeenCalled();
106+
});
107+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {Fragment} from 'react';
2+
import {css} from '@emotion/react';
3+
import styled from '@emotion/styled';
4+
5+
import JSXNode from 'sentry/components/stories/jsxNode';
6+
import storyBook from 'sentry/stories/storyBook';
7+
import {space} from 'sentry/styles/space';
8+
9+
import {ScrollCarousel} from './scrollCarousel';
10+
11+
export default storyBook(ScrollCarousel, story => {
12+
story('Default', () => (
13+
<Fragment>
14+
<p>
15+
<JSXNode name="ScrollCarousel" /> will detect if the content overflows and show
16+
arrows to scroll left and right. Native scrollbars are hidden.
17+
</p>
18+
<div style={{width: '375px', display: 'block'}}>
19+
<ScrollCarousel
20+
css={css`
21+
gap: ${space(1)};
22+
`}
23+
>
24+
{['one', 'two', 'three', 'four', 'five', 'six'].map(item => (
25+
<ExampleItem key={item}>{item}</ExampleItem>
26+
))}
27+
</ScrollCarousel>
28+
</div>
29+
</Fragment>
30+
));
31+
});
32+
33+
const ExampleItem = styled('div')`
34+
min-width: 100px;
35+
height: 30px;
36+
display: flex;
37+
align-items: center;
38+
justify-content: center;
39+
border: 1px solid ${p => p.theme.border};
40+
border-radius: ${p => p.theme.borderRadius};
41+
background-color: ${p => p.theme.backgroundSecondary};
42+
43+
&:hover {
44+
background-color: ${p => p.theme.backgroundTertiary};
45+
}
46+
`;

0 commit comments

Comments
 (0)