diff --git a/static/app/components/draggableTabs/draggableTab.tsx b/static/app/components/draggableTabs/draggableTab.tsx new file mode 100644 index 00000000000000..dfce9e947cf5b5 --- /dev/null +++ b/static/app/components/draggableTabs/draggableTab.tsx @@ -0,0 +1,68 @@ +import type React from 'react'; +import {forwardRef} from 'react'; +import styled from '@emotion/styled'; +import type {AriaTabProps} from '@react-aria/tabs'; +import {useTab} from '@react-aria/tabs'; +import {useObjectRef} from '@react-aria/utils'; +import type {TabListState} from '@react-stately/tabs'; +import type {Node, Orientation} from '@react-types/shared'; + +import {BaseTab} from 'sentry/components/tabs/tab'; + +interface DraggableTabProps extends AriaTabProps { + item: Node; + orientation: Orientation; + /** + * Whether this tab is overflowing the TabList container. If so, the tab + * needs to be visually hidden. Users can instead select it via an overflow + * menu. + */ + overflowing: boolean; + state: TabListState; +} + +export const DraggableTab = forwardRef( + ( + {item, state, orientation, overflowing}: DraggableTabProps, + forwardedRef: React.ForwardedRef + ) => { + const ref = useObjectRef(forwardedRef); + + const { + key, + rendered, + props: {to, hidden}, + } = item; + const {tabProps, isSelected} = useTab({key, isDisabled: hidden}, state, ref); + + return ( + + ); + } +); + +const StyledBaseTab = styled(BaseTab)` + padding: 2px 12px 2px 12px; + gap: 8px; + border-radius: 6px 6px 0px 0px; + border: 1px solid ${p => p.theme.gray200}; + opacity: 0px; +`; + +const TabContentWrap = styled('span')` + display: flex; + align-items: center; + flex-direction: row; + gap: 6px; +`; diff --git a/static/app/components/draggableTabs/draggableTabList.tsx b/static/app/components/draggableTabs/draggableTabList.tsx new file mode 100644 index 00000000000000..a48ceeeb7f5fc2 --- /dev/null +++ b/static/app/components/draggableTabs/draggableTabList.tsx @@ -0,0 +1,251 @@ +import {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, Orientation} from '@react-types/shared'; +import {Reorder} from 'framer-motion'; + +import type {SelectOption} from 'sentry/components/compactSelect'; +import {TabsContext} from 'sentry/components/tabs'; +import {OverflowMenu, useOverflowTabs} from 'sentry/components/tabs/tabList'; +import {tabsShouldForwardProp} from 'sentry/components/tabs/utils'; +import {space} from 'sentry/styles/space'; +import {browserHistory} from 'sentry/utils/browserHistory'; +import type {Tab} from 'sentry/views/issueList/draggableTabBar'; + +import {DraggableTab} from './draggableTab'; +import type {DraggableTabListItemProps} from './item'; +import {Item} from './item'; + +interface BaseDraggableTabListProps extends DraggableTabListProps { + items: DraggableTabListItemProps[]; + setTabs: (tabs: Tab[]) => void; + tabs: Tab[]; +} + +function BaseDraggableTabList({ + hideBorder = false, + className, + outerWrapStyles, + tabs, + setTabs, + ...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.disabledKeys, state.selectedItem, state.selectedKey, props.children]); + + // Detect tabs that overflow from the wrapper and put them in an overflow menu + const tabItemsRef = useRef>({}); + const overflowTabs = useOverflowTabs({ + tabListRef, + tabItemsRef, + tabItems: props.items, + }); + + const overflowMenuItems = useMemo(() => { + // Sort overflow items in the order that they appear in TabList + const sortedKeys = [...state.collection].map(item => item.key); + const sortedOverflowTabs = overflowTabs.sort( + (a, b) => sortedKeys.indexOf(a) - sortedKeys.indexOf(b) + ); + + return sortedOverflowTabs.flatMap>(key => { + const item = state.collection.getItem(key); + + if (!item) { + return []; + } + + return { + value: key, + label: item.props.children, + disabled: item.props.disabled, + textValue: item.textValue, + }; + }); + }, [state.collection, overflowTabs]); + + return ( + + + + {[...state.collection].map(item => ( + tab.key === item.key)} + style={{display: 'flex', flexDirection: 'row'}} + > + (tabItemsRef.current[item.key] = element)} + /> + {state.selectedKey !== item.key && + state.collection.getKeyAfter(item.key) !== state.selectedKey && ( + + )} + + ))} + + + + {orientation === 'horizontal' && overflowMenuItems.length > 0 && ( + + )} + + ); +} + +const collectionFactory = (nodes: Iterable>) => new ListCollection(nodes); + +export interface DraggableTabListProps + extends AriaTabListOptions, + TabListStateOptions { + setTabs: (tabs: Tab[]) => void; + tabs: Tab[]; + className?: string; + hideBorder?: boolean; + outerWrapStyles?: React.CSSProperties; +} + +/** + * To be used as a direct child of the component. See example usage + * in tabs.stories.js + */ +export function DraggableTabList({ + items, + tabs, + setTabs, + ...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; + +const TabDivider = styled('div')` + height: 50%; + width: 1px; + border-radius: 6px; + background-color: ${p => p.theme.gray200}; + margin: 9px auto; +`; + +const TabListOuterWrap = styled('div')` + position: relative; +`; + +const TabListWrap = styled('ul', { + shouldForwardProp: tabsShouldForwardProp, +})<{ + hideBorder: boolean; + orientation: Orientation; +}>` + position: relative; + display: grid; + padding: 0; + margin: 0; + list-style-type: none; + flex-shrink: 0; + padding-left: 15px; + + ${p => + p.orientation === 'horizontal' + ? ` + grid-auto-flow: column; + justify-content: start; + gap: ${space(0.5)}; + ${!p.hideBorder && `border-bottom: solid 1px ${p.theme.border};`} + stroke-dasharray: 4, 3; + ` + : ` + height: 100%; + grid-auto-flow: row; + align-content: start; + gap: 1px; + padding-right: ${space(2)}; + ${!p.hideBorder && `border-right: solid 1px ${p.theme.border};`} + `}; +`; diff --git a/static/app/components/draggableTabs/index.stories.tsx b/static/app/components/draggableTabs/index.stories.tsx new file mode 100644 index 00000000000000..f8bbad7f752bfc --- /dev/null +++ b/static/app/components/draggableTabs/index.stories.tsx @@ -0,0 +1,64 @@ +import {Fragment} from 'react'; +import styled from '@emotion/styled'; + +import JSXNode from 'sentry/components/stories/jsxNode'; +import SizingWindow from 'sentry/components/stories/sizingWindow'; +import storyBook from 'sentry/stories/storyBook'; +import {DraggableTabBar} from 'sentry/views/issueList/draggableTabBar'; + +const TabPanelContainer = styled('div')` + width: 90%; + height: 250px; + background-color: white; +`; + +export default storyBook(DraggableTabBar, story => { + const TABS = [ + { + key: 'one', + label: 'Inbox', + content: This is the Inbox view, + }, + { + key: 'two', + label: 'For Review', + content: This is the For Review view, + }, + { + key: 'three', + label: 'Regressed', + content: This is the Regressed view, + }, + ]; + + story('Default', () => ( + +

+ You should be using all of , ,{' '} + , and + components. +

+

+ This will give you all kinds of accessibility and state tracking out of the box. + But you will have to render all tab content, including hooks, upfront. +

+ + + This is a temporary tab + } + /> + + +
+ )); +}); + +const TabBarContainer = styled('div')` + display: flex; + justify-content: start; + width: 90%; + height: 300px; +`; diff --git a/static/app/components/draggableTabs/item.tsx b/static/app/components/draggableTabs/item.tsx new file mode 100644 index 00000000000000..00cd6e94e91c38 --- /dev/null +++ b/static/app/components/draggableTabs/item.tsx @@ -0,0 +1,12 @@ +import {Item as _Item} from '@react-stately/collections'; +import type {ItemProps} from '@react-types/shared'; +import type {LocationDescriptor} from 'history'; + +export interface DraggableTabListItemProps extends ItemProps { + key: string | number; + disabled?: boolean; + hidden?: boolean; + to?: LocationDescriptor; +} + +export const Item = _Item as (props: DraggableTabListItemProps) => JSX.Element; diff --git a/static/app/components/tabs/tab.tsx b/static/app/components/tabs/tab.tsx index c489c26488b721..8ab42271d090b4 100644 --- a/static/app/components/tabs/tab.tsx +++ b/static/app/components/tabs/tab.tsx @@ -185,7 +185,7 @@ const FilledTabWrap = styled('li', {shouldForwardProp: tabsShouldForwardProp})<{ border-top: 1px solid transparent; } - padding: ${space(0.5)} ${space(1)}; + padding: ${space(0.75)} ${space(1.5)}; transform: translateY(1px); diff --git a/static/app/components/tabs/tabList.tsx b/static/app/components/tabs/tabList.tsx index a22455c7a99fe5..ea5432ccc42306 100644 --- a/static/app/components/tabs/tabList.tsx +++ b/static/app/components/tabs/tabList.tsx @@ -26,7 +26,7 @@ import {tabsShouldForwardProp} from './utils'; * Uses IntersectionObserver API to detect overflowing tabs. Returns an array * containing of keys of overflowing tabs. */ -function useOverflowTabs({ +export function useOverflowTabs({ tabListRef, tabItemsRef, tabItems, diff --git a/static/app/views/issueList/draggableTabBar.tsx b/static/app/views/issueList/draggableTabBar.tsx new file mode 100644 index 00000000000000..67b772a1e6ea56 --- /dev/null +++ b/static/app/views/issueList/draggableTabBar.tsx @@ -0,0 +1,38 @@ +import 'intersection-observer'; // polyfill + +import {useState} from 'react'; +import type {Key} from '@react-types/shared'; + +import {DraggableTabList} from 'sentry/components/draggableTabs/draggableTabList'; +import {TabPanels, Tabs} from 'sentry/components/tabs'; + +export interface Tab { + content: React.ReactNode; + key: Key; + label: string; + queryCount?: number; +} + +export interface DraggableTabBarProps { + tabs: Tab[]; + tempTabContent: React.ReactNode; +} + +export function DraggableTabBar(props: DraggableTabBarProps) { + const [tabs, setTabs] = useState([...props.tabs]); + + return ( + + + {tabs.map(tab => ( + {tab.label} + ))} + + + {tabs.map(tab => ( + {tab.content} + ))} + + + ); +}