Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions static/app/components/interactionStateLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import {defined} from 'sentry/utils';
interface StateLayerProps extends React.HTMLAttributes<HTMLSpanElement> {
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;
Expand Down Expand Up @@ -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};
}
`}
`}


Expand All @@ -79,4 +89,8 @@ const InteractionStateLayer = styled(
}
`;

InteractionStateLayer.defaultProps = {
hasSelectedBackground: true,
};
Comment on lines +92 to +94
Copy link
Contributor Author

@MichaelSun48 MichaelSun48 Jul 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is how default props have to be defined apparently 🫠 (setting hasSelectedBackground=true in the destructed props does not work)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see, because it's a styled component I guess


export default InteractionStateLayer;
24 changes: 24 additions & 0 deletions static/app/components/tabs/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,28 @@ export default storyBook(Tabs, story => {
</div>
</SideBySide>
));

story('Variants', () => (
<div>
<p>
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
</p>
<SizingWindow>
<Tabs>
<TabList variant={'filled'}>
{TABS.map(tab => (
<TabList.Item key={tab.key}>{tab.label}</TabList.Item>
))}
</TabList>
<TabPanels>
{TABS.map(tab => (
<TabPanels.Item key={tab.key}>{tab.content}</TabPanels.Item>
))}
</TabPanels>
</Tabs>
</SizingWindow>
</div>
));
});
239 changes: 188 additions & 51 deletions static/app/components/tabs/tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,6 +28,7 @@ interface TabProps extends AriaTabProps {
*/
overflowing: boolean;
state: TabListState<any>;
variant?: BaseTabProps['variant'];
}

/**
Expand All @@ -37,64 +43,165 @@ function handleLinkClick(e: React.PointerEvent<HTMLAnchorElement>) {
}
}

export interface BaseTabProps {
children: React.ReactNode;
hidden: boolean;
isSelected: boolean;
orientation: Orientation;
overflowing: boolean;
tabProps: DOMAttributes<FocusableElement>;
/**
* 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<HTMLLIElement>) => {
const {
to,
orientation,
overflowing,
tabProps,
hidden,
isSelected,
variant = 'flat',
borderStyle = 'solid',
} = props;

const ref = useObjectRef(forwardedRef);
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]
);
if (variant === 'filled') {
return (
<FilledTabWrap
{...tabProps}
hidden={hidden}
overflowing={overflowing}
borderStyle={borderStyle}
ref={ref}
>
<FilledStyledInteractionStateLayer hasSelectedBackground={false} />
<FilledFocusLayer />
{props.children}
</FilledTabWrap>
);
}

return (
<TabWrap
{...tabProps}
hidden={hidden}
selected={isSelected}
overflowing={overflowing}
ref={ref}
>
<InnerWrap>
<StyledInteractionStateLayer
orientation={orientation}
higherOpacity={isSelected}
/>
<FocusLayer orientation={orientation} />
{props.children}
<TabSelectionIndicator orientation={orientation} selected={isSelected} />
</InnerWrap>
</TabWrap>
);
}
);

/**
* 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]
);
export const Tab = forwardRef(
(
{item, state, orientation, overflowing, variant}: TabProps,
forwardedRef: React.ForwardedRef<HTMLLIElement>
) => {
const ref = useObjectRef(forwardedRef);

return (
<TabWrap
{...tabProps}
hidden={hidden}
selected={isSelected}
overflowing={overflowing}
ref={ref}
>
<InnerWrap>
<StyledInteractionStateLayer
orientation={orientation}
higherOpacity={isSelected}
/>
<FocusLayer orientation={orientation} />
const {
key,
rendered,
props: {to, hidden},
} = item;
const {tabProps, isSelected} = useTab({key, isDisabled: hidden}, state, ref);

return (
<BaseTab
tabProps={tabProps}
isSelected={isSelected}
to={to}
hidden={hidden}
orientation={orientation}
overflowing={overflowing}
ref={ref}
variant={variant}
>
{rendered}
<TabSelectionIndicator orientation={orientation} selected={isSelected} />
</InnerWrap>
</TabWrap>
);
}
</BaseTab>
);
}
);

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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading