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';
const StyledMenuContainer = styled.div`
left: 26.5px;
position: absolute;
top: ${({ theme }) => theme.spacing(10)};
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 styled from '@emotion/styled';
import { FloatingIconButtonGroup } from '@/ui/button/components/FloatingIconButtonGroup';
import { IconComponent } from '@/ui/icon/types/IconComponent';
import { MenuItemLeftContent } from '../internals/components/MenuItemLeftContent';
import { StyledMenuItemBase } from '../internals/components/StyledMenuItemBase';
import {
StyledHoverableMenuItemBase,
StyledMenuItemLeftContent,
} from '../internals/components/StyledMenuItemBase';
import { MenuItemAccent } from '../types/MenuItemAccent';
export type MenuItemIconButton = {
@ -14,7 +16,6 @@ export type MenuItemIconButton = {
};
export type MenuItemProps = {
isDraggable?: boolean;
LeftIcon?: IconComponent | null;
accent?: MenuItemAccent;
text: string;
@ -24,25 +25,7 @@ export type MenuItemProps = {
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 = ({
isDraggable,
LeftIcon,
accent = 'default',
text,
@ -60,11 +43,9 @@ export const MenuItem = ({
className={className}
accent={accent}
>
<MenuItemLeftContent
isDraggable={isDraggable ? true : false}
LeftIcon={LeftIcon ?? undefined}
text={text}
/>
<StyledMenuItemLeftContent>
<MenuItemLeftContent LeftIcon={LeftIcon ?? undefined} text={text} />
</StyledMenuItemLeftContent>
<div className="hoverable-buttons">
{showIconButtons && (
<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 { MenuItemLeftContent } from '../internals/components/MenuItemLeftContent';
import { StyledMenuItemBase } from '../internals/components/StyledMenuItemBase';
import {
StyledMenuItemBase,
StyledMenuItemLeftContent,
} from '../internals/components/StyledMenuItemBase';
export type MenuItemProps = {
LeftIcon?: IconComponent;
@ -23,7 +26,9 @@ export const MenuItemNavigate = ({
return (
<StyledMenuItemBase onClick={onClick} className={className}>
<MenuItemLeftContent LeftIcon={LeftIcon} text={text} />
<StyledMenuItemLeftContent>
<MenuItemLeftContent LeftIcon={LeftIcon} text={text} />
</StyledMenuItemLeftContent>
<IconChevronRight size={theme.icon.size.sm} />
</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';
type OwnProps = {
isDraggable?: boolean;
LeftIcon: IconComponent | null | undefined;
showGrip?: boolean;
text: string;
};
export const MenuItemLeftContent = ({
isDraggable,
LeftIcon,
text,
showGrip = false,
}: OwnProps) => {
const theme = useTheme();
return (
<StyledMenuItemLeftContent>
{isDraggable && (
{showGrip && (
<IconGripVertical
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}

View File

@ -88,3 +88,20 @@ export const StyledMenuItemRightContent = styled.div`
display: flex;
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 {
DragDropContext,
Draggable,
Droppable,
DropResult,
OnDragEndResponder,
ResponderProvided,
} 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 { StyledDropdownMenuSubheader } from '@/ui/dropdown/components/StyledDropdownMenuSubheader';
import { IconMinus, IconPlus } from '@/ui/icon';
import { MenuItem } from '@/ui/menu-item/components/MenuItem';
import { MenuItemDraggable } from '@/ui/menu-item/components/MenuItemDraggable';
import { ViewFieldForVisibility } from '../types/ViewFieldForVisibility';
type OwnProps = {
type ViewFieldsVisibilityDropdownSectionProps = {
fields: ViewFieldForVisibility[];
onVisibilityChange: (field: ViewFieldForVisibility) => void;
title: string;
@ -23,17 +22,13 @@ type OwnProps = {
onDragEnd?: OnDragEndResponder;
};
const StyledDropdownMenuItemWrapper = styled.div`
width: 100%;
`;
export const ViewFieldsVisibilityDropdownSection = ({
fields,
onVisibilityChange,
title,
isDraggable,
onDragEnd,
}: OwnProps) => {
}: ViewFieldsVisibilityDropdownSectionProps) => {
const handleOnDrag = (result: DropResult, provided: ResponderProvided) => {
onDragEnd?.(result, provided);
};
@ -54,57 +49,31 @@ export const ViewFieldsVisibilityDropdownSection = ({
<StyledDropdownMenuSubheader>{title}</StyledDropdownMenuSubheader>
<StyledDropdownMenuItemsContainer>
{isDraggable && (
<DragDropContext onDragEnd={handleOnDrag}>
<StyledDropdownMenuItemWrapper>
<Droppable droppableId="droppable">
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
{fields.map((field, index) => (
<Draggable
<DroppableList
droppableId="droppable"
onDragEnd={handleOnDrag}
draggableItems={
<>
{fields.map((field, index) => (
<DraggableItem
key={field.key}
draggableId={field.key}
index={index}
isDragDisabled={index === 0}
itemComponent={
<MenuItemDraggable
key={field.key}
draggableId={field.key}
index={index}
LeftIcon={field.Icon}
iconButtons={getIconButtons(index, field)}
text={field.name}
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 &&
fields.map((field) => (