import {Fragment, useContext, useEffect, useMemo, useRef} from 'react'; import styled from '@emotion/styled'; import type {AriaTabListOptions} from '@react-aria/tabs'; import {useTabList} from '@react-aria/tabs'; import {useCollection} from '@react-stately/collections'; import {ListCollection} from '@react-stately/list'; import type {TabListStateOptions} from '@react-stately/tabs'; import {useTabListState} from '@react-stately/tabs'; import type {Node} from '@react-types/shared'; import {motion, Reorder} from 'framer-motion'; import {Button} from 'sentry/components/button'; import {TabsContext} from 'sentry/components/tabs'; import {type BaseTabProps, Tab} from 'sentry/components/tabs/tab'; import {IconAdd} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {browserHistory} from 'sentry/utils/browserHistory'; import type {DraggableTabListItemProps} from './item'; import {Item} from './item'; interface BaseDraggableTabListProps extends DraggableTabListProps { items: DraggableTabListItemProps[]; } function BaseDraggableTabList({ hideBorder = false, className, outerWrapStyles, onReorder, onAddView, tabVariant = 'filled', ...props }: BaseDraggableTabListProps) { const tabListRef = useRef(null); const {rootProps, setTabListState} = useContext(TabsContext); const { value, defaultValue, onChange, disabled, orientation = 'horizontal', keyboardActivation = 'manual', ...otherRootProps } = rootProps; // Load up list state const ariaProps = { selectedKey: value, defaultSelectedKey: defaultValue, onSelectionChange: key => { onChange?.(key); // If the newly selected tab is a tab link, then navigate to the specified link const linkTo = [...(props.items ?? [])].find(item => item.key === key)?.to; if (!linkTo) { return; } browserHistory.push(linkTo); }, isDisabled: disabled, keyboardActivation, ...otherRootProps, ...props, }; const state = useTabListState(ariaProps); const {tabListProps} = useTabList({orientation, ...ariaProps}, state, tabListRef); useEffect(() => { setTabListState(state); // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.selectedKey]); // Detect tabs that overflow from the wrapper and put them in an overflow menu const tabItemsRef = useRef>({}); const persistentTabs = [...state.collection].filter( item => item.key !== 'temporary-tab' ); const tempTab = [...state.collection].find(item => item.key === 'temporary-tab'); return ( {persistentTabs.map(item => ( (tabItemsRef.current[item.key] = element)} variant={tabVariant} /> ))} {t('Add View')} {tempTab && ( (tabItemsRef.current[tempTab.key] = element)} variant={tabVariant} borderStyle="dashed" /> )} ); } const collectionFactory = (nodes: Iterable>) => new ListCollection(nodes); export interface DraggableTabListProps extends AriaTabListOptions, TabListStateOptions { onReorder: (newOrder: Node[]) => void; className?: string; hideBorder?: boolean; onAddView?: React.MouseEventHandler; outerWrapStyles?: React.CSSProperties; showTempTab?: boolean; tabVariant?: BaseTabProps['variant']; } /** * To be used as a direct child of the component. See example usage * in tabs.stories.js */ export function DraggableTabList({items, onAddView, ...props}: DraggableTabListProps) { const collection = useCollection({items, ...props}, collectionFactory); const parsedItems = useMemo( () => [...collection].map(({key, props: itemProps}) => ({key, ...itemProps})), [collection] ); /** * List of keys of disabled items (those with a `disbled` prop) to be passed * into `BaseTabList`. */ const disabledKeys = useMemo( () => parsedItems.filter(item => item.disabled).map(item => item.key), [parsedItems] ); return ( {item => } ); } DraggableTabList.Item = Item; /** * TabDividers are only visible around NON-selected tabs. They are not visible around the selected tab, * but they still create some space and act as a gap between tabs. */ const TabDivider = styled(motion.div, { shouldForwardProp: prop => prop !== 'isVisible', })<{isVisible: boolean}>` ${p => p.isVisible && ` background-color: ${p.theme.gray200}; height: 50%; width: 1px; border-radius: 6px; margin-right: ${space(0.5)}; `} margin-top: 1px; margin-left: ${space(0.5)}; `; const TabListOuterWrap = styled('div')<{ borderStyle: 'dashed' | 'solid'; hideBorder: boolean; }>` position: relative; ${p => !p.hideBorder && `border-bottom: solid 1px ${p.theme.border};`} `; const AddViewTempTabWrap = styled('div')` position: relative; display: grid; padding: 0; margin: 0; list-style-type: none; flex-shrink: 0; grid-auto-flow: column; justify-content: start; align-items: center; `; const TabListWrap = styled('ul')` position: relative; display: grid; justify-content: start; grid-auto-flow: column; padding: 0; margin: 0; list-style-type: none; flex-shrink: 0; align-items: center; `; const AddViewButton = styled(Button)` display: flex; color: ${p => p.theme.gray300}; font-weight: normal; padding: ${space(0.5)}; transform: translateY(1px); margin-right: ${space(0.5)}; `; const StyledIconAdd = styled(IconAdd)` margin-right: 4px; `; const MotionWrapper = styled(motion.div)` display: flex; position: relative; `;