Chore: Improve dropdown draggable list (#1738)

* draggable menu item component

* Menu item isDragged prop removed

* Droppable list component

* Draggablee item component

* Drag and drop use refactor

* lint fix

* isDragDisabled check on DraggableItem

* revert changes on non visibility items

* MenuItemDraggable stroybook

* DraggableItem storybook

* lint fix

* lint fix

* BoardColumnMenu css fix

* showGrip prop addition

* isDragged css fix
This commit is contained in:
Aditya Pimpalkar
2023-10-04 14:56:25 +01:00
committed by GitHub
parent 1e402aca5f
commit 93a01c7292
11 changed files with 368 additions and 91 deletions

View File

@ -24,7 +24,6 @@ import { BoardColumnHotkeyScope } from '../types/BoardColumnHotkeyScope';
import { BoardColumnEditTitleMenu } from './BoardColumnEditTitleMenu'; import { BoardColumnEditTitleMenu } from './BoardColumnEditTitleMenu';
const StyledMenuContainer = styled.div` const StyledMenuContainer = styled.div`
left: 26.5px;
position: absolute; position: absolute;
top: ${({ theme }) => theme.spacing(10)}; top: ${({ theme }) => theme.spacing(10)};
width: 200px; width: 200px;

View File

@ -0,0 +1,53 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconBell } from '@/ui/icon';
import { MenuItemDraggable } from '@/ui/menu-item/components/MenuItemDraggable';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { DraggableItem } from '../components/DraggableItem';
import { DroppableList } from '../components/DroppableList';
const meta: Meta<typeof DraggableItem> = {
title: 'ui/draggable-list/DraggableItem',
component: DraggableItem,
decorators: [
(Story, { parameters }) => (
<DroppableList
droppableId={parameters.droppableId}
onDragEnd={parameters.onDragEnd}
draggableItems={<Story />}
/>
),
ComponentDecorator,
],
parameters: {
droppableId: 'droppable',
onDragEnd: () => console.log('dragged'),
},
args: {
draggableId: 'draggable-1',
key: 'key-1',
index: 0,
isDragDisabled: false,
itemComponent: (
<>
<MenuItemDraggable
LeftIcon={IconBell}
key="key-1"
text="Draggable item 1"
/>
<MenuItemDraggable
LeftIcon={IconBell}
key="key-2"
text="Draggable item 2"
/>
</>
),
},
};
export default meta;
type Story = StoryObj<typeof DraggableItem>;
export const Default: Story = {};

View File

@ -0,0 +1,57 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import { Draggable } from '@hello-pangea/dnd';
type DraggableItemProps = {
key: string;
draggableId: string;
isDragDisabled?: boolean;
index: number;
itemComponent: JSX.Element;
};
export const DraggableItem = ({
key,
draggableId,
isDragDisabled = false,
index,
itemComponent,
}: DraggableItemProps) => {
const theme = useTheme();
return (
<Draggable
key={key}
draggableId={draggableId}
index={index}
isDragDisabled={isDragDisabled}
>
{(draggableProvided, draggableSnapshot) => {
const draggableStyle = draggableProvided.draggableProps.style;
const isDragged = draggableSnapshot.isDragging;
return (
<div
ref={draggableProvided.innerRef}
{...{
...draggableProvided.draggableProps,
style: {
...draggableStyle,
left: 'auto',
top: 'auto',
transform: draggableStyle?.transform?.replace(
/\(-?\d+px,/,
'(0,',
),
background: isDragged
? theme.background.transparent.light
: 'none',
},
}}
{...draggableProvided.dragHandleProps}
>
{itemComponent}
</div>
);
}}
</Draggable>
);
};

View File

@ -0,0 +1,37 @@
import styled from '@emotion/styled';
import {
DragDropContext,
Droppable,
OnDragEndResponder,
} from '@hello-pangea/dnd';
type DroppableListProps = {
droppableId: string;
draggableItems: React.ReactNode;
onDragEnd: OnDragEndResponder;
};
const StyledDragDropItemsWrapper = styled.div`
width: 100%;
`;
export const DroppableList = ({
droppableId,
draggableItems,
onDragEnd,
}: DroppableListProps) => {
return (
<DragDropContext onDragEnd={onDragEnd}>
<StyledDragDropItemsWrapper>
<Droppable droppableId={droppableId}>
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
{draggableItems}
{provided.placeholder}
</div>
)}
</Droppable>
</StyledDragDropItemsWrapper>
</DragDropContext>
);
};

View File

@ -1,11 +1,13 @@
import { MouseEvent } from 'react'; import { MouseEvent } from 'react';
import styled from '@emotion/styled';
import { FloatingIconButtonGroup } from '@/ui/button/components/FloatingIconButtonGroup'; import { FloatingIconButtonGroup } from '@/ui/button/components/FloatingIconButtonGroup';
import { IconComponent } from '@/ui/icon/types/IconComponent'; import { IconComponent } from '@/ui/icon/types/IconComponent';
import { MenuItemLeftContent } from '../internals/components/MenuItemLeftContent'; import { MenuItemLeftContent } from '../internals/components/MenuItemLeftContent';
import { StyledMenuItemBase } from '../internals/components/StyledMenuItemBase'; import {
StyledHoverableMenuItemBase,
StyledMenuItemLeftContent,
} from '../internals/components/StyledMenuItemBase';
import { MenuItemAccent } from '../types/MenuItemAccent'; import { MenuItemAccent } from '../types/MenuItemAccent';
export type MenuItemIconButton = { export type MenuItemIconButton = {
@ -14,7 +16,6 @@ export type MenuItemIconButton = {
}; };
export type MenuItemProps = { export type MenuItemProps = {
isDraggable?: boolean;
LeftIcon?: IconComponent | null; LeftIcon?: IconComponent | null;
accent?: MenuItemAccent; accent?: MenuItemAccent;
text: string; text: string;
@ -24,25 +25,7 @@ export type MenuItemProps = {
onClick?: () => void; onClick?: () => void;
}; };
const StyledHoverableMenuItemBase = styled(StyledMenuItemBase)`
& .hoverable-buttons {
opacity: 0;
pointer-events: none;
position: fixed;
right: ${({ theme }) => theme.spacing(2)};
transition: opacity ${({ theme }) => theme.animation.duration.instant}s ease;
}
&:hover {
& .hoverable-buttons {
opacity: 1;
pointer-events: auto;
}
}
`;
export const MenuItem = ({ export const MenuItem = ({
isDraggable,
LeftIcon, LeftIcon,
accent = 'default', accent = 'default',
text, text,
@ -60,11 +43,9 @@ export const MenuItem = ({
className={className} className={className}
accent={accent} accent={accent}
> >
<MenuItemLeftContent <StyledMenuItemLeftContent>
isDraggable={isDraggable ? true : false} <MenuItemLeftContent LeftIcon={LeftIcon ?? undefined} text={text} />
LeftIcon={LeftIcon ?? undefined} </StyledMenuItemLeftContent>
text={text}
/>
<div className="hoverable-buttons"> <div className="hoverable-buttons">
{showIconButtons && ( {showIconButtons && (
<FloatingIconButtonGroup iconButtons={iconButtons} /> <FloatingIconButtonGroup iconButtons={iconButtons} />

View File

@ -0,0 +1,52 @@
import { FloatingIconButtonGroup } from '@/ui/button/components/FloatingIconButtonGroup';
import { IconComponent } from '@/ui/icon/types/IconComponent';
import { MenuItemLeftContent } from '../internals/components/MenuItemLeftContent';
import { StyledHoverableMenuItemBase } from '../internals/components/StyledMenuItemBase';
import { MenuItemAccent } from '../types/MenuItemAccent';
import { MenuItemIconButton } from './MenuItem';
export type MenuItemDraggableProps = {
key: string;
LeftIcon: IconComponent | undefined;
accent?: MenuItemAccent;
iconButtons?: MenuItemIconButton[];
onClick?: () => void;
text: string;
isDragDisabled?: boolean;
className?: string;
};
export const MenuItemDraggable = ({
key,
LeftIcon,
accent = 'default',
iconButtons,
onClick,
text,
isDragDisabled = false,
className,
}: MenuItemDraggableProps) => {
const showIconButtons = Array.isArray(iconButtons) && iconButtons.length > 0;
return (
<StyledHoverableMenuItemBase
data-testid={key ?? undefined}
onClick={onClick}
accent={accent}
className={className}
>
<MenuItemLeftContent
LeftIcon={LeftIcon}
text={text}
key={key}
showGrip={!isDragDisabled}
/>
<div className="hoverable-buttons">
{showIconButtons && (
<FloatingIconButtonGroup iconButtons={iconButtons} />
)}
</div>
</StyledHoverableMenuItemBase>
);
};

View File

@ -4,7 +4,10 @@ import { IconChevronRight } from '@/ui/icon';
import { IconComponent } from '@/ui/icon/types/IconComponent'; import { IconComponent } from '@/ui/icon/types/IconComponent';
import { MenuItemLeftContent } from '../internals/components/MenuItemLeftContent'; import { MenuItemLeftContent } from '../internals/components/MenuItemLeftContent';
import { StyledMenuItemBase } from '../internals/components/StyledMenuItemBase'; import {
StyledMenuItemBase,
StyledMenuItemLeftContent,
} from '../internals/components/StyledMenuItemBase';
export type MenuItemProps = { export type MenuItemProps = {
LeftIcon?: IconComponent; LeftIcon?: IconComponent;
@ -23,7 +26,9 @@ export const MenuItemNavigate = ({
return ( return (
<StyledMenuItemBase onClick={onClick} className={className}> <StyledMenuItemBase onClick={onClick} className={className}>
<MenuItemLeftContent LeftIcon={LeftIcon} text={text} /> <StyledMenuItemLeftContent>
<MenuItemLeftContent LeftIcon={LeftIcon} text={text} />
</StyledMenuItemLeftContent>
<IconChevronRight size={theme.icon.size.sm} /> <IconChevronRight size={theme.icon.size.sm} />
</StyledMenuItemBase> </StyledMenuItemBase>
); );

View File

@ -0,0 +1,107 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconBell, IconMinus } from '@/ui/icon';
import {
CatalogDecorator,
CatalogDimension,
CatalogOptions,
} from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { MenuItemAccent } from '../../types/MenuItemAccent';
import { MenuItemDraggable } from '../MenuItemDraggable';
const meta: Meta<typeof MenuItemDraggable> = {
title: 'ui/MenuItem/MenuItemDraggable',
component: MenuItemDraggable,
};
export default meta;
type Story = StoryObj<typeof MenuItemDraggable>;
export const Default: Story = {
args: {
key: 'key-1',
LeftIcon: IconBell,
accent: 'default',
iconButtons: [{ Icon: IconMinus, onClick: () => console.log('Clicked') }],
onClick: () => console.log('Clicked'),
text: 'Menu item draggable',
isDragDisabled: false,
},
decorators: [ComponentDecorator],
};
export const Catalog: Story = {
args: { ...Default.args },
argTypes: {
accent: { control: false },
iconButtons: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'] },
catalog: {
dimensions: [
{
name: 'isDragDisabled',
values: [true, false],
props: (isDragDisabled: boolean) => ({
isDragDisabled: isDragDisabled,
}),
labels: (isDragDisabled: boolean) =>
isDragDisabled ? 'Without drag icon' : 'With drag icon',
},
{
name: 'accents',
values: ['default', 'danger', 'placeholder'] as MenuItemAccent[],
props: (accent: MenuItemAccent) => ({ accent }),
},
{
name: 'states',
values: ['default', 'hover'],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
return { className: state };
default:
return {};
}
},
},
{
name: 'iconButtons',
values: ['no icon button', 'minus icon buttons'],
props: (choice: string) => {
switch (choice) {
case 'no icon button': {
return {
iconButtons: [],
};
}
case 'minus icon buttons': {
return {
iconButtons: [
{
Icon: IconMinus,
onClick: () =>
console.log('Clicked on minus icon button'),
},
],
};
}
}
},
},
] as CatalogDimension[],
options: {
elementContainer: {
width: 200,
},
} as CatalogOptions,
},
},
decorators: [CatalogDecorator],
};

View File

@ -10,21 +10,21 @@ import {
} from './StyledMenuItemBase'; } from './StyledMenuItemBase';
type OwnProps = { type OwnProps = {
isDraggable?: boolean;
LeftIcon: IconComponent | null | undefined; LeftIcon: IconComponent | null | undefined;
showGrip?: boolean;
text: string; text: string;
}; };
export const MenuItemLeftContent = ({ export const MenuItemLeftContent = ({
isDraggable,
LeftIcon, LeftIcon,
text, text,
showGrip = false,
}: OwnProps) => { }: OwnProps) => {
const theme = useTheme(); const theme = useTheme();
return ( return (
<StyledMenuItemLeftContent> <StyledMenuItemLeftContent>
{isDraggable && ( {showGrip && (
<IconGripVertical <IconGripVertical
size={theme.icon.size.md} size={theme.icon.size.md}
stroke={theme.icon.stroke.sm} stroke={theme.icon.stroke.sm}

View File

@ -88,3 +88,20 @@ export const StyledMenuItemRightContent = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
`; `;
export const StyledHoverableMenuItemBase = styled(StyledMenuItemBase)`
& .hoverable-buttons {
opacity: 0;
pointer-events: none;
position: fixed;
right: ${({ theme }) => theme.spacing(2)};
transition: opacity ${({ theme }) => theme.animation.duration.instant}s ease;
}
&:hover {
& .hoverable-buttons {
opacity: 1;
pointer-events: auto;
}
}
`;

View File

@ -1,21 +1,20 @@
import styled from '@emotion/styled';
import { import {
DragDropContext,
Draggable,
Droppable,
DropResult, DropResult,
OnDragEndResponder, OnDragEndResponder,
ResponderProvided, ResponderProvided,
} from '@hello-pangea/dnd'; } from '@hello-pangea/dnd';
import { DraggableItem } from '@/ui/draggable-list/components/DraggableItem';
import { DroppableList } from '@/ui/draggable-list/components/DroppableList';
import { StyledDropdownMenuItemsContainer } from '@/ui/dropdown/components/StyledDropdownMenuItemsContainer'; import { StyledDropdownMenuItemsContainer } from '@/ui/dropdown/components/StyledDropdownMenuItemsContainer';
import { StyledDropdownMenuSubheader } from '@/ui/dropdown/components/StyledDropdownMenuSubheader'; import { StyledDropdownMenuSubheader } from '@/ui/dropdown/components/StyledDropdownMenuSubheader';
import { IconMinus, IconPlus } from '@/ui/icon'; import { IconMinus, IconPlus } from '@/ui/icon';
import { MenuItem } from '@/ui/menu-item/components/MenuItem'; import { MenuItem } from '@/ui/menu-item/components/MenuItem';
import { MenuItemDraggable } from '@/ui/menu-item/components/MenuItemDraggable';
import { ViewFieldForVisibility } from '../types/ViewFieldForVisibility'; import { ViewFieldForVisibility } from '../types/ViewFieldForVisibility';
type OwnProps = { type ViewFieldsVisibilityDropdownSectionProps = {
fields: ViewFieldForVisibility[]; fields: ViewFieldForVisibility[];
onVisibilityChange: (field: ViewFieldForVisibility) => void; onVisibilityChange: (field: ViewFieldForVisibility) => void;
title: string; title: string;
@ -23,17 +22,13 @@ type OwnProps = {
onDragEnd?: OnDragEndResponder; onDragEnd?: OnDragEndResponder;
}; };
const StyledDropdownMenuItemWrapper = styled.div`
width: 100%;
`;
export const ViewFieldsVisibilityDropdownSection = ({ export const ViewFieldsVisibilityDropdownSection = ({
fields, fields,
onVisibilityChange, onVisibilityChange,
title, title,
isDraggable, isDraggable,
onDragEnd, onDragEnd,
}: OwnProps) => { }: ViewFieldsVisibilityDropdownSectionProps) => {
const handleOnDrag = (result: DropResult, provided: ResponderProvided) => { const handleOnDrag = (result: DropResult, provided: ResponderProvided) => {
onDragEnd?.(result, provided); onDragEnd?.(result, provided);
}; };
@ -54,57 +49,31 @@ export const ViewFieldsVisibilityDropdownSection = ({
<StyledDropdownMenuSubheader>{title}</StyledDropdownMenuSubheader> <StyledDropdownMenuSubheader>{title}</StyledDropdownMenuSubheader>
<StyledDropdownMenuItemsContainer> <StyledDropdownMenuItemsContainer>
{isDraggable && ( {isDraggable && (
<DragDropContext onDragEnd={handleOnDrag}> <DroppableList
<StyledDropdownMenuItemWrapper> droppableId="droppable"
<Droppable droppableId="droppable"> onDragEnd={handleOnDrag}
{(provided) => ( draggableItems={
<div ref={provided.innerRef} {...provided.droppableProps}> <>
{fields.map((field, index) => ( {fields.map((field, index) => (
<Draggable <DraggableItem
key={field.key}
draggableId={field.key}
index={index}
isDragDisabled={index === 0}
itemComponent={
<MenuItemDraggable
key={field.key} key={field.key}
draggableId={field.key} LeftIcon={field.Icon}
index={index} iconButtons={getIconButtons(index, field)}
text={field.name}
isDragDisabled={index === 0} isDragDisabled={index === 0}
> />
{(draggableProvided) => { }
const draggableStyle = />
draggableProvided.draggableProps.style; ))}
</>
return ( }
<div />
ref={draggableProvided.innerRef}
{...{
...draggableProvided.draggableProps,
style: {
...draggableStyle,
left: 'auto',
top: 'auto',
transform: draggableStyle?.transform?.replace(
/\(-?\d+px,/,
'(0,',
),
},
}}
{...draggableProvided.dragHandleProps}
>
<MenuItem
isDraggable={index !== 0 && isDraggable}
key={field.key}
LeftIcon={field.Icon}
iconButtons={getIconButtons(index, field)}
text={field.name}
/>
</div>
);
}}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</StyledDropdownMenuItemWrapper>
</DragDropContext>
)} )}
{!isDraggable && {!isDraggable &&
fields.map((field) => ( fields.map((field) => (