draggableTabList.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import {Fragment, useContext, useEffect, useMemo, useRef} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {AriaTabListOptions} from '@react-aria/tabs';
  4. import {useTabList} from '@react-aria/tabs';
  5. import {useCollection} from '@react-stately/collections';
  6. import {ListCollection} from '@react-stately/list';
  7. import type {TabListStateOptions} from '@react-stately/tabs';
  8. import {useTabListState} from '@react-stately/tabs';
  9. import type {Node} from '@react-types/shared';
  10. import {motion, Reorder} from 'framer-motion';
  11. import {Button} from 'sentry/components/button';
  12. import {TabsContext} from 'sentry/components/tabs';
  13. import {type BaseTabProps, Tab} from 'sentry/components/tabs/tab';
  14. import {IconAdd} from 'sentry/icons';
  15. import {t} from 'sentry/locale';
  16. import {space} from 'sentry/styles/space';
  17. import {browserHistory} from 'sentry/utils/browserHistory';
  18. import type {DraggableTabListItemProps} from './item';
  19. import {Item} from './item';
  20. interface BaseDraggableTabListProps extends DraggableTabListProps {
  21. items: DraggableTabListItemProps[];
  22. }
  23. function BaseDraggableTabList({
  24. hideBorder = false,
  25. className,
  26. outerWrapStyles,
  27. onReorder,
  28. onAddView,
  29. tabVariant = 'filled',
  30. ...props
  31. }: BaseDraggableTabListProps) {
  32. const tabListRef = useRef<HTMLUListElement>(null);
  33. const {rootProps, setTabListState} = useContext(TabsContext);
  34. const {
  35. value,
  36. defaultValue,
  37. onChange,
  38. disabled,
  39. orientation = 'horizontal',
  40. keyboardActivation = 'manual',
  41. ...otherRootProps
  42. } = rootProps;
  43. // Load up list state
  44. const ariaProps = {
  45. selectedKey: value,
  46. defaultSelectedKey: defaultValue,
  47. onSelectionChange: key => {
  48. onChange?.(key);
  49. // If the newly selected tab is a tab link, then navigate to the specified link
  50. const linkTo = [...(props.items ?? [])].find(item => item.key === key)?.to;
  51. if (!linkTo) {
  52. return;
  53. }
  54. browserHistory.push(linkTo);
  55. },
  56. isDisabled: disabled,
  57. keyboardActivation,
  58. ...otherRootProps,
  59. ...props,
  60. };
  61. const state = useTabListState(ariaProps);
  62. const {tabListProps} = useTabList({orientation, ...ariaProps}, state, tabListRef);
  63. useEffect(() => {
  64. setTabListState(state);
  65. // eslint-disable-next-line react-hooks/exhaustive-deps
  66. }, [state.selectedKey]);
  67. // Detect tabs that overflow from the wrapper and put them in an overflow menu
  68. const tabItemsRef = useRef<Record<string | number, HTMLLIElement | null>>({});
  69. const persistentTabs = [...state.collection].filter(
  70. item => item.key !== 'temporary-tab'
  71. );
  72. const tempTab = [...state.collection].find(item => item.key === 'temporary-tab');
  73. return (
  74. <TabListOuterWrap
  75. style={outerWrapStyles}
  76. hideBorder={hideBorder}
  77. borderStyle={state.selectedKey === 'temporary-tab' ? 'dashed' : 'solid'}
  78. >
  79. <Reorder.Group
  80. axis="x"
  81. values={[...state.collection]}
  82. onReorder={onReorder}
  83. as="div"
  84. style={{display: 'grid', gridAutoFlow: 'column', gridAutoColumns: 'min-content'}}
  85. layoutRoot
  86. >
  87. <TabListWrap {...tabListProps} className={className} ref={tabListRef}>
  88. {persistentTabs.map(item => (
  89. <Fragment key={item.key}>
  90. <Reorder.Item
  91. key={item.key}
  92. value={item}
  93. style={{display: 'flex', flexDirection: 'row'}}
  94. as="div"
  95. dragConstraints={tabListRef} // Sets the container that the tabs can be dragged within
  96. dragElastic={0} // Prevents tabs from being dragged outside of the tab bar
  97. dragTransition={{bounceStiffness: 400, bounceDamping: 40}} // Recovers spring behavior thats lost when using dragElastic
  98. layout
  99. >
  100. <Tab
  101. key={item.key}
  102. item={item}
  103. state={state}
  104. orientation={orientation}
  105. overflowing={false}
  106. ref={element => (tabItemsRef.current[item.key] = element)}
  107. variant={tabVariant}
  108. />
  109. </Reorder.Item>
  110. <TabDivider
  111. layout
  112. isVisible={
  113. state.selectedKey === 'temporary-tab' ||
  114. (state.selectedKey !== item.key &&
  115. state.collection.getKeyAfter(item.key) !== state.selectedKey)
  116. }
  117. />
  118. </Fragment>
  119. ))}
  120. </TabListWrap>
  121. <AddViewTempTabWrap>
  122. <MotionWrapper layout>
  123. <AddViewButton borderless size="zero" onClick={onAddView}>
  124. <StyledIconAdd size="xs" />
  125. {t('Add View')}
  126. </AddViewButton>
  127. </MotionWrapper>
  128. <MotionWrapper layout>
  129. {tempTab && (
  130. <Tab
  131. key={tempTab.key}
  132. item={tempTab}
  133. state={state}
  134. orientation={orientation}
  135. overflowing={false}
  136. ref={element => (tabItemsRef.current[tempTab.key] = element)}
  137. variant={tabVariant}
  138. borderStyle="dashed"
  139. />
  140. )}
  141. </MotionWrapper>
  142. </AddViewTempTabWrap>
  143. </Reorder.Group>
  144. </TabListOuterWrap>
  145. );
  146. }
  147. const collectionFactory = (nodes: Iterable<Node<any>>) => new ListCollection(nodes);
  148. export interface DraggableTabListProps
  149. extends AriaTabListOptions<DraggableTabListItemProps>,
  150. TabListStateOptions<DraggableTabListItemProps> {
  151. onReorder: (newOrder: Node<DraggableTabListItemProps>[]) => void;
  152. className?: string;
  153. hideBorder?: boolean;
  154. onAddView?: React.MouseEventHandler;
  155. outerWrapStyles?: React.CSSProperties;
  156. showTempTab?: boolean;
  157. tabVariant?: BaseTabProps['variant'];
  158. }
  159. /**
  160. * To be used as a direct child of the <Tabs /> component. See example usage
  161. * in tabs.stories.js
  162. */
  163. export function DraggableTabList({items, onAddView, ...props}: DraggableTabListProps) {
  164. const collection = useCollection({items, ...props}, collectionFactory);
  165. const parsedItems = useMemo(
  166. () => [...collection].map(({key, props: itemProps}) => ({key, ...itemProps})),
  167. [collection]
  168. );
  169. /**
  170. * List of keys of disabled items (those with a `disbled` prop) to be passed
  171. * into `BaseTabList`.
  172. */
  173. const disabledKeys = useMemo(
  174. () => parsedItems.filter(item => item.disabled).map(item => item.key),
  175. [parsedItems]
  176. );
  177. return (
  178. <BaseDraggableTabList
  179. items={parsedItems}
  180. onAddView={onAddView}
  181. disabledKeys={disabledKeys}
  182. {...props}
  183. >
  184. {item => <Item {...item} />}
  185. </BaseDraggableTabList>
  186. );
  187. }
  188. DraggableTabList.Item = Item;
  189. /**
  190. * TabDividers are only visible around NON-selected tabs. They are not visible around the selected tab,
  191. * but they still create some space and act as a gap between tabs.
  192. */
  193. const TabDivider = styled(motion.div, {
  194. shouldForwardProp: prop => prop !== 'isVisible',
  195. })<{isVisible: boolean}>`
  196. ${p =>
  197. p.isVisible &&
  198. `
  199. background-color: ${p.theme.gray200};
  200. height: 50%;
  201. width: 1px;
  202. border-radius: 6px;
  203. margin-right: ${space(0.5)};
  204. `}
  205. margin-top: 1px;
  206. margin-left: ${space(0.5)};
  207. `;
  208. const TabListOuterWrap = styled('div')<{
  209. borderStyle: 'dashed' | 'solid';
  210. hideBorder: boolean;
  211. }>`
  212. position: relative;
  213. ${p => !p.hideBorder && `border-bottom: solid 1px ${p.theme.border};`}
  214. `;
  215. const AddViewTempTabWrap = styled('div')`
  216. position: relative;
  217. display: grid;
  218. padding: 0;
  219. margin: 0;
  220. list-style-type: none;
  221. flex-shrink: 0;
  222. grid-auto-flow: column;
  223. justify-content: start;
  224. align-items: center;
  225. `;
  226. const TabListWrap = styled('ul')`
  227. position: relative;
  228. display: grid;
  229. justify-content: start;
  230. grid-auto-flow: column;
  231. padding: 0;
  232. margin: 0;
  233. list-style-type: none;
  234. flex-shrink: 0;
  235. align-items: center;
  236. `;
  237. const AddViewButton = styled(Button)`
  238. display: flex;
  239. color: ${p => p.theme.gray300};
  240. font-weight: normal;
  241. padding: ${space(0.5)};
  242. transform: translateY(1px);
  243. margin-right: ${space(0.5)};
  244. `;
  245. const StyledIconAdd = styled(IconAdd)`
  246. margin-right: 4px;
  247. `;
  248. const MotionWrapper = styled(motion.div)`
  249. display: flex;
  250. position: relative;
  251. `;