diff --git a/static/app/components/interactionStateLayer.tsx b/static/app/components/interactionStateLayer.tsx index cb76d3d44a4a5d..4a41c1c2f7048d 100644 --- a/static/app/components/interactionStateLayer.tsx +++ b/static/app/components/interactionStateLayer.tsx @@ -7,6 +7,11 @@ import {defined} from 'sentry/utils'; interface StateLayerProps extends React.HTMLAttributes { as?: React.ElementType; color?: string; + /** + * Controls if the opacity is increased when the element is in a + * selected or expanded state (aria-selected='true' or aria-expanded='true') + */ + hasSelectedBackground?: boolean; higherOpacity?: boolean; isHovered?: boolean; isPressed?: boolean; @@ -65,11 +70,16 @@ const InteractionStateLayer = styled( ` : // If isPressed is undefined, then fallback to default press selectors css` - *:active > &&, - *[aria-expanded='true'] > &&, - *[aria-selected='true'] > && { + *:active > && { opacity: ${p.higherOpacity ? 0.12 : 0.09}; } + ${p.hasSelectedBackground && + css` + *[aria-expanded='true'] > &&, + *[aria-selected='true'] > && { + opacity: ${p.higherOpacity ? 0.12 : 0.09}; + } + `} `} @@ -79,4 +89,8 @@ const InteractionStateLayer = styled( } `; +InteractionStateLayer.defaultProps = { + hasSelectedBackground: true, +}; + export default InteractionStateLayer; diff --git a/static/app/components/tabs/index.stories.tsx b/static/app/components/tabs/index.stories.tsx index 3d376562b18c84..dbd7f5bf22377d 100644 --- a/static/app/components/tabs/index.stories.tsx +++ b/static/app/components/tabs/index.stories.tsx @@ -187,4 +187,28 @@ export default storyBook(Tabs, story => { )); + + story('Variants', () => ( +
+

+ Use the variant prop to control which tab design to use. The default, "flat", is + used in the above examples, but you can also use "filled" variant, as shown below. + Note that the "filled" variant does not work when the oritentation is vertical +

+ + + + {TABS.map(tab => ( + {tab.label} + ))} + + + {TABS.map(tab => ( + {tab.content} + ))} + + + +
+ )); }); diff --git a/static/app/components/tabs/tab.tsx b/static/app/components/tabs/tab.tsx index 9f5cd4bfdf1c7b..c489c26488b721 100644 --- a/static/app/components/tabs/tab.tsx +++ b/static/app/components/tabs/tab.tsx @@ -5,7 +5,12 @@ 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 type { + DOMAttributes, + FocusableElement, + Node, + Orientation, +} from '@react-types/shared'; import InteractionStateLayer from 'sentry/components/interactionStateLayer'; import Link from 'sentry/components/links/link'; @@ -23,6 +28,7 @@ interface TabProps extends AriaTabProps { */ overflowing: boolean; state: TabListState; + variant?: BaseTabProps['variant']; } /** @@ -37,64 +43,165 @@ function handleLinkClick(e: React.PointerEvent) { } } +export interface BaseTabProps { + children: React.ReactNode; + hidden: boolean; + isSelected: boolean; + orientation: Orientation; + overflowing: boolean; + tabProps: DOMAttributes; + /** + * This controls the border style of the tab. Only active when + * `variant=filled` since other variants do not have a border + */ + borderStyle?: 'solid' | 'dashed'; + to?: string; + variant?: 'flat' | 'filled'; +} + +export const BaseTab = forwardRef( + (props: BaseTabProps, forwardedRef: React.ForwardedRef) => { + const { + to, + orientation, + overflowing, + tabProps, + hidden, + isSelected, + variant = 'flat', + borderStyle = 'solid', + } = props; + + const ref = useObjectRef(forwardedRef); + const InnerWrap = useCallback( + ({children}) => + to ? ( + + {children} + + ) : ( + {children} + ), + [to, orientation] + ); + if (variant === 'filled') { + return ( + + ); + } + + return ( + + ); + } +); + /** * Renders a single tab item. This should not be imported directly into any * page/view – it's only meant to be used by . See the correct * usage in tabs.stories.js */ -function BaseTab( - {item, state, orientation, overflowing}: TabProps, - forwardedRef: React.ForwardedRef -) { - const ref = useObjectRef(forwardedRef); - - const { - key, - rendered, - props: {to, hidden}, - } = item; - const {tabProps, isSelected} = useTab({key, isDisabled: hidden}, state, ref); - - const InnerWrap = useCallback( - ({children}) => - to ? ( - - {children} - - ) : ( - {children} - ), - [to, orientation] - ); +export const Tab = forwardRef( + ( + {item, state, orientation, overflowing, variant}: TabProps, + forwardedRef: React.ForwardedRef + ) => { + const ref = useObjectRef(forwardedRef); - return ( - - ); -} + + ); + } +); + +const FilledTabWrap = styled('li', {shouldForwardProp: tabsShouldForwardProp})<{ + borderStyle: 'dashed' | 'solid'; + overflowing: boolean; +}>` + &[aria-selected='true'] { + ${p => + ` + border-top: 1px ${p.borderStyle} ${p.theme.border}; + border-left: 1px ${p.borderStyle} ${p.theme.border}; + border-right: 1px ${p.borderStyle} ${p.theme.border}; + background-color: ${p.theme.background}; + font-weight: ${p.theme.fontWeightBold}; + `} + } + + border-radius: 6px 6px 1px 1px; + + &[aria-selected='false'] { + border-top: 1px solid transparent; + } + + padding: ${space(0.5)} ${space(1)}; + + transform: translateY(1px); + + cursor: pointer; + + &:focus { + outline: none; + } -export const Tab = forwardRef(BaseTab); + ${p => + p.overflowing && + ` + opacity: 0; + pointer-events: none; + `} +`; const TabWrap = styled('li', {shouldForwardProp: tabsShouldForwardProp})<{ overflowing: boolean; @@ -182,6 +289,17 @@ const StyledInteractionStateLayer = styled(InteractionStateLayer)<{ bottom: ${p => (p.orientation === 'horizontal' ? space(0.75) : 0)}; `; +const FilledStyledInteractionStateLayer = styled(InteractionStateLayer)` + position: absolute; + width: auto; + height: auto; + transform: none; + left: 0; + right: 0; + top: 0; + bottom: 0; +`; + const FocusLayer = styled('div')<{orientation: Orientation}>` position: absolute; left: 0; @@ -201,6 +319,25 @@ const FocusLayer = styled('div')<{orientation: Orientation}>` } `; +const FilledFocusLayer = styled('div')` + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + + pointer-events: none; + border-radius: inherit; + z-index: 0; + transition: box-shadow 0.1s ease-out; + + li:focus-visible & { + box-shadow: + ${p => p.theme.focusBorder} 0 0 0 1px, + inset ${p => p.theme.focusBorder} 0 0 0 1px; + } +`; + const TabSelectionIndicator = styled('div')<{ orientation: Orientation; selected: boolean; diff --git a/static/app/components/tabs/tabList.tsx b/static/app/components/tabs/tabList.tsx index 253a32ccf53577..a22455c7a99fe5 100644 --- a/static/app/components/tabs/tabList.tsx +++ b/static/app/components/tabs/tabList.tsx @@ -19,7 +19,7 @@ import {browserHistory} from 'sentry/utils/browserHistory'; import {TabsContext} from './index'; import type {TabListItemProps} from './item'; import {Item} from './item'; -import {Tab} from './tab'; +import {type BaseTabProps, Tab} from './tab'; import {tabsShouldForwardProp} from './utils'; /** @@ -85,22 +85,51 @@ function useOverflowTabs({ return overflowTabs.filter(tabKey => !tabItemKeyToHiddenMap[tabKey]); } +export function OverflowMenu({state, overflowMenuItems, disabled}) { + return ( + + state.setSelectedKey(opt.value)} + disabled={disabled} + position="bottom-end" + size="sm" + offset={4} + trigger={triggerProps => ( + } + aria-label={t('More tabs')} + /> + )} + /> + + ); +} + export interface TabListProps extends AriaTabListOptions, TabListStateOptions { className?: string; hideBorder?: boolean; outerWrapStyles?: React.CSSProperties; + variant?: BaseTabProps['variant']; } interface BaseTabListProps extends TabListProps { items: TabListItemProps[]; + variant?: BaseTabProps['variant']; } function BaseTabList({ hideBorder = false, className, outerWrapStyles, + variant = 'flat', ...props }: BaseTabListProps) { const tabListRef = useRef(null); @@ -181,6 +210,7 @@ function BaseTabList({ hideBorder={hideBorder} className={className} ref={tabListRef} + variant={variant} > {[...state.collection].map(item => ( (tabItemsRef.current[item.key] = element)} + variant={variant} /> ))} {orientation === 'horizontal' && overflowMenuItems.length > 0 && ( - - state.setSelectedKey(opt.value)} - disabled={disabled} - position="bottom-end" - size="sm" - offset={4} - trigger={triggerProps => ( - } - aria-label={t('More tabs')} - /> - )} - /> - + )} ); @@ -227,7 +242,7 @@ const collectionFactory = (nodes: Iterable>) => new ListCollection(nod * To be used as a direct child of the component. See example usage * in tabs.stories.js */ -export function TabList({items, ...props}: TabListProps) { +export function TabList({items, variant, ...props}: TabListProps) { /** * Initial, unfiltered list of tab items. */ @@ -248,7 +263,12 @@ export function TabList({items, ...props}: TabListProps) { ); return ( - + {item => } ); @@ -263,6 +283,7 @@ const TabListOuterWrap = styled('div')` const TabListWrap = styled('ul', {shouldForwardProp: tabsShouldForwardProp})<{ hideBorder: boolean; orientation: Orientation; + variant: BaseTabProps['variant']; }>` position: relative; display: grid; @@ -276,7 +297,7 @@ const TabListWrap = styled('ul', {shouldForwardProp: tabsShouldForwardProp})<{ ? ` grid-auto-flow: column; justify-content: start; - gap: ${space(2)}; + gap: ${p.variant === 'filled' ? space(0) : space(2)}; ${!p.hideBorder && `border-bottom: solid 1px ${p.theme.border};`} ` : `