carousel.tsx 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. import {useCallback, useEffect, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Button} from 'sentry/components/button';
  4. import {IconArrow} from 'sentry/icons';
  5. import {t} from 'sentry/locale';
  6. import {space} from 'sentry/styles/space';
  7. interface CarouselProps {
  8. children?: React.ReactNode;
  9. /**
  10. * This number determines what percentage of an element must be within the
  11. * visible scroll region for it to be considered 'visible'. If it is visible
  12. * but slightly off screen it will be skipped when scrolling
  13. *
  14. * For example, if set to 0.8, and 10% of the element is out of the scroll
  15. * area to the right, pressing the right arrow will skip over scrolling to
  16. * this element, and will scroll to the next invisible one.
  17. *
  18. * @default 0.8
  19. */
  20. visibleRatio?: number;
  21. }
  22. function Carousel({children, visibleRatio = 0.8}: CarouselProps) {
  23. const ref = useRef<HTMLDivElement | null>(null);
  24. // The visibility match up to the elements list. Visibility of elements is
  25. // true if visible in the scroll container, false if outside.
  26. const [childrenEls, setChildrenEls] = useState<HTMLElement[]>([]);
  27. const [visibility, setVisibility] = useState<boolean[]>([]);
  28. const isAtStart = visibility[0];
  29. const isAtEnd = visibility[visibility.length - 1];
  30. // Update list of children element
  31. useEffect(
  32. () => setChildrenEls(Array.from(ref.current?.children ?? []) as HTMLElement[]),
  33. [children]
  34. );
  35. // Update the threshold list. This
  36. useEffect(() => {
  37. if (!ref.current) {
  38. return () => {};
  39. }
  40. const observer = new IntersectionObserver(
  41. entries =>
  42. setVisibility(currentVisibility =>
  43. // Compute visibility list of the elements
  44. childrenEls.map((child, idx) => {
  45. const entry = entries.find(e => e.target === child);
  46. // NOTE: When the intersection observer fires, only elements that
  47. // have passed a threshold will be included in the entries list.
  48. // This is why we fallback to the currentThreshold value if there
  49. // was no entry for the child.
  50. return entry !== undefined
  51. ? entry.intersectionRatio > visibleRatio
  52. : currentVisibility[idx] ?? false;
  53. })
  54. ),
  55. {
  56. root: ref.current,
  57. threshold: [visibleRatio],
  58. }
  59. );
  60. childrenEls.map(child => observer.observe(child));
  61. return () => observer.disconnect();
  62. }, [childrenEls, visibleRatio]);
  63. const scrollLeft = useCallback(
  64. () =>
  65. childrenEls[visibility.findIndex(Boolean) - 1].scrollIntoView({
  66. behavior: 'smooth',
  67. block: 'nearest',
  68. inline: 'start',
  69. }),
  70. [visibility, childrenEls]
  71. );
  72. const scrollRight = useCallback(
  73. () =>
  74. childrenEls[visibility.findLastIndex(Boolean) + 1].scrollIntoView({
  75. behavior: 'smooth',
  76. block: 'nearest',
  77. inline: 'end',
  78. }),
  79. [visibility, childrenEls]
  80. );
  81. return (
  82. <CarouselContainer>
  83. <CarouselItems ref={ref}>{children}</CarouselItems>
  84. {!isAtStart && (
  85. <StyledArrowButton
  86. onClick={scrollLeft}
  87. direction="left"
  88. aria-label={t('Scroll left')}
  89. icon={<IconArrow size="sm" direction="left" />}
  90. />
  91. )}
  92. {!isAtEnd && (
  93. <StyledArrowButton
  94. onClick={scrollRight}
  95. direction="right"
  96. aria-label={t('Scroll right')}
  97. icon={<IconArrow size="sm" direction="right" />}
  98. />
  99. )}
  100. </CarouselContainer>
  101. );
  102. }
  103. const CarouselContainer = styled('div')`
  104. position: relative;
  105. /* We provide some margin to make room for the scroll bar. It is applied on
  106. * the top and bottom for consistency.
  107. */
  108. margin: ${space(0.25)};
  109. `;
  110. const CarouselItems = styled('div')`
  111. display: flex;
  112. overflow-x: scroll;
  113. scroll-behavior: smooth;
  114. /* We provide some margin to make room for the scroll bar. It is applied on
  115. * the top and bottom for consistency.
  116. */
  117. padding: ${space(1.5)} 0;
  118. `;
  119. const StyledArrowButton = styled(Button)<{direction: string}>`
  120. position: absolute;
  121. ${p => (p.direction === 'left' ? `left: 0;` : `right: 0;`)}
  122. top: 0;
  123. bottom: 0;
  124. height: 36px;
  125. width: 36px;
  126. border-radius: 50%;
  127. border: 1px solid ${p => p.theme.gray200};
  128. padding: 0;
  129. margin: auto;
  130. background-color: ${p => p.theme.background};
  131. `;
  132. export default Carousel;