draggableTabList.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. import {
  2. type Dispatch,
  3. Fragment,
  4. type Key,
  5. type SetStateAction,
  6. useContext,
  7. useEffect,
  8. useMemo,
  9. useRef,
  10. useState,
  11. } from 'react';
  12. import styled from '@emotion/styled';
  13. import type {AriaTabListOptions} from '@react-aria/tabs';
  14. import {useTabList} from '@react-aria/tabs';
  15. import {useCollection} from '@react-stately/collections';
  16. import {ListCollection} from '@react-stately/list';
  17. import type {TabListState, TabListStateOptions} from '@react-stately/tabs';
  18. import {useTabListState} from '@react-stately/tabs';
  19. import type {Node} from '@react-types/shared';
  20. import {motion, Reorder} from 'framer-motion';
  21. import {Button} from 'sentry/components/button';
  22. import {CompactSelect} from 'sentry/components/compactSelect';
  23. import DropdownButton from 'sentry/components/dropdownButton';
  24. import {TabsContext} from 'sentry/components/tabs';
  25. import {type BaseTabProps, Tab} from 'sentry/components/tabs/tab';
  26. import {IconAdd, IconEllipsis} from 'sentry/icons';
  27. import {t} from 'sentry/locale';
  28. import {space} from 'sentry/styles/space';
  29. import {defined} from 'sentry/utils';
  30. import {trackAnalytics} from 'sentry/utils/analytics';
  31. import {browserHistory} from 'sentry/utils/browserHistory';
  32. import {useDimensions} from 'sentry/utils/useDimensions';
  33. import {useDimensionsMultiple} from 'sentry/utils/useDimensionsMultiple';
  34. import useOrganization from 'sentry/utils/useOrganization';
  35. import type {DraggableTabListItemProps} from './item';
  36. import {Item} from './item';
  37. export const TEMPORARY_TAB_KEY = 'temporary-tab';
  38. interface BaseDraggableTabListProps extends DraggableTabListProps {
  39. items: DraggableTabListItemProps[];
  40. }
  41. function useOverflowingTabs({state}: {state: TabListState<DraggableTabListItemProps>}) {
  42. const persistentTabs = [...state.collection].filter(
  43. item => item.key !== TEMPORARY_TAB_KEY
  44. );
  45. const outerRef = useRef<HTMLDivElement>(null);
  46. const addViewTempTabRef = useRef<HTMLDivElement>(null);
  47. const [tabElements, setTabElements] = useState<Array<HTMLDivElement | null>>([]);
  48. const {width: outerWidth} = useDimensions({elementRef: outerRef});
  49. const {width: addViewTempTabWidth} = useDimensions({elementRef: addViewTempTabRef});
  50. const tabsDimensions = useDimensionsMultiple({elements: tabElements});
  51. const overflowingTabs = useMemo(() => {
  52. const availableWidth = outerWidth - addViewTempTabWidth;
  53. let totalWidth = 0;
  54. const overflowing: Node<DraggableTabListItemProps>[] = [];
  55. for (let i = 0; i < tabsDimensions.length; i++) {
  56. totalWidth += tabsDimensions[i].width + 1; // 1 extra pixel for the divider
  57. if (totalWidth > availableWidth + 1) {
  58. overflowing.push(persistentTabs[i]);
  59. }
  60. }
  61. return overflowing.filter(defined);
  62. }, [outerWidth, addViewTempTabWidth, persistentTabs, tabsDimensions]);
  63. return {
  64. overflowingTabs,
  65. setTabElements,
  66. outerRef,
  67. addViewTempTabRef,
  68. persistentTabs,
  69. };
  70. }
  71. function OverflowMenu({
  72. state,
  73. overflowTabs,
  74. }: {
  75. overflowTabs: Node<DraggableTabListItemProps>[];
  76. state: TabListState<any>;
  77. }) {
  78. const options = useMemo(() => {
  79. return overflowTabs.map(tab => {
  80. return {
  81. value: tab.key,
  82. label: tab.textValue,
  83. textValue: tab.textValue,
  84. };
  85. });
  86. }, [overflowTabs]);
  87. return (
  88. <CompactSelect
  89. options={options}
  90. multiple={false}
  91. value={state.selectionManager.firstSelectedKey?.toString()}
  92. onChange={opt => state.setSelectedKey(opt.value)}
  93. position="bottom-end"
  94. size="sm"
  95. offset={4}
  96. trigger={triggerProps => (
  97. <OverflowMenuTrigger
  98. {...triggerProps}
  99. size="sm"
  100. borderless
  101. showChevron={false}
  102. icon={<IconEllipsis />}
  103. aria-label={t('More tabs')}
  104. />
  105. )}
  106. />
  107. );
  108. }
  109. function Tabs({
  110. orientation,
  111. ariaProps,
  112. state,
  113. className,
  114. onReorder,
  115. tabVariant,
  116. setTabRefs,
  117. tabs,
  118. overflowingTabs,
  119. hoveringKey,
  120. setHoveringKey,
  121. tempTabActive,
  122. }: {
  123. ariaProps: AriaTabListOptions<DraggableTabListItemProps>;
  124. hoveringKey: Key | 'addView' | null;
  125. onReorder: (newOrder: Node<DraggableTabListItemProps>[]) => void;
  126. orientation: 'horizontal' | 'vertical';
  127. overflowingTabs: Node<DraggableTabListItemProps>[];
  128. setHoveringKey: (key: Key | 'addView' | null) => void;
  129. setTabRefs: Dispatch<SetStateAction<Array<HTMLDivElement | null>>>;
  130. state: TabListState<DraggableTabListItemProps>;
  131. tabs: Node<DraggableTabListItemProps>[];
  132. tempTabActive: boolean;
  133. className?: string;
  134. disabled?: boolean;
  135. onChange?: (key: string | number) => void;
  136. tabVariant?: BaseTabProps['variant'];
  137. value?: string | number;
  138. }) {
  139. const tabListRef = useRef<HTMLUListElement>(null);
  140. const {tabListProps} = useTabList({orientation, ...ariaProps}, state, tabListRef);
  141. const values = useMemo(() => [...state.collection], [state.collection]);
  142. const [isDragging, setIsDragging] = useState(false);
  143. // Only apply this while dragging, because it causes tabs to stay within the container
  144. // which we do not want (we hide tabs once they overflow
  145. const dragConstraints = isDragging ? tabListRef : undefined;
  146. const isTabDividerVisible = tabKey => {
  147. // If the tab divider is succeeding or preceding the selected tab key
  148. if (
  149. state.selectedKey === tabKey ||
  150. (state.selectedKey !== TEMPORARY_TAB_KEY &&
  151. state.collection.getKeyAfter(tabKey) !== TEMPORARY_TAB_KEY &&
  152. state.collection.getKeyAfter(tabKey) === state.selectedKey)
  153. ) {
  154. return false;
  155. }
  156. // If the tab divider is succeeding or preceding the hovering tab key
  157. if (
  158. hoveringKey !== TEMPORARY_TAB_KEY &&
  159. (hoveringKey === tabKey || hoveringKey === state.collection.getKeyAfter(tabKey))
  160. ) {
  161. return false;
  162. }
  163. if (
  164. tempTabActive &&
  165. state.collection.getKeyAfter(tabKey) === TEMPORARY_TAB_KEY &&
  166. hoveringKey === 'addView'
  167. ) {
  168. return false;
  169. }
  170. if (
  171. tabKey !== TEMPORARY_TAB_KEY &&
  172. !state.collection.getKeyAfter(tabKey) &&
  173. hoveringKey === 'addView'
  174. ) {
  175. return false;
  176. }
  177. return true;
  178. };
  179. return (
  180. <TabListWrap {...tabListProps} className={className} ref={tabListRef}>
  181. <ReorderGroup
  182. axis="x"
  183. values={values}
  184. onReorder={onReorder}
  185. as="div"
  186. initial={false}
  187. >
  188. {tabs.map((item, i) => (
  189. <Fragment key={item.key}>
  190. <TabItemWrap
  191. isSelected={state.selectedKey === item.key}
  192. ref={el =>
  193. setTabRefs(old => {
  194. if (!el || old.includes(el)) {
  195. return old;
  196. }
  197. const newRefs = [...old];
  198. newRefs[i] = el;
  199. return newRefs;
  200. })
  201. }
  202. value={item}
  203. as="div"
  204. data-key={item.key}
  205. dragConstraints={dragConstraints} // dragConstraints are the bounds that the tab can be dragged within
  206. dragElastic={0} // Prevents the tab from being dragged outside of the dragConstraints (w/o this you can drag it outside but it'll spring back)
  207. dragTransition={{bounceStiffness: 400, bounceDamping: 40}} // Recovers spring behavior thats lost when using dragElastic=0
  208. transition={{delay: -0.1}} // Skips the first few frames of the animation that make the tab appear to shrink before growing
  209. layout
  210. onDrag={() => setIsDragging(true)}
  211. onDragEnd={() => setIsDragging(false)}
  212. onHoverStart={() => setHoveringKey(item.key)}
  213. onHoverEnd={() => setHoveringKey(null)}
  214. initial={false}
  215. >
  216. <Tab
  217. key={item.key}
  218. item={item}
  219. state={state}
  220. orientation={orientation}
  221. overflowing={overflowingTabs.some(tab => tab.key === item.key)}
  222. variant={tabVariant}
  223. />
  224. </TabItemWrap>
  225. <TabDivider isVisible={isTabDividerVisible(item.key)} initial={false} />
  226. </Fragment>
  227. ))}
  228. </ReorderGroup>
  229. </TabListWrap>
  230. );
  231. }
  232. function BaseDraggableTabList({
  233. hideBorder = false,
  234. className,
  235. outerWrapStyles,
  236. onReorder,
  237. onAddView,
  238. tabVariant = 'filled',
  239. ...props
  240. }: BaseDraggableTabListProps) {
  241. const [hoveringKey, setHoveringKey] = useState<Key | null>(null);
  242. const {rootProps, setTabListState} = useContext(TabsContext);
  243. const organization = useOrganization();
  244. const {
  245. value,
  246. defaultValue,
  247. onChange,
  248. disabled,
  249. orientation = 'horizontal',
  250. keyboardActivation = 'manual',
  251. ...otherRootProps
  252. } = rootProps;
  253. // Load up list state
  254. const ariaProps = {
  255. selectedKey: value,
  256. defaultSelectedKey: defaultValue,
  257. onSelectionChange: key => {
  258. onChange?.(key);
  259. // If the newly selected tab is a tab link, then navigate to the specified link
  260. const linkTo = [...(props.items ?? [])].find(item => item.key === key)?.to;
  261. if (!linkTo) {
  262. return;
  263. }
  264. trackAnalytics('issue_views.switched_views', {
  265. organization,
  266. });
  267. browserHistory.push(linkTo);
  268. },
  269. isDisabled: disabled,
  270. keyboardActivation,
  271. ...otherRootProps,
  272. ...props,
  273. };
  274. const state = useTabListState(ariaProps);
  275. useEffect(() => {
  276. setTabListState(state);
  277. // eslint-disable-next-line react-hooks/exhaustive-deps
  278. }, [state.selectedKey]);
  279. const tempTab = [...state.collection].find(item => item.key === TEMPORARY_TAB_KEY);
  280. const {outerRef, setTabElements, persistentTabs, overflowingTabs, addViewTempTabRef} =
  281. useOverflowingTabs({state});
  282. return (
  283. <TabListOuterWrap
  284. style={outerWrapStyles}
  285. hideBorder={hideBorder}
  286. borderStyle={state.selectedKey === TEMPORARY_TAB_KEY ? 'dashed' : 'solid'}
  287. ref={outerRef}
  288. >
  289. <Tabs
  290. orientation={orientation}
  291. ariaProps={ariaProps}
  292. state={state}
  293. className={className}
  294. onReorder={onReorder}
  295. tabVariant={tabVariant}
  296. setTabRefs={setTabElements}
  297. tabs={persistentTabs}
  298. overflowingTabs={overflowingTabs}
  299. hoveringKey={hoveringKey}
  300. setHoveringKey={setHoveringKey}
  301. tempTabActive={!!tempTab}
  302. />
  303. <AddViewTempTabWrap ref={addViewTempTabRef}>
  304. <AddViewMotionWrapper
  305. onHoverStart={() => setHoveringKey('addView')}
  306. onHoverEnd={() => setHoveringKey(null)}
  307. >
  308. <AddViewButton
  309. borderless
  310. size="zero"
  311. onClick={onAddView}
  312. analyticsEventName="Issue Views: Add View Clicked"
  313. analyticsEventKey="issue_views.add_view.clicked"
  314. >
  315. <StyledIconAdd size="xs" />
  316. {t('Add View')}
  317. </AddViewButton>
  318. </AddViewMotionWrapper>
  319. <TabDivider
  320. isVisible={
  321. defined(tempTab) &&
  322. state?.selectedKey !== TEMPORARY_TAB_KEY &&
  323. hoveringKey !== 'addView' &&
  324. hoveringKey !== TEMPORARY_TAB_KEY
  325. }
  326. />
  327. <MotionWrapper
  328. onHoverStart={() => setHoveringKey(TEMPORARY_TAB_KEY)}
  329. onHoverEnd={() => setHoveringKey(null)}
  330. >
  331. {tempTab && (
  332. <TempTabWrap>
  333. <Tab
  334. key={TEMPORARY_TAB_KEY}
  335. item={tempTab}
  336. state={state}
  337. orientation={orientation}
  338. overflowing={false}
  339. variant={tabVariant}
  340. borderStyle="dashed"
  341. />
  342. </TempTabWrap>
  343. )}
  344. </MotionWrapper>
  345. {overflowingTabs.length > 0 ? (
  346. <OverflowMenu state={state} overflowTabs={overflowingTabs} />
  347. ) : null}
  348. </AddViewTempTabWrap>
  349. </TabListOuterWrap>
  350. );
  351. }
  352. const collectionFactory = (nodes: Iterable<Node<any>>) => new ListCollection(nodes);
  353. export interface DraggableTabListProps
  354. extends AriaTabListOptions<DraggableTabListItemProps>,
  355. TabListStateOptions<DraggableTabListItemProps> {
  356. onReorder: (newOrder: Node<DraggableTabListItemProps>[]) => void;
  357. className?: string;
  358. hideBorder?: boolean;
  359. onAddView?: React.MouseEventHandler;
  360. outerWrapStyles?: React.CSSProperties;
  361. showTempTab?: boolean;
  362. tabVariant?: BaseTabProps['variant'];
  363. }
  364. /**
  365. * To be used as a direct child of the <Tabs /> component. See example usage
  366. * in tabs.stories.js
  367. */
  368. export function DraggableTabList({items, onAddView, ...props}: DraggableTabListProps) {
  369. const collection = useCollection({items, ...props}, collectionFactory);
  370. const parsedItems = useMemo(
  371. () => [...collection].map(({key, props: itemProps}) => ({key, ...itemProps})),
  372. [collection]
  373. );
  374. /**
  375. * List of keys of disabled items (those with a `disbled` prop) to be passed
  376. * into `BaseTabList`.
  377. */
  378. const disabledKeys = useMemo(
  379. () => parsedItems.filter(item => item.disabled).map(item => item.key),
  380. [parsedItems]
  381. );
  382. return (
  383. <BaseDraggableTabList
  384. items={parsedItems}
  385. onAddView={onAddView}
  386. disabledKeys={disabledKeys}
  387. {...props}
  388. >
  389. {item => <Item {...item} />}
  390. </BaseDraggableTabList>
  391. );
  392. }
  393. DraggableTabList.Item = Item;
  394. const TabItemWrap = styled(Reorder.Item, {
  395. shouldForwardProp: prop => prop !== 'isSelected',
  396. })<{isSelected: boolean}>`
  397. display: flex;
  398. position: relative;
  399. z-index: ${p => (p.isSelected ? 1 : 0)};
  400. `;
  401. const TempTabWrap = styled('div')`
  402. display: flex;
  403. position: relative;
  404. line-height: 1.6;
  405. `;
  406. /**
  407. * TabDividers are only visible around NON-selected tabs. They are not visible around the selected tab,
  408. * but they still create some space and act as a gap between tabs.
  409. */
  410. const TabDivider = styled(motion.div, {
  411. shouldForwardProp: prop => prop !== 'isVisible',
  412. })<{isVisible: boolean}>`
  413. ${p =>
  414. p.isVisible &&
  415. `
  416. background-color: ${p.theme.gray200};
  417. height: 16px;
  418. width: 1px;
  419. border-radius: 6px;
  420. `}
  421. ${p => !p.isVisible && `margin-left: 1px;`}
  422. margin-top: 1px;
  423. `;
  424. const TabListOuterWrap = styled('div')<{
  425. borderStyle: 'dashed' | 'solid';
  426. hideBorder: boolean;
  427. }>`
  428. position: relative;
  429. ${p => !p.hideBorder && `border-bottom: solid 1px ${p.theme.border};`}
  430. display: grid;
  431. grid-template-columns: minmax(auto, max-content) minmax(max-content, 1fr);
  432. bottom: -1px;
  433. `;
  434. const AddViewTempTabWrap = styled('div')`
  435. position: relative;
  436. display: grid;
  437. padding: 0;
  438. margin: 0;
  439. list-style-type: none;
  440. flex-shrink: 0;
  441. grid-auto-flow: column;
  442. justify-content: start;
  443. align-items: center;
  444. `;
  445. const TabListWrap = styled('ul')`
  446. padding: 0;
  447. margin: 0;
  448. list-style-type: none;
  449. overflow-x: hidden;
  450. `;
  451. const ReorderGroup = styled(Reorder.Group<Node<DraggableTabListItemProps>>)`
  452. display: flex;
  453. align-items: center;
  454. overflow: hidden;
  455. width: max-content;
  456. position: relative;
  457. `;
  458. const AddViewButton = styled(Button)`
  459. display: flex;
  460. color: ${p => p.theme.gray300};
  461. font-weight: normal;
  462. padding: ${space(0.5)} ${space(1)};
  463. margin-bottom: 1px;
  464. border: none;
  465. bottom: -1px;
  466. `;
  467. const StyledIconAdd = styled(IconAdd)`
  468. margin-right: 4px;
  469. `;
  470. const MotionWrapper = styled(motion.div)`
  471. display: flex;
  472. position: relative;
  473. bottom: 1px;
  474. `;
  475. const AddViewMotionWrapper = styled(motion.div)`
  476. display: flex;
  477. position: relative;
  478. margin-top: ${space(0.25)};
  479. `;
  480. const OverflowMenuTrigger = styled(DropdownButton)`
  481. padding: ${space(0.5)} ${space(0.75)};
  482. border: none;
  483. & > span {
  484. height: 26px;
  485. }
  486. `;