tabList.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import {useContext, useEffect, useMemo, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {AriaTabListProps, useTabList} from '@react-aria/tabs';
  4. import {Item, useCollection} from '@react-stately/collections';
  5. import {ListCollection} from '@react-stately/list';
  6. import {TabListProps as TabListStateProps, useTabListState} from '@react-stately/tabs';
  7. import {Node, Orientation} from '@react-types/shared';
  8. import CompactSelect from 'sentry/components/compactSelect';
  9. import DropdownButton from 'sentry/components/dropdownButton';
  10. import {IconEllipsis} from 'sentry/icons';
  11. import {t} from 'sentry/locale';
  12. import space from 'sentry/styles/space';
  13. import {TabsContext} from './index';
  14. import {Tab} from './tab';
  15. import {tabsShouldForwardProp} from './utils';
  16. /**
  17. * Uses IntersectionObserver API to detect overflowing tabs. Returns an array
  18. * containing of keys of overflowing tabs.
  19. */
  20. function useOverflowTabs({
  21. tabListRef,
  22. tabItemsRef,
  23. }: {
  24. tabItemsRef: React.RefObject<Record<React.Key, HTMLLIElement | null>>;
  25. tabListRef: React.RefObject<HTMLUListElement>;
  26. }) {
  27. const [overflowTabs, setOverflowTabs] = useState<React.Key[]>([]);
  28. useEffect(() => {
  29. const options = {
  30. root: tabListRef.current,
  31. // Nagative right margin to account for overflow menu's trigger button
  32. rootMargin: `0px -42px 1px ${space(1)}`,
  33. threshold: 1,
  34. };
  35. const callback: IntersectionObserverCallback = entries => {
  36. entries.forEach(entry => {
  37. const {target} = entry;
  38. const {key} = (target as HTMLElement).dataset;
  39. if (!key) {
  40. return;
  41. }
  42. if (!entry.isIntersecting) {
  43. setOverflowTabs(prev => prev.concat([key]));
  44. return;
  45. }
  46. setOverflowTabs(prev => prev.filter(k => k !== key));
  47. });
  48. };
  49. const observer = new IntersectionObserver(callback, options);
  50. Object.values(tabItemsRef.current ?? {}).forEach(
  51. element => element && observer.observe(element)
  52. );
  53. return () => observer.disconnect();
  54. }, [tabListRef, tabItemsRef]);
  55. return overflowTabs;
  56. }
  57. interface TabListProps extends TabListStateProps<any>, AriaTabListProps<any> {
  58. className?: string;
  59. hideBorder?: boolean;
  60. }
  61. function BaseTabList({hideBorder = false, className, ...props}: TabListProps) {
  62. const tabListRef = useRef<HTMLUListElement>(null);
  63. const {rootProps, setTabListState} = useContext(TabsContext);
  64. const {value, defaultValue, onChange, orientation, disabled, ...otherRootProps} =
  65. rootProps;
  66. // Load up list state
  67. const ariaProps = {
  68. selectedKey: value,
  69. defaultSelectedKey: defaultValue,
  70. onSelectionChange: onChange,
  71. isDisabled: disabled,
  72. ...otherRootProps,
  73. ...props,
  74. };
  75. const state = useTabListState(ariaProps);
  76. const {tabListProps} = useTabList({orientation, ...ariaProps}, state, tabListRef);
  77. useEffect(() => {
  78. setTabListState(state);
  79. // eslint-disable-next-line react-hooks/exhaustive-deps
  80. }, [state.disabledKeys, state.selectedItem, state.selectedKey, props.children]);
  81. // Detect tabs that overflow from the wrapper and put them in an overflow menu
  82. const tabItemsRef = useRef<Record<React.Key, HTMLLIElement | null>>({});
  83. const overflowTabs = useOverflowTabs({tabListRef, tabItemsRef});
  84. const overflowMenuItems = useMemo(() => {
  85. // Sort overflow items in the order that they appear in TabList
  86. const sortedKeys = [...state.collection].map(item => item.key);
  87. const sortedOverflowTabs = overflowTabs.sort(
  88. (a, b) => sortedKeys.indexOf(a) - sortedKeys.indexOf(b)
  89. );
  90. return sortedOverflowTabs.map(key => {
  91. const item = state.collection.getItem(key);
  92. return {
  93. value: key,
  94. label: item.props.children,
  95. disabled: item.props.disabled,
  96. };
  97. });
  98. }, [state.collection, overflowTabs]);
  99. return (
  100. <TabListOuterWrap>
  101. <TabListWrap
  102. {...tabListProps}
  103. orientation={orientation}
  104. hideBorder={hideBorder}
  105. className={className}
  106. ref={tabListRef}
  107. >
  108. {[...state.collection].map(item => (
  109. <Tab
  110. key={item.key}
  111. item={item}
  112. state={state}
  113. orientation={orientation}
  114. overflowing={orientation === 'horizontal' && overflowTabs.includes(item.key)}
  115. ref={element => (tabItemsRef.current[item.key] = element)}
  116. />
  117. ))}
  118. </TabListWrap>
  119. {orientation === 'horizontal' && overflowMenuItems.length > 0 && (
  120. <CompactSelect
  121. options={overflowMenuItems}
  122. value={[...state.selectionManager.selectedKeys][0]}
  123. onChange={opt => state.setSelectedKey(opt.value)}
  124. isDisabled={disabled}
  125. position="bottom-end"
  126. size="sm"
  127. offset={4}
  128. trigger={triggerProps => (
  129. <OverflowMenuTrigger
  130. {...triggerProps}
  131. borderless
  132. showChevron={false}
  133. icon={<IconEllipsis />}
  134. aria-label={t('More tabs')}
  135. />
  136. )}
  137. />
  138. )}
  139. </TabListOuterWrap>
  140. );
  141. }
  142. const collectionFactory = (nodes: Iterable<Node<any>>) => new ListCollection(nodes);
  143. /**
  144. * To be used as a direct child of the <Tabs /> component. See example usage
  145. * in tabs.stories.js
  146. */
  147. export function TabList({items, ...props}: TabListProps) {
  148. /**
  149. * Initial, unfiltered list of tab items.
  150. */
  151. const collection = useCollection({items, ...props}, collectionFactory);
  152. /**
  153. * Filtered list of items with hidden items (those with a `disbled` prop)
  154. * removed. The `hidden` prop is useful for hiding tabs based on some
  155. * conditions.
  156. */
  157. const parsedItems = useMemo(
  158. () =>
  159. [...collection]
  160. .filter(item => !item.props.hidden)
  161. .map(({key, props: itemProps}) => ({key, ...itemProps})),
  162. [collection]
  163. );
  164. /**
  165. * List of keys of disabled items (those with a `disbled` prop) to be passed
  166. * into `BaseTabList`.
  167. */
  168. const disabledKeys = useMemo(
  169. () => parsedItems.filter(item => item.disabled).map(item => item.key),
  170. [parsedItems]
  171. );
  172. return (
  173. <BaseTabList items={parsedItems} disabledKeys={disabledKeys} {...props}>
  174. {item => <Item {...item} />}
  175. </BaseTabList>
  176. );
  177. }
  178. const TabListOuterWrap = styled('div')`
  179. position: relative;
  180. `;
  181. const TabListWrap = styled('ul', {shouldForwardProp: tabsShouldForwardProp})<{
  182. hideBorder: boolean;
  183. orientation: Orientation;
  184. }>`
  185. position: relative;
  186. display: grid;
  187. padding: 0;
  188. margin: 0;
  189. list-style-type: none;
  190. flex-shrink: 0;
  191. ${p =>
  192. p.orientation === 'horizontal'
  193. ? `
  194. grid-auto-flow: column;
  195. justify-content: start;
  196. gap: ${space(2)};
  197. ${!p.hideBorder && `border-bottom: solid 1px ${p.theme.border};`}
  198. `
  199. : `
  200. height: 100%;
  201. grid-auto-flow: row;
  202. align-content: start;
  203. gap: 1px;
  204. padding-right: ${space(2)};
  205. ${!p.hideBorder && `border-right: solid 1px ${p.theme.border};`}
  206. `};
  207. `;
  208. const OverflowMenuTrigger = styled(DropdownButton)`
  209. position: absolute;
  210. right: 0;
  211. bottom: ${space(0.75)};
  212. padding-left: ${space(1)};
  213. padding-right: ${space(1)};
  214. `;