Files
2024-02-15 17:01:50 +00:00

237 lines
5.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {forwardRef, useCallback} from 'react';
import type {Theme} from '@emotion/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 InteractionStateLayer from 'sentry/components/interactionStateLayer';
import Link from 'sentry/components/links/link';
import {space} from 'sentry/styles/space';
import {tabsShouldForwardProp} from './utils';
interface TabProps extends AriaTabProps {
item: Node<any>;
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<any>;
}
/**
* Stops event propagation if the command/ctrl/shift key is pressed, in effect
* preventing any state change. This is useful because when a user
* command/ctrl/shift-clicks on a tab link, the intention is to view the tab
* in a new browser tab/window, not to update the current view.
*/
function handleLinkClick(e: React.PointerEvent<HTMLAnchorElement>) {
if (e.metaKey || e.ctrlKey || e.shiftKey) {
e.stopPropagation();
}
}
/**
* Renders a single tab item. This should not be imported directly into any
* page/view – it's only meant to be used by <TabsList />. See the correct
* usage in tabs.stories.js
*/
function BaseTab(
{item, state, orientation, overflowing}: TabProps,
forwardedRef: React.ForwardedRef<HTMLLIElement>
) {
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 ? (
<TabLink
to={to}
onMouseDown={handleLinkClick}
onPointerDown={handleLinkClick}
orientation={orientation}
tabIndex={-1}
>
{children}
</TabLink>
) : (
<TabInnerWrap orientation={orientation}>{children}</TabInnerWrap>
),
[to, orientation]
);
return (
<TabWrap
{...tabProps}
hidden={hidden}
selected={isSelected}
overflowing={overflowing}
ref={ref}
>
<InnerWrap>
<StyledInteractionStateLayer
orientation={orientation}
higherOpacity={isSelected}
/>
<FocusLayer orientation={orientation} />
{rendered}
<TabSelectionIndicator orientation={orientation} selected={isSelected} />
</InnerWrap>
</TabWrap>
);
}
export const Tab = forwardRef(BaseTab);
const TabWrap = styled('li', {shouldForwardProp: tabsShouldForwardProp})<{
overflowing: boolean;
selected: boolean;
}>`
color: ${p => (p.selected ? p.theme.activeText : p.theme.textColor)};
white-space: nowrap;
cursor: pointer;
&:hover {
color: ${p => (p.selected ? p.theme.activeText : p.theme.headingColor)};
}
&:focus {
outline: none;
}
&[aria-disabled],
&[aria-disabled]:hover {
color: ${p => p.theme.subText};
pointer-events: none;
cursor: default;
}
${p =>
p.overflowing &&
`
opacity: 0;
pointer-events: none;
`}
`;
const innerWrapStyles = ({
theme,
orientation,
}: {
orientation: Orientation;
theme: Theme;
}) => `
display: flex;
align-items: center;
position: relative;
height: calc(
${theme.form.sm.height}px +
${orientation === 'horizontal' ? space(0.75) : '0px'}
);
border-radius: ${theme.borderRadius};
transform: translateY(1px);
${
orientation === 'horizontal'
? `
/* Extra padding + negative margin trick, to expand click area */
padding: ${space(0.75)} ${space(1)} ${space(1.5)};
margin-left: -${space(1)};
margin-right: -${space(1)};
`
: `padding: ${space(0.75)} ${space(2)};`
};
`;
const TabLink = styled(Link)<{orientation: Orientation}>`
${innerWrapStyles}
&,
&:hover {
color: inherit;
}
`;
const TabInnerWrap = styled('span')<{orientation: Orientation}>`
${innerWrapStyles}
`;
const StyledInteractionStateLayer = styled(InteractionStateLayer)<{
orientation: Orientation;
}>`
position: absolute;
width: auto;
height: auto;
transform: none;
left: 0;
right: 0;
top: 0;
bottom: ${p => (p.orientation === 'horizontal' ? space(0.75) : 0)};
`;
const FocusLayer = styled('div')<{orientation: Orientation}>`
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: ${p => (p.orientation === 'horizontal' ? space(0.75) : 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;
}>`
position: absolute;
border-radius: 2px;
pointer-events: none;
background: ${p => (p.selected ? p.theme.active : 'transparent')};
transition: background 0.1s ease-out;
li[aria-disabled='true'] & {
background: ${p => (p.selected ? p.theme.subText : 'transparent')};
}
${p =>
p.orientation === 'horizontal'
? `
width: calc(100% - ${space(2)});
height: 3px;
bottom: 0;
left: 50%;
transform: translateX(-50%);
`
: `
width: 3px;
height: 50%;
left: 0;
top: 50%;
transform: translateY(-50%);
`};
`;