Refactor/new menu item (#1448)
* wip * finished * Added disabled * Fixed disabled * Finished cleaning * Minor fixes from merge * Added docs * Added PascalCase * Fix from review * Fixes from merge * Fix lint * Fixed storybook tests
This commit is contained in:
@ -1,52 +0,0 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { Checkbox } from '@/ui/input/checkbox/components/Checkbox';
|
||||
|
||||
import { DropdownMenuItem } from './DropdownMenuItem';
|
||||
|
||||
type Props = {
|
||||
checked: boolean;
|
||||
onChange?: (newCheckedValue: boolean) => void;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
const StyledDropdownMenuCheckableItemContainer = styled(DropdownMenuItem)`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const StyledLeftContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledChildrenContainer = styled.div`
|
||||
align-items: center;
|
||||
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
export function DropdownMenuCheckableItem({
|
||||
checked,
|
||||
onChange,
|
||||
children,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
function handleClick() {
|
||||
onChange?.(!checked);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledDropdownMenuCheckableItemContainer onClick={handleClick}>
|
||||
<StyledLeftContainer>
|
||||
<Checkbox checked={checked} />
|
||||
<StyledChildrenContainer>{children}</StyledChildrenContainer>
|
||||
</StyledLeftContainer>
|
||||
</StyledDropdownMenuCheckableItemContainer>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
import { ComponentProps, ReactElement } from 'react';
|
||||
import { ComponentProps } from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconComponent } from '@/ui/icon/types/IconComponent';
|
||||
|
||||
const StyledHeader = styled.li`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
@ -40,23 +43,31 @@ const StyledEndIconWrapper = styled(StyledStartIconWrapper)`
|
||||
`;
|
||||
|
||||
type DropdownMenuHeaderProps = ComponentProps<'li'> & {
|
||||
startIcon?: ReactElement;
|
||||
endIcon?: ReactElement;
|
||||
StartIcon?: IconComponent;
|
||||
EndIcon?: IconComponent;
|
||||
};
|
||||
|
||||
export function DropdownMenuHeader({
|
||||
children,
|
||||
startIcon,
|
||||
endIcon,
|
||||
StartIcon,
|
||||
EndIcon,
|
||||
...props
|
||||
}: DropdownMenuHeaderProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledHeader {...props}>
|
||||
{startIcon && (
|
||||
<StyledStartIconWrapper>{startIcon}</StyledStartIconWrapper>
|
||||
{StartIcon && (
|
||||
<StyledStartIconWrapper>
|
||||
<StartIcon size={theme.icon.size.md} />
|
||||
</StyledStartIconWrapper>
|
||||
)}
|
||||
{children}
|
||||
{endIcon && <StyledEndIconWrapper>{endIcon}</StyledEndIconWrapper>}
|
||||
{EndIcon && (
|
||||
<StyledEndIconWrapper>
|
||||
<EndIcon size={theme.icon.size.md} />
|
||||
</StyledEndIconWrapper>
|
||||
)}
|
||||
</StyledHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,79 +0,0 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { FloatingIconButtonGroup } from '@/ui/button/components/FloatingIconButtonGroup';
|
||||
import { hoverBackground } from '@/ui/theme/constants/effects';
|
||||
|
||||
export type DropdownMenuItemAccent = 'regular' | 'danger';
|
||||
|
||||
const StyledItem = styled.li<{ accent: DropdownMenuItemAccent }>`
|
||||
--horizontal-padding: ${({ theme }) => theme.spacing(1)};
|
||||
--vertical-padding: ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
align-items: center;
|
||||
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
color: ${({ theme, accent }) =>
|
||||
accent === 'danger' ? theme.color.red : theme.font.color.secondary};
|
||||
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
height: calc(32px - 2 * var(--vertical-padding));
|
||||
|
||||
padding: var(--vertical-padding) var(--horizontal-padding);
|
||||
|
||||
${hoverBackground};
|
||||
|
||||
position: relative;
|
||||
user-select: none;
|
||||
|
||||
width: calc(100% - 2 * var(--horizontal-padding));
|
||||
|
||||
&:hover .actions-hover-container {
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledActions = styled(motion.div)`
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
export type DropdownMenuItemProps = ComponentProps<'li'> & {
|
||||
actions?: React.ReactNode[];
|
||||
accent?: DropdownMenuItemAccent;
|
||||
};
|
||||
|
||||
export function DropdownMenuItem({
|
||||
actions,
|
||||
children,
|
||||
accent = 'regular',
|
||||
...props
|
||||
}: DropdownMenuItemProps) {
|
||||
return (
|
||||
<StyledItem {...props} accent={accent}>
|
||||
{children}
|
||||
{actions && (
|
||||
<StyledActions
|
||||
className="actions-hover-container"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<FloatingIconButtonGroup size="small">
|
||||
{actions}
|
||||
</FloatingIconButtonGroup>
|
||||
</StyledActions>
|
||||
)}
|
||||
</StyledItem>
|
||||
);
|
||||
}
|
||||
@ -1,87 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconCheck } from '@/ui/icon/index';
|
||||
import { hoverBackground } from '@/ui/theme/constants/effects';
|
||||
|
||||
import { DropdownMenuItem } from './DropdownMenuItem';
|
||||
|
||||
type Props = React.ComponentProps<'li'> & {
|
||||
selected?: boolean;
|
||||
hovered?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const StyledDropdownMenuSelectableItemContainer = styled(DropdownMenuItem)<
|
||||
Pick<Props, 'hovered'>
|
||||
>`
|
||||
${hoverBackground};
|
||||
|
||||
align-items: center;
|
||||
|
||||
background: ${(props) =>
|
||||
props.hovered ? props.theme.background.transparent.light : 'transparent'};
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
width: calc(100% - ${({ theme }) => theme.spacing(2)});
|
||||
`;
|
||||
|
||||
const StyledLeftContainer = styled.div<Pick<Props, 'disabled'>>`
|
||||
align-items: center;
|
||||
|
||||
display: flex;
|
||||
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
opacity: ${({ disabled }) => (disabled ? 0.5 : 1)};
|
||||
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledRightIcon = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export function DropdownMenuSelectableItem({
|
||||
selected,
|
||||
onClick,
|
||||
children,
|
||||
hovered,
|
||||
disabled,
|
||||
...restProps
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
const theme = useTheme();
|
||||
|
||||
function handleClick(event: React.MouseEvent<HTMLLIElement>) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClick?.(event);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hovered) {
|
||||
window.scrollTo({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, [hovered]);
|
||||
|
||||
return (
|
||||
<StyledDropdownMenuSelectableItemContainer
|
||||
{...restProps}
|
||||
onClick={handleClick}
|
||||
hovered={hovered}
|
||||
data-testid="dropdown-menu-item"
|
||||
>
|
||||
<StyledLeftContainer disabled={disabled}>{children}</StyledLeftContainer>
|
||||
<StyledRightIcon>
|
||||
{selected && <IconCheck size={theme.icon.size.md} />}
|
||||
</StyledRightIcon>
|
||||
</StyledDropdownMenuSelectableItemContainer>
|
||||
);
|
||||
}
|
||||
@ -15,5 +15,6 @@ export const StyledDropdownMenuItemsContainer = styled.div<{
|
||||
overflow-y: auto;
|
||||
|
||||
padding: var(--padding);
|
||||
padding-right: var(--padding);
|
||||
width: calc(100% - 2 * var(--padding));
|
||||
`;
|
||||
|
||||
@ -2,17 +2,16 @@ import { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { IconButton } from '@/ui/button/components/IconButton';
|
||||
import { IconPlus, IconUser } from '@/ui/icon';
|
||||
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
|
||||
import { MenuItem } from '@/ui/menu-item/components/MenuItem';
|
||||
import { MenuItemMultiSelectAvatar } from '@/ui/menu-item/components/MenuItemMultiSelectAvatar';
|
||||
import { MenuItemSelectAvatar } from '@/ui/menu-item/components/MenuItemSelectAvatar';
|
||||
import { Avatar } from '@/users/components/Avatar';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
|
||||
import { DropdownMenuCheckableItem } from '../DropdownMenuCheckableItem';
|
||||
import { DropdownMenuHeader } from '../DropdownMenuHeader';
|
||||
import { DropdownMenuInput } from '../DropdownMenuInput';
|
||||
import { DropdownMenuItem } from '../DropdownMenuItem';
|
||||
import { DropdownMenuSelectableItem } from '../DropdownMenuSelectableItem';
|
||||
import { StyledDropdownMenu } from '../StyledDropdownMenu';
|
||||
import { StyledDropdownMenuItemsContainer } from '../StyledDropdownMenuItemsContainer';
|
||||
import { StyledDropdownMenuSeparator } from '../StyledDropdownMenuSeparator';
|
||||
@ -101,21 +100,22 @@ const FakeSelectableMenuItemList = ({ hasAvatar }: { hasAvatar?: boolean }) => {
|
||||
return (
|
||||
<>
|
||||
{mockSelectArray.map((item) => (
|
||||
<DropdownMenuSelectableItem
|
||||
<MenuItemSelectAvatar
|
||||
key={item.id}
|
||||
selected={selectedItem === item.id}
|
||||
onClick={() => setSelectedItem(item.id)}
|
||||
>
|
||||
{hasAvatar && (
|
||||
<Avatar
|
||||
placeholder="A"
|
||||
avatarUrl={item.avatarUrl}
|
||||
size="md"
|
||||
type="squared"
|
||||
/>
|
||||
)}
|
||||
{item.name}
|
||||
</DropdownMenuSelectableItem>
|
||||
avatar={
|
||||
hasAvatar ? (
|
||||
<Avatar
|
||||
placeholder="A"
|
||||
avatarUrl={item.avatarUrl}
|
||||
size="md"
|
||||
type="squared"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
text={item.name}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@ -127,28 +127,28 @@ const FakeCheckableMenuItemList = ({ hasAvatar }: { hasAvatar?: boolean }) => {
|
||||
return (
|
||||
<>
|
||||
{mockSelectArray.map((item) => (
|
||||
<DropdownMenuCheckableItem
|
||||
<MenuItemMultiSelectAvatar
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
checked={selectedItems.includes(item.id)}
|
||||
onChange={(checked) => {
|
||||
selected={selectedItems.includes(item.id)}
|
||||
onSelectChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedItems([...selectedItems, item.id]);
|
||||
} else {
|
||||
setSelectedItems(selectedItems.filter((id) => id !== item.id));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasAvatar && (
|
||||
<Avatar
|
||||
placeholder="A"
|
||||
avatarUrl={item.avatarUrl}
|
||||
size="md"
|
||||
type="squared"
|
||||
/>
|
||||
)}
|
||||
{item.name}
|
||||
</DropdownMenuCheckableItem>
|
||||
avatar={
|
||||
hasAvatar ? (
|
||||
<Avatar
|
||||
placeholder="A"
|
||||
avatarUrl={item.avatarUrl}
|
||||
size="md"
|
||||
type="squared"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
text={item.name}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@ -182,7 +182,7 @@ export const SimpleMenuItem: Story = {
|
||||
<StyledDropdownMenu {...args}>
|
||||
<StyledDropdownMenuItemsContainer hasMaxHeight>
|
||||
{mockSelectArray.map(({ name }) => (
|
||||
<DropdownMenuItem>{name}</DropdownMenuItem>
|
||||
<MenuItem text={name} />
|
||||
))}
|
||||
</StyledDropdownMenuItemsContainer>
|
||||
</StyledDropdownMenu>
|
||||
@ -198,14 +198,14 @@ export const WithHeaders: Story = {
|
||||
<StyledDropdownMenuSubheader>Subheader 1</StyledDropdownMenuSubheader>
|
||||
<StyledDropdownMenuItemsContainer>
|
||||
{mockSelectArray.slice(0, 3).map(({ name }) => (
|
||||
<DropdownMenuItem>{name}</DropdownMenuItem>
|
||||
<MenuItem text={name} />
|
||||
))}
|
||||
</StyledDropdownMenuItemsContainer>
|
||||
<StyledDropdownMenuSeparator />
|
||||
<StyledDropdownMenuSubheader>Subheader 2</StyledDropdownMenuSubheader>
|
||||
<StyledDropdownMenuItemsContainer>
|
||||
{mockSelectArray.slice(3).map(({ name }) => (
|
||||
<DropdownMenuItem>{name}</DropdownMenuItem>
|
||||
<MenuItem text={name} />
|
||||
))}
|
||||
</StyledDropdownMenuItemsContainer>
|
||||
</StyledDropdownMenu>
|
||||
@ -218,10 +218,7 @@ export const WithIcons: Story = {
|
||||
<StyledDropdownMenu {...args}>
|
||||
<StyledDropdownMenuItemsContainer hasMaxHeight>
|
||||
{mockSelectArray.map(({ name }) => (
|
||||
<DropdownMenuItem>
|
||||
<IconUser size={16} />
|
||||
{name}
|
||||
</DropdownMenuItem>
|
||||
<MenuItem text={name} LeftIcon={IconUser} />
|
||||
))}
|
||||
</StyledDropdownMenuItemsContainer>
|
||||
</StyledDropdownMenu>
|
||||
@ -234,15 +231,11 @@ export const WithActions: Story = {
|
||||
<StyledDropdownMenu {...args}>
|
||||
<StyledDropdownMenuItemsContainer hasMaxHeight>
|
||||
{mockSelectArray.map(({ name }, index) => (
|
||||
<DropdownMenuItem
|
||||
<MenuItem
|
||||
className={index === 0 ? 'hover' : undefined}
|
||||
actions={[
|
||||
<IconButton icon={<IconUser />} />,
|
||||
<IconButton icon={<IconPlus />} />,
|
||||
]}
|
||||
>
|
||||
{name}
|
||||
</DropdownMenuItem>
|
||||
iconButtons={[{ Icon: IconUser }, { Icon: IconPlus }]}
|
||||
text={name}
|
||||
/>
|
||||
))}
|
||||
</StyledDropdownMenuItemsContainer>
|
||||
</StyledDropdownMenu>
|
||||
@ -273,7 +266,7 @@ export const Search: Story = {
|
||||
<StyledDropdownMenuSeparator />
|
||||
<StyledDropdownMenuItemsContainer hasMaxHeight>
|
||||
{mockSelectArray.map(({ name }) => (
|
||||
<DropdownMenuItem>{name}</DropdownMenuItem>
|
||||
<MenuItem text={name} />
|
||||
))}
|
||||
</StyledDropdownMenuItemsContainer>
|
||||
</StyledDropdownMenu>
|
||||
|
||||
Reference in New Issue
Block a user