Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(<DraggableTabs>): Add new draggable tabs component to storybook #73239

Merged
merged 17 commits into from
Jun 28, 2024
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
311 changes: 107 additions & 204 deletions static/app/components/draggableTabs/draggableTab.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import {forwardRef, useCallback} from 'react';
import type {Theme} from '@emotion/react';
import type React from 'react';
import {forwardRef, Fragment, useRef} from 'react';
import styled from '@emotion/styled';
import {useButton} from '@react-aria/button';
import {
type DropIndicatorProps,
useDrag,
useDropIndicator,
useDroppableItem,
} from '@react-aria/dnd';
import type {AriaTabProps} from '@react-aria/tabs';
import {useTab} from '@react-aria/tabs';
import {useObjectRef} from '@react-aria/utils';
import {mergeProps, useObjectRef} from '@react-aria/utils';
import type {DroppableCollectionState} from '@react-stately/dnd';
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 {BaseTab} from 'sentry/components/tabs/tab';

import {tabsShouldForwardProp} from './utils';

interface TabProps extends AriaTabProps {
interface DraggableTabProps extends AriaTabProps {
dropState: DroppableCollectionState;
item: Node<any>;
orientation: Orientation;
/**
Expand All @@ -25,212 +30,110 @@ interface TabProps extends AriaTabProps {
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();
interface BaseDropIndicatorProps {
dropState: DroppableCollectionState;
target: DropIndicatorProps['target'];
}

function TabDropIndicator(props: BaseDropIndicatorProps) {
const ref = useRef(null);
const {dropIndicatorProps, isHidden} = useDropIndicator(props, props.dropState, ref);
if (isHidden) {
return null;
}

return <TabSeparator {...dropIndicatorProps} role="option" ref={ref} />;
}

interface DraggableProps {
children: React.ReactNode;
item: Node<any>;
onTabClick: () => void;
}

function Draggable({item, children, onTabClick}: DraggableProps) {
// TODO(msun): Implement the "preview" parameter in this useDrag hook
const {dragProps, dragButtonProps} = useDrag({
getAllowedDropOperations: () => ['move'],
getItems() {
return [
{
tab: JSON.stringify({key: item.key, value: children}),
},
];
},
});

const ref = useRef(null);
const {buttonProps} = useButton({...dragButtonProps, elementType: 'div'}, ref);

return (
<div {...mergeProps(buttonProps, dragProps)} ref={ref} onClick={onTabClick}>
{children}
</div>
);
}

/**
* 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
export const DraggableTab = forwardRef(
(
{item, state, orientation, overflowing, dropState}: DraggableTabProps,
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 {dropProps} = useDroppableItem(
{
target: {type: 'item', key: item.key, dropPosition: 'on'},
},
dropState,
ref
);

return (
<Fragment>
<TabDropIndicator
target={{type: 'item', key: item.key, dropPosition: 'before'}}
dropState={dropState}
/>
<BaseTab
additionalProps={dropProps}
tabProps={tabProps}
isSelected={isSelected}
to={to}
onMouseDown={handleLinkClick}
onPointerDown={handleLinkClick}
hidden={hidden}
orientation={orientation}
tabIndex={-1}
overflowing={overflowing}
ref={ref}
>
{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;
<Draggable onTabClick={() => state.setSelectedKey(item.key)} item={item}>
{rendered}
</Draggable>
</BaseTab>
{state.collection.getKeyAfter(item.key) == null && (
<TabDropIndicator
target={{type: 'item', key: item.key, dropPosition: 'after'}}
dropState={dropState}
/>
)}
</Fragment>
);
}
);

&[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%);
`};
const TabSeparator = styled('li')`
height: 80%;
width: 2px;
background-color: ${p => p.theme.gray200};
`;
Loading
Loading