Cathy Teng 1 год назад
Родитель
Сommit
5f1bcf42ce

+ 79 - 0
static/app/components/carousel.spec.tsx

@@ -0,0 +1,79 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import Carousel from 'sentry/components/carousel';
+import Placeholder from 'sentry/components/placeholder';
+
+function setIntersectionObserver(
+  entries: {isIntersecting: boolean; target: {id: string}}[]
+) {
+  (() => {
+    return (global.IntersectionObserver = class IntersectionObserver {
+      [x: string]: any;
+      constructor(cb: any) {
+        this.cb = cb;
+      }
+      observe() {
+        this.cb(entries);
+      }
+      unobserve() {}
+      disconnect() {}
+    } as any);
+  })();
+}
+
+describe('Carousel', function () {
+  beforeEach(() => {});
+  it('hides arrows if content does not overflow in x', function () {
+    setIntersectionObserver([
+      {target: {id: 'left-anchor'}, isIntersecting: true},
+      {target: {id: 'right-anchor'}, isIntersecting: true},
+    ]);
+
+    render(
+      <Placeholder width="200px" height="100px">
+        <Carousel>
+          <Placeholder width="50px" height="50px" />
+        </Carousel>
+      </Placeholder>
+    );
+
+    expect(screen.queryByLabelText('Scroll left')).not.toBeInTheDocument();
+    expect(screen.queryByLabelText('Scroll right')).not.toBeInTheDocument();
+  });
+
+  it('does not show left arrow if all the way to the left', function () {
+    setIntersectionObserver([
+      {target: {id: 'left-anchor'}, isIntersecting: true},
+      {target: {id: 'right-anchor'}, isIntersecting: false},
+    ]);
+
+    render(
+      <Carousel>
+        <Placeholder />
+        <Placeholder />
+        <Placeholder />
+      </Carousel>
+    );
+
+    expect(screen.queryByLabelText('Scroll left')).not.toBeInTheDocument();
+    expect(screen.queryByLabelText('Scroll right')).toBeInTheDocument();
+  });
+
+  it('does not show right arrow if all the way to the right', async function () {
+    setIntersectionObserver([
+      {target: {id: 'left-anchor'}, isIntersecting: false},
+      {target: {id: 'right-anchor'}, isIntersecting: true},
+    ]);
+
+    render(
+      <Carousel>
+        <Placeholder />
+        <Placeholder />
+        <Placeholder />
+      </Carousel>
+    );
+
+    expect(await screen.findByLabelText('Scroll left')).toBeInTheDocument();
+    expect(screen.queryByLabelText('Scroll right')).not.toBeInTheDocument();
+  });
+});

+ 146 - 0
static/app/components/carousel.tsx

@@ -0,0 +1,146 @@
+import {useEffect, useRef, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {Button} from 'sentry/components/button';
+import {IconArrow} from 'sentry/icons';
+import {ArrowProps} from 'sentry/icons/iconArrow';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+
+type Props = {
+  children?: React.ReactNode;
+};
+
+function Carousel({children}: Props) {
+  const ref = useRef<HTMLDivElement | null>(null);
+  const [anchorRefs, setAnchorRefs] = useState<HTMLElement[]>([]);
+  const [childrenRefs, setChildrenRefs] = useState<HTMLElement[]>([]);
+  const [isAtStart, setIsAtStart] = useState(true);
+  const [isAtEnd, setIsAtEnd] = useState(false);
+
+  useEffect(() => {
+    if (!ref.current) {
+      return () => {};
+    }
+
+    const observer = new IntersectionObserver(
+      entries => {
+        entries.forEach(e => {
+          if (e.target.id === anchorRefs[0].id) {
+            setIsAtStart(e.isIntersecting);
+          } else if (e.target.id === anchorRefs[1].id) {
+            setIsAtEnd(e.isIntersecting);
+          }
+        });
+      },
+      {root: ref.current, threshold: [1]}
+    );
+
+    if (anchorRefs) {
+      anchorRefs.map(anchor => observer.observe(anchor));
+    }
+
+    return () => observer.disconnect();
+  }, [anchorRefs]);
+
+  useEffect(() => {
+    if (!ref.current) {
+      return;
+    }
+
+    setChildrenRefs(Array.from(ref.current.children) as HTMLElement[]);
+
+    const anchors = [
+      ref.current.children[0],
+      ref.current.children[ref.current.children.length - 1],
+    ] as HTMLElement[];
+    setAnchorRefs(anchors);
+  }, [children]);
+
+  const handleScroll = (direction: string) => {
+    if (!ref.current) {
+      return;
+    }
+
+    const scrollLeft = ref.current.scrollLeft;
+
+    if (direction === 'left') {
+      // scroll to the last child to the left of the left side of the container
+      const elements = childrenRefs.filter(child => child.offsetLeft < scrollLeft);
+      ref.current.scrollTo(elements[elements.length - 1].offsetLeft, 0);
+    } else if (direction === 'right') {
+      // scroll to the first child to the right of the left side of the container
+      const elements = childrenRefs.filter(child => child.offsetLeft > scrollLeft);
+      ref.current.scrollTo(elements[0].offsetLeft, 0);
+    }
+  };
+
+  return (
+    <CarouselContainer>
+      <CarouselItems ref={ref}>
+        <Anchor id="left-anchor" />
+        {children}
+        <Anchor id="right-anchor" />
+      </CarouselItems>
+      {!isAtStart && (
+        <ScrollButton onClick={() => handleScroll('left')} direction="left" />
+      )}
+      {!isAtEnd && (
+        <ScrollButton onClick={() => handleScroll('right')} direction="right" />
+      )}
+    </CarouselContainer>
+  );
+}
+
+const CarouselContainer = styled('div')`
+  position: relative;
+  padding-bottom: ${space(0.5)};
+`;
+
+const CarouselItems = styled('div')`
+  display: flex;
+  overflow-x: scroll;
+  scroll-behavior: smooth;
+  &::-webkit-scrollbar {
+    background-color: transparent;
+    height: 8px;
+  }
+  &::-webkit-scrollbar-thumb {
+    background: ${p => p.theme.gray300};
+    border-radius: 8px;
+  }
+`;
+
+const Anchor = styled('div')``;
+
+type ScrollButtonProps = {
+  direction: ArrowProps['direction'];
+  onClick: () => void;
+};
+
+function ScrollButton({onClick, direction = 'left'}: ScrollButtonProps) {
+  return (
+    <StyledArrowButton
+      onClick={onClick}
+      direction={direction}
+      aria-label={t('Scroll %s', direction)}
+      icon={<IconArrow size="sm" direction={direction} />}
+    />
+  );
+}
+
+const StyledArrowButton = styled(Button)<{direction: string}>`
+  position: absolute;
+  ${p => (p.direction === 'left' ? `left: 0;` : `right: 0;`)}
+  top: 0;
+  bottom: 0;
+  height: 36px;
+  width: 36px;
+  border-radius: 50%;
+  border: 1px solid ${p => p.theme.gray200};
+  padding: 0;
+  margin: auto;
+  background-color: ${p => p.theme.background};
+`;
+
+export default Carousel;

+ 2 - 2
static/app/icons/iconArrow.tsx

@@ -3,11 +3,11 @@ import {css, useTheme} from '@emotion/react';
 
 import {SvgIcon, SVGIconProps} from './svgIcon';
 
-interface Props extends SVGIconProps {
+export interface ArrowProps extends SVGIconProps {
   direction?: 'up' | 'right' | 'down' | 'left';
 }
 
-const IconArrow = forwardRef<SVGSVGElement, Props>(
+const IconArrow = forwardRef<SVGSVGElement, ArrowProps>(
   ({direction = 'up', ...props}, ref) => {
     const theme = useTheme();