tabList.tsx 8.4 KB

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