123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276 |
- 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<HTMLUListElement>(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<Record<string | number, HTMLLIElement | null>>({});
- const persistentTabs = [...state.collection].filter(
- item => item.key !== 'temporary-tab'
- );
- const tempTab = [...state.collection].find(item => item.key === 'temporary-tab');
- return (
- <TabListOuterWrap
- style={outerWrapStyles}
- hideBorder={hideBorder}
- borderStyle={state.selectedKey === 'temporary-tab' ? 'dashed' : 'solid'}
- >
- <Reorder.Group
- axis="x"
- values={[...state.collection]}
- onReorder={onReorder}
- as="div"
- style={{display: 'grid', gridAutoFlow: 'column', gridAutoColumns: 'min-content'}}
- layoutRoot
- >
- <TabListWrap {...tabListProps} className={className} ref={tabListRef}>
- {persistentTabs.map(item => (
- <Fragment key={item.key}>
- <Reorder.Item
- key={item.key}
- value={item}
- style={{display: 'flex', flexDirection: 'row'}}
- as="div"
- dragConstraints={tabListRef} // Sets the container that the tabs can be dragged within
- dragElastic={0} // Prevents tabs from being dragged outside of the tab bar
- dragTransition={{bounceStiffness: 400, bounceDamping: 40}} // Recovers spring behavior thats lost when using dragElastic
- layout
- >
- <Tab
- key={item.key}
- item={item}
- state={state}
- orientation={orientation}
- overflowing={false}
- ref={element => (tabItemsRef.current[item.key] = element)}
- variant={tabVariant}
- />
- </Reorder.Item>
- <TabDivider
- layout
- isVisible={
- state.selectedKey === 'temporary-tab' ||
- (state.selectedKey !== item.key &&
- state.collection.getKeyAfter(item.key) !== state.selectedKey)
- }
- />
- </Fragment>
- ))}
- </TabListWrap>
- <AddViewTempTabWrap>
- <MotionWrapper layout>
- <AddViewButton borderless size="zero" onClick={onAddView}>
- <StyledIconAdd size="xs" />
- {t('Add View')}
- </AddViewButton>
- </MotionWrapper>
- <MotionWrapper layout>
- {tempTab && (
- <Tab
- key={tempTab.key}
- item={tempTab}
- state={state}
- orientation={orientation}
- overflowing={false}
- ref={element => (tabItemsRef.current[tempTab.key] = element)}
- variant={tabVariant}
- borderStyle="dashed"
- />
- )}
- </MotionWrapper>
- </AddViewTempTabWrap>
- </Reorder.Group>
- </TabListOuterWrap>
- );
- }
- const collectionFactory = (nodes: Iterable<Node<any>>) => new ListCollection(nodes);
- export interface DraggableTabListProps
- extends AriaTabListOptions<DraggableTabListItemProps>,
- TabListStateOptions<DraggableTabListItemProps> {
- onReorder: (newOrder: Node<DraggableTabListItemProps>[]) => 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 <Tabs /> 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 (
- <BaseDraggableTabList
- items={parsedItems}
- onAddView={onAddView}
- disabledKeys={disabledKeys}
- {...props}
- >
- {item => <Item {...item} />}
- </BaseDraggableTabList>
- );
- }
- 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;
- `;
|