Refactor/dropdown menu (#279)

* Created dropdown menu UI component with story

* Added all components for composing Dropdown Menus

* Better component naming and reordered stories

* Solved comment thread from review
This commit is contained in:
Lucas Bordeau 2023-06-13 15:15:19 +02:00 committed by GitHub
parent 16e1b862d9
commit 3a719001de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 614 additions and 22 deletions

View File

@ -2,7 +2,7 @@ import { Tooltip } from 'react-tooltip';
import styled from '@emotion/styled';
import { CommentForDrawer } from '@/comments/types/CommentForDrawer';
import { UserAvatar } from '@/users/components/UserAvatar';
import { Avatar } from '@/users/components/Avatar';
import {
beautifyExactDate,
beautifyPastDateRelativeToNow,
@ -75,7 +75,7 @@ export function CommentHeader({ comment }: OwnProps) {
return (
<StyledContainer>
<UserAvatar
<Avatar
avatarUrl={avatarUrl}
size={16}
placeholderLetter={capitalizedFirstUsernameLetter}

View File

@ -15,5 +15,5 @@ export const EditableRelationCreateButton = styled.button`
width: 100%;
height: 31px;
background: none;
gap: 8px;
gap: ${(props) => props.theme.spacing(2)};
`;

View File

@ -0,0 +1,20 @@
import styled from '@emotion/styled';
export const DropdownMenu = styled.div`
width: 200px;
height: fit-content;
display: flex;
flex-direction: column;
align-items: center;
background: ${(props) => props.theme.secondaryBackgroundTransparent};
border: 1px solid ${(props) => props.theme.lightBorder};
border-radius: calc(${(props) => props.theme.borderRadius} * 2);
box-shadow: ${(props) => props.theme.modalBoxShadow};
backdrop-filter: blur(20px);
`;

View File

@ -0,0 +1,30 @@
import styled from '@emotion/styled';
import { hoverBackground } from '@/ui/layout/styles/themes';
export const DropdownMenuButton = styled.div`
--horizontal-padding: ${(props) => props.theme.spacing(1.5)};
--vertical-padding: ${(props) => props.theme.spacing(2)};
padding: var(--vertical-padding) var(--horizontal-padding);
width: calc(100% - 2 * var(--horizontal-padding));
height: calc(32px - 2 * var(--vertical-padding));
display: flex;
flex-direction: row;
align-items: center;
gap: ${(props) => props.theme.spacing(2)};
border-radius: ${(props) => props.theme.borderRadius};
cursor: pointer;
user-select: none;
${hoverBackground};
color: ${(props) => props.theme.text60};
font-size: ${(props) => props.theme.fontSizeSmall};
`;

View File

@ -0,0 +1,53 @@
import React from 'react';
import styled from '@emotion/styled';
import { Checkbox } from '../form/Checkbox';
import { DropdownMenuButton } from './DropdownMenuButton';
type Props = {
checked: boolean;
onChange?: (newCheckedValue: boolean) => void;
id: string;
};
const DropdownMenuCheckableItemContainer = styled(DropdownMenuButton)`
display: flex;
align-items: center;
justify-content: space-between;
`;
const StyledLeftContainer = styled.div`
display: flex;
align-items: center;
gap: ${(props) => props.theme.spacing(2)};
`;
const StyledChildrenContainer = styled.div`
font-size: ${(props) => props.theme.fontSizeSmall};
display: flex;
align-items: center;
gap: ${(props) => props.theme.spacing(2)};
`;
export function DropdownMenuCheckableItem({
checked,
onChange,
id,
children,
}: React.PropsWithChildren<Props>) {
function handleClick() {
onChange?.(!checked);
}
return (
<DropdownMenuCheckableItemContainer onClick={handleClick}>
<StyledLeftContainer>
<Checkbox onChange={onChange} id={id} name={id} checked={checked} />
<StyledChildrenContainer>{children}</StyledChildrenContainer>
</StyledLeftContainer>
</DropdownMenuCheckableItemContainer>
);
}

View File

@ -0,0 +1,22 @@
import styled from '@emotion/styled';
export const DropdownMenuItem = styled.div`
--horizontal-padding: ${(props) => props.theme.spacing(1.5)};
--vertical-padding: ${(props) => props.theme.spacing(2)};
padding: var(--vertical-padding) var(--horizontal-padding);
width: calc(100% - 2 * var(--horizontal-padding));
height: calc(32px - 2 * var(--vertical-padding));
display: flex;
flex-direction: row;
align-items: center;
gap: ${(props) => props.theme.spacing(2)};
border-radius: ${(props) => props.theme.borderRadius};
color: ${(props) => props.theme.text60};
font-size: ${(props) => props.theme.fontSizeSmall};
`;

View File

@ -0,0 +1,14 @@
import styled from '@emotion/styled';
export const DropdownMenuItemContainer = styled.div`
--padding: ${(props) => props.theme.spacing(1 / 2)};
width: calc(100% - 2 * var(--padding));
height: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: var(--padding);
gap: 2px;
`;

View File

@ -0,0 +1,40 @@
import { InputHTMLAttributes } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '@/ui/layout/styles/themes';
export const DropdownMenuSearchContainer = styled.div`
--horizontal-padding: ${(props) => props.theme.spacing(2)};
--vertical-padding: ${(props) => props.theme.spacing(1)};
width: calc(100% - 2 * var(--horizontal-padding));
height: calc(36px - 2 * var(--vertical-padding));
display: flex;
flex-direction: row;
align-items: center;
padding: var(--vertical-padding) var(--horizontal-padding);
border-bottom: 1px solid ${(props) => props.theme.lightBorder};
`;
const StyledEditModeSearchInput = styled.input`
width: 100%;
${textInputStyle}
font-size: ${(props) => props.theme.fontSizeSmall};
`;
export function DropdownMenuSearch(
props: InputHTMLAttributes<HTMLInputElement>,
) {
return (
<DropdownMenuSearchContainer>
<StyledEditModeSearchInput
{...props}
placeholder={props.placeholder ?? 'Search'}
/>
</DropdownMenuSearchContainer>
);
}

View File

@ -0,0 +1,44 @@
import React from 'react';
import styled from '@emotion/styled';
import { IconCheck } from '@tabler/icons-react';
import { hoverBackground } from '@/ui/layout/styles/themes';
import { DropdownMenuButton } from './DropdownMenuButton';
type Props = {
selected: boolean;
onClick: () => void;
};
const DropdownMenuSelectableItemContainer = styled(DropdownMenuButton)<Props>`
${hoverBackground};
display: flex;
align-items: center;
justify-content: space-between;
`;
const StyledLeftContainer = styled.div`
display: flex;
align-items: center;
gap: ${(props) => props.theme.spacing(2)};
`;
const StyledRightIcon = styled.div`
display: flex;
`;
export function DropdownMenuSelectableItem({
selected,
onClick,
children,
}: React.PropsWithChildren<Props>) {
return (
<DropdownMenuSelectableItemContainer onClick={onClick} selected={selected}>
<StyledLeftContainer>{children}</StyledLeftContainer>
<StyledRightIcon>{selected && <IconCheck size={16} />}</StyledRightIcon>
</DropdownMenuSelectableItemContainer>
);
}

View File

@ -0,0 +1,8 @@
import styled from '@emotion/styled';
export const DropdownMenuSeparator = styled.div`
width: 100%;
height: 1px;
background-color: ${(props) => props.theme.mediumBorder};
`;

View File

@ -0,0 +1,331 @@
import React, { useState } from 'react';
import styled from '@emotion/styled';
import type { Meta, StoryObj } from '@storybook/react';
import { IconPlus } from '@tabler/icons-react';
import { Avatar } from '@/users/components/Avatar';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { DropdownMenu } from '../DropdownMenu';
import { DropdownMenuButton } from '../DropdownMenuButton';
import { DropdownMenuCheckableItem } from '../DropdownMenuCheckableItem';
import { DropdownMenuItem } from '../DropdownMenuItem';
import { DropdownMenuItemContainer } from '../DropdownMenuItemContainer';
import { DropdownMenuSearch } from '../DropdownMenuSearch';
import { DropdownMenuSelectableItem } from '../DropdownMenuSelectableItem';
import { DropdownMenuSeparator } from '../DropdownMenuSeparator';
const meta: Meta<typeof DropdownMenu> = {
title: 'Components/DropdownMenu',
component: DropdownMenu,
};
export default meta;
type Story = StoryObj<typeof DropdownMenu>;
const FakeContentBelow = () => (
<div style={{ position: 'absolute' }}>
askjdlaksjdlaksjdlakjsdlkj lkajsldkjalskd jalksdj alksjd alskjd alksjd
alksjd laksjd askjdlaksjdlaksjdlakjsdlkj lkajsldkjalskd jalksdj alksjd
</div>
);
const avatarUrl =
'https://s3-alpha-sig.figma.com/img/bbb5/4905/f0a52cc2b9aaeb0a82a360d478dae8bf?Expires=1687132800&Signature=iVBr0BADa3LHoFVGbwqO-wxC51n1o~ZyFD-w7nyTyFP4yB-Y6zFawL-igewaFf6PrlumCyMJThDLAAc-s-Cu35SBL8BjzLQ6HymzCXbrblUADMB208PnMAvc1EEUDq8TyryFjRO~GggLBk5yR0EXzZ3zenqnDEGEoQZR~TRqS~uDF-GwQB3eX~VdnuiU2iittWJkajIDmZtpN3yWtl4H630A3opQvBnVHZjXAL5YPkdh87-a-H~6FusWvvfJxfNC2ZzbrARzXofo8dUFtH7zUXGCC~eUk~hIuLbLuz024lFQOjiWq2VKyB7dQQuGFpM-OZQEV8tSfkViP8uzDLTaCg__&Key-Pair-Id=APKAQ4GOSFWCVNEHN3O4';
const FakeMenuContent = styled.div`
width: 100%;
height: 400px;
`;
const FakeBelowContainer = styled.div`
width: 300px;
height: 600px;
position: relative;
`;
const MenuAbsolutePositionWrapper = styled.div`
width: fit-content;
height: fit-content;
position: absolute;
`;
const FakeMenuItemList = () => (
<>
<DropdownMenuItem>Company A</DropdownMenuItem>
<DropdownMenuItem>Company B</DropdownMenuItem>
<DropdownMenuItem>Company C</DropdownMenuItem>
<DropdownMenuItem>Person 2</DropdownMenuItem>
<DropdownMenuItem>Company D</DropdownMenuItem>
<DropdownMenuItem>Person 1</DropdownMenuItem>
</>
);
const mockSelectArray = [
{
id: '1',
name: 'Company A',
avatarUrl: avatarUrl,
},
{
id: '2',
name: 'Company B',
avatarUrl: avatarUrl,
},
{
id: '3',
name: 'Company C',
avatarUrl: avatarUrl,
},
{
id: '4',
name: 'Person 2',
avatarUrl: avatarUrl,
},
{
id: '5',
name: 'Company D',
avatarUrl: avatarUrl,
},
{
id: '6',
name: 'Person 1',
avatarUrl: avatarUrl,
},
];
export const Empty: Story = {
render: getRenderWrapperForComponent(
<DropdownMenu>
<FakeMenuContent />
</DropdownMenu>,
),
};
const DropdownMenuStoryWrapper = ({
children,
}: React.PropsWithChildren<unknown>) => (
<FakeBelowContainer>
<FakeContentBelow />
<MenuAbsolutePositionWrapper>
<DropdownMenu>{children}</DropdownMenu>
</MenuAbsolutePositionWrapper>
</FakeBelowContainer>
);
export const EmptyWithContentBelow: Story = {
render: getRenderWrapperForComponent(
<DropdownMenuStoryWrapper>
<FakeMenuContent />
</DropdownMenuStoryWrapper>,
),
};
export const SimpleMenuItem: Story = {
render: getRenderWrapperForComponent(
<DropdownMenuStoryWrapper>
<DropdownMenuItemContainer>
<FakeMenuItemList />
</DropdownMenuItemContainer>
</DropdownMenuStoryWrapper>,
),
};
export const Search: Story = {
render: getRenderWrapperForComponent(
<FakeBelowContainer>
<FakeContentBelow />
<MenuAbsolutePositionWrapper>
<DropdownMenu>
<DropdownMenuSearch />
<DropdownMenuSeparator />
<DropdownMenuItemContainer>
<FakeMenuItemList />
</DropdownMenuItemContainer>
</DropdownMenu>
</MenuAbsolutePositionWrapper>
</FakeBelowContainer>,
),
};
const FakeSelectableMenuItemList = () => {
const [selectedItem, setSelectedItem] = useState<string | null>(null);
return (
<>
{mockSelectArray.map((item) => (
<DropdownMenuSelectableItem
key={item.id}
selected={selectedItem === item.id}
onClick={() => setSelectedItem(item.id)}
>
{item.name}
</DropdownMenuSelectableItem>
))}
</>
);
};
export const Button: Story = {
render: getRenderWrapperForComponent(
<FakeBelowContainer>
<FakeContentBelow />
<MenuAbsolutePositionWrapper>
<DropdownMenu>
<DropdownMenuItemContainer>
<DropdownMenuButton>
<IconPlus size={16} />
<div>Create new</div>
</DropdownMenuButton>
</DropdownMenuItemContainer>
<DropdownMenuSeparator />
<DropdownMenuItemContainer>
<FakeSelectableMenuItemList />
</DropdownMenuItemContainer>
</DropdownMenu>
</MenuAbsolutePositionWrapper>
</FakeBelowContainer>,
),
};
export const SelectableMenuItem: Story = {
render: getRenderWrapperForComponent(
<FakeBelowContainer>
<FakeContentBelow />
<MenuAbsolutePositionWrapper>
<DropdownMenu>
<DropdownMenuItemContainer>
<FakeSelectableMenuItemList />
</DropdownMenuItemContainer>
</DropdownMenu>
</MenuAbsolutePositionWrapper>
</FakeBelowContainer>,
),
};
const FakeSelectableMenuItemWithAvatarList = () => {
const [selectedItem, setSelectedItem] = useState<string | null>(null);
return (
<>
{mockSelectArray.map((item) => (
<DropdownMenuSelectableItem
key={item.id}
selected={selectedItem === item.id}
onClick={() => setSelectedItem(item.id)}
>
<Avatar
placeholderLetter="A"
avatarUrl={item.avatarUrl}
size={16}
type="squared"
/>
{item.name}
</DropdownMenuSelectableItem>
))}
</>
);
};
export const SelectableMenuItemWithAvatar: Story = {
render: getRenderWrapperForComponent(
<FakeBelowContainer>
<FakeContentBelow />
<MenuAbsolutePositionWrapper>
<DropdownMenu>
<DropdownMenuItemContainer>
<FakeSelectableMenuItemWithAvatarList />
</DropdownMenuItemContainer>
</DropdownMenu>
</MenuAbsolutePositionWrapper>
</FakeBelowContainer>,
),
};
const FakeCheckableMenuItemList = () => {
const [selectedItems, setSelectedItems] = useState<string[]>([]);
return (
<>
{mockSelectArray.map((item) => (
<DropdownMenuCheckableItem
key={item.id}
id={item.id}
checked={selectedItems.includes(item.id)}
onChange={(checked) => {
if (checked) {
setSelectedItems([...selectedItems, item.id]);
} else {
setSelectedItems(selectedItems.filter((id) => id !== item.id));
}
}}
>
{item.name}
</DropdownMenuCheckableItem>
))}
</>
);
};
export const CheckableMenuItem: Story = {
render: getRenderWrapperForComponent(
<FakeBelowContainer>
<FakeContentBelow />
<MenuAbsolutePositionWrapper>
<DropdownMenu>
<DropdownMenuItemContainer>
<FakeCheckableMenuItemList />
</DropdownMenuItemContainer>
</DropdownMenu>
</MenuAbsolutePositionWrapper>
</FakeBelowContainer>,
),
};
const FakeCheckableMenuItemWithAvatarList = () => {
const [selectedItems, setSelectedItems] = useState<string[]>([]);
return (
<>
{mockSelectArray.map((item) => (
<DropdownMenuCheckableItem
key={item.id}
id={item.id}
checked={selectedItems.includes(item.id)}
onChange={(checked) => {
if (checked) {
setSelectedItems([...selectedItems, item.id]);
} else {
setSelectedItems(selectedItems.filter((id) => id !== item.id));
}
}}
>
<Avatar
placeholderLetter="A"
avatarUrl={item.avatarUrl}
size={16}
type="squared"
/>
{item.name}
</DropdownMenuCheckableItem>
))}
</>
);
};
export const CheckableMenuItemWithAvatar: Story = {
render: getRenderWrapperForComponent(
<FakeBelowContainer>
<FakeContentBelow />
<MenuAbsolutePositionWrapper>
<DropdownMenu>
<DropdownMenuItemContainer>
<FakeCheckableMenuItemWithAvatarList />
</DropdownMenuItemContainer>
</DropdownMenu>
</MenuAbsolutePositionWrapper>
</FakeBelowContainer>,
),
};

View File

@ -3,3 +3,4 @@ export { IconComment } from './components/IconComment';
export { IconSidebarLeftCollapse } from './components/IconSidebarLeftCollapse';
export { IconSidebarRightCollapse } from './components/IconSidebarRightCollapse';
export { IconAward } from '@tabler/icons-react';
export { IconCheck } from '@tabler/icons-react';

View File

@ -49,6 +49,7 @@ const lightThemeSpecific = {
primaryBorder: 'rgba(0, 0, 0, 0.08)',
lightBorder: '#f5f5f5',
mediumBorder: '#ebebeb',
clickableElementBackgroundHover: 'rgba(0, 0, 0, 0.04)',
clickableElementBackgroundTransition: 'background 0.1s ease',
@ -72,6 +73,8 @@ const lightThemeSpecific = {
blueLowTransparency: 'rgba(25, 97, 237, 0.32)',
boxShadow: '0px 2px 4px 0px #0F0F0F0A',
modalBoxShadow: '0px 3px 12px rgba(0, 0, 0, 0.09)',
};
const darkThemeSpecific: typeof lightThemeSpecific = {
@ -94,6 +97,7 @@ const darkThemeSpecific: typeof lightThemeSpecific = {
primaryBorder: 'rgba(255, 255, 255, 0.08)',
lightBorder: '#222222',
mediumBorder: '#141414',
text100: '#ffffff',
text80: '#cccccc',
@ -113,13 +117,14 @@ const darkThemeSpecific: typeof lightThemeSpecific = {
blueHighTransparency: 'rgba(104, 149, 236, 0.03)',
blueLowTransparency: 'rgba(104, 149, 236, 0.32)',
boxShadow: '0px 2px 4px 0px #0F0F0F0A', // TODO change color for dark theme
modalBoxShadow: '0px 3px 12px rgba(0, 0, 0, 0.09)', // TODO change color for dark theme
};
export const overlayBackground = (props: any) =>
css`
background: ${props.theme.secondaryBackgroundTransparent};
backdrop-filter: blur(8px);
box-shadow: 0px 3px 12px rgba(0, 0, 0, 0.09);
box-shadow: ${props.theme.modalBoxShadow};
`;
export const textInputStyle = (props: any) =>
@ -137,6 +142,14 @@ export const textInputStyle = (props: any) =>
}
`;
export const hoverBackground = (props: any) =>
css`
transition: background 0.1s ease;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
`;
export const lightTheme = { ...commonTheme, ...lightThemeSpecific };
export const darkTheme = { ...commonTheme, ...darkThemeSpecific };

View File

@ -6,12 +6,13 @@ type OwnProps = {
avatarUrl: string | null | undefined;
size: number;
placeholderLetter: string;
type?: 'squared' | 'rounded';
};
export const StyledUserAvatar = styled.div<Omit<OwnProps, 'placeholderLetter'>>`
export const StyledAvatar = styled.div<Omit<OwnProps, 'placeholderLetter'>>`
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border-radius: ${(props) => (props.type === 'rounded' ? '50%' : '2px')};
background-image: url(${(props) =>
isNonEmptyString(props.avatarUrl) ? props.avatarUrl : 'none'});
background-color: ${(props) =>
@ -46,16 +47,21 @@ export const StyledPlaceholderLetter = styled.div<StyledPlaceholderLetterProps>`
color: ${(props) => props.theme.text80};
`;
export function UserAvatar({ avatarUrl, size, placeholderLetter }: OwnProps) {
export function Avatar({
avatarUrl,
size,
placeholderLetter,
type = 'squared',
}: OwnProps) {
const noAvatarUrl = !isNonEmptyString(avatarUrl);
return (
<StyledUserAvatar avatarUrl={avatarUrl} size={size}>
<StyledAvatar avatarUrl={avatarUrl} size={size} type={type}>
{noAvatarUrl && (
<StyledPlaceholderLetter size={size}>
{placeholderLetter}
</StyledPlaceholderLetter>
)}
</StyledUserAvatar>
</StyledAvatar>
);
}

View File

@ -2,39 +2,49 @@ import type { Meta, StoryObj } from '@storybook/react';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { UserAvatar } from '../UserAvatar';
import { Avatar } from '../Avatar';
const meta: Meta<typeof UserAvatar> = {
title: 'Users/UserAvatar',
component: UserAvatar,
const meta: Meta<typeof Avatar> = {
title: 'Components/Common/Avatar',
component: Avatar,
};
export default meta;
type Story = StoryObj<typeof UserAvatar>;
type Story = StoryObj<typeof Avatar>;
const avatarUrl =
'https://s3-alpha-sig.figma.com/img/bbb5/4905/f0a52cc2b9aaeb0a82a360d478dae8bf?Expires=1687132800&Signature=iVBr0BADa3LHoFVGbwqO-wxC51n1o~ZyFD-w7nyTyFP4yB-Y6zFawL-igewaFf6PrlumCyMJThDLAAc-s-Cu35SBL8BjzLQ6HymzCXbrblUADMB208PnMAvc1EEUDq8TyryFjRO~GggLBk5yR0EXzZ3zenqnDEGEoQZR~TRqS~uDF-GwQB3eX~VdnuiU2iittWJkajIDmZtpN3yWtl4H630A3opQvBnVHZjXAL5YPkdh87-a-H~6FusWvvfJxfNC2ZzbrARzXofo8dUFtH7zUXGCC~eUk~hIuLbLuz024lFQOjiWq2VKyB7dQQuGFpM-OZQEV8tSfkViP8uzDLTaCg__&Key-Pair-Id=APKAQ4GOSFWCVNEHN3O4';
export const Size40: Story = {
export const Rounded: Story = {
render: getRenderWrapperForComponent(
<UserAvatar avatarUrl={avatarUrl} size={40} placeholderLetter="L" />,
<Avatar
avatarUrl={avatarUrl}
size={16}
placeholderLetter="L"
type="rounded"
/>,
),
};
export const Size32: Story = {
export const Squared: Story = {
render: getRenderWrapperForComponent(
<UserAvatar avatarUrl={avatarUrl} size={32} placeholderLetter="L" />,
<Avatar
avatarUrl={avatarUrl}
size={16}
placeholderLetter="L"
type="squared"
/>,
),
};
export const Size16: Story = {
export const NoAvatarPictureRounded: Story = {
render: getRenderWrapperForComponent(
<UserAvatar avatarUrl={avatarUrl} size={16} placeholderLetter="L" />,
<Avatar avatarUrl={''} size={16} placeholderLetter="L" type="rounded" />,
),
};
export const NoAvatarPicture: Story = {
export const NoAvatarPictureSquared: Story = {
render: getRenderWrapperForComponent(
<UserAvatar avatarUrl={''} size={16} placeholderLetter="L" />,
<Avatar avatarUrl={''} size={16} placeholderLetter="L" type="squared" />,
),
};