Add ButtonGroup concept + Soon pill on button + implement in timeline (#551)

* Add ButtonGroup concept

* Add soon pill

* Fix incorrect wrapping behavior

* Implement button group in timeline
This commit is contained in:
Félix Malfait
2023-07-10 14:06:35 +02:00
committed by GitHub
parent c529c49ea6
commit a2da3a5f09
11 changed files with 292 additions and 148 deletions

View File

@ -0,0 +1,40 @@
import { useTheme } from '@emotion/react';
import { Button } from '@/ui/components/buttons/Button';
import { ButtonGroup } from '@/ui/components/buttons/ButtonGroup';
import { IconCheckbox, IconNotes, IconTimelineEvent } from '@/ui/icons/index';
type CommentThreadCreateButtonProps = {
onNoteClick?: () => void;
onTaskClick?: () => void;
onActivityClick?: () => void;
};
export function CommentThreadCreateButton({
onNoteClick,
onTaskClick,
onActivityClick,
}: CommentThreadCreateButtonProps) {
const theme = useTheme();
return (
<ButtonGroup variant="secondary">
<Button
icon={<IconNotes size={theme.icon.size.sm} />}
title="Note"
onClick={onNoteClick}
/>
<Button
icon={<IconCheckbox size={theme.icon.size.sm} />}
title="Task"
soon={true}
onClick={onTaskClick}
/>
<Button
icon={<IconTimelineEvent size={theme.icon.size.sm} />}
title="Activity"
soon={true}
onClick={onActivityClick}
/>
</ButtonGroup>
);
}

View File

@ -6,7 +6,6 @@ import { useOpenCommentThreadRightDrawer } from '@/comments/hooks/useOpenComment
import { useOpenCreateCommentThreadDrawer } from '@/comments/hooks/useOpenCreateCommentThreadDrawer'; import { useOpenCreateCommentThreadDrawer } from '@/comments/hooks/useOpenCreateCommentThreadDrawer';
import { CommentableEntity } from '@/comments/types/CommentableEntity'; import { CommentableEntity } from '@/comments/types/CommentableEntity';
import { CommentThreadForDrawer } from '@/comments/types/CommentThreadForDrawer'; import { CommentThreadForDrawer } from '@/comments/types/CommentThreadForDrawer';
import { TableActionBarButtonToggleComments } from '@/ui/components/table/action-bar/TableActionBarButtonOpenComments';
import { IconCirclePlus, IconNotes } from '@/ui/icons/index'; import { IconCirclePlus, IconNotes } from '@/ui/icons/index';
import { import {
beautifyExactDate, beautifyExactDate,
@ -17,6 +16,8 @@ import {
useGetCommentThreadsByTargetsQuery, useGetCommentThreadsByTargetsQuery,
} from '~/generated/graphql'; } from '~/generated/graphql';
import { CommentThreadCreateButton } from '../comment-thread/CommentThreadCreateButton';
const StyledMainContainer = styled.div` const StyledMainContainer = styled.div`
align-items: flex-start; align-items: flex-start;
align-self: stretch; align-self: stretch;
@ -208,8 +209,8 @@ export function Timeline({ entity }: { entity: CommentableEntity }) {
<StyledTimelineEmptyContainer> <StyledTimelineEmptyContainer>
<StyledEmptyTimelineTitle>No activity yet</StyledEmptyTimelineTitle> <StyledEmptyTimelineTitle>No activity yet</StyledEmptyTimelineTitle>
<StyledEmptyTimelineSubTitle>Create one:</StyledEmptyTimelineSubTitle> <StyledEmptyTimelineSubTitle>Create one:</StyledEmptyTimelineSubTitle>
<TableActionBarButtonToggleComments <CommentThreadCreateButton
onClick={() => openCreateCommandThread(entity)} onNoteClick={() => openCreateCommandThread(entity)}
/> />
</StyledTimelineEmptyContainer> </StyledTimelineEmptyContainer>
); );
@ -223,8 +224,8 @@ export function Timeline({ entity }: { entity: CommentableEntity }) {
<IconCirclePlus /> <IconCirclePlus />
</StyledIconContainer> </StyledIconContainer>
<TableActionBarButtonToggleComments <CommentThreadCreateButton
onClick={() => openCreateCommandThread(entity)} onNoteClick={() => openCreateCommandThread(entity)}
/> />
</StyledTimelineItemContainer> </StyledTimelineItemContainer>
</StyledTopActionBar> </StyledTopActionBar>

View File

@ -0,0 +1,22 @@
import styled from '@emotion/styled';
const StyledSoonPill = styled.span`
align-items: center;
background: ${({ theme }) => theme.background.transparent.light};
border-radius: 50px;
color: ${({ theme }) => theme.font.color.light};
display: flex;
font-size: ${({ theme }) => theme.font.size.xs};
font-style: normal;
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(2)};
height: ${({ theme }) => theme.spacing(4)};
justify-content: flex-end;
line-height: ${({ theme }) => theme.text.lineHeight.lg};
margin-left: auto;
padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)};
`;
export function SoonPill() {
return <StyledSoonPill>Soon</StyledSoonPill>;
}

View File

@ -3,7 +3,9 @@ import styled from '@emotion/styled';
import { rgba } from '@/ui/themes/colors'; import { rgba } from '@/ui/themes/colors';
type Variant = import { SoonPill } from '../accessories/SoonPill';
export type ButtonVariant =
| 'primary' | 'primary'
| 'secondary' | 'secondary'
| 'tertiary' | 'tertiary'
@ -11,18 +13,22 @@ type Variant =
| 'tertiaryLight' | 'tertiaryLight'
| 'danger'; | 'danger';
type Size = 'medium' | 'small'; export type ButtonSize = 'medium' | 'small';
type Props = { export type ButtonPosition = 'left' | 'middle' | 'right' | undefined;
export type ButtonProps = {
icon?: React.ReactNode; icon?: React.ReactNode;
title?: string; title?: string;
fullWidth?: boolean; fullWidth?: boolean;
variant?: Variant; variant?: ButtonVariant;
size?: Size; size?: ButtonSize;
position?: ButtonPosition;
soon?: boolean;
} & React.ComponentProps<'button'>; } & React.ComponentProps<'button'>;
const StyledButton = styled.button< const StyledButton = styled.button<
Pick<Props, 'fullWidth' | 'variant' | 'size' | 'title'> Pick<ButtonProps, 'fullWidth' | 'variant' | 'size' | 'position' | 'title'>
>` >`
align-items: center; align-items: center;
background: ${({ theme, variant, disabled }) => { background: ${({ theme, variant, disabled }) => {
@ -49,7 +55,18 @@ const StyledButton = styled.button<
return 'none'; return 'none';
} }
}}; }};
border-radius: 4px; border-radius: ${({ position }) => {
switch (position) {
case 'left':
return '4px 0px 0px 4px';
case 'right':
return '0px 4px 4px 0px';
case 'middle':
return '0px';
default:
return '4px';
}
}};
box-shadow: ${({ theme, variant }) => { box-shadow: ${({ theme, variant }) => {
switch (variant) { switch (variant) {
case 'primary': case 'primary':
@ -59,6 +76,7 @@ const StyledButton = styled.button<
return 'none'; return 'none';
} }
}}; }};
color: ${({ theme, variant, disabled }) => { color: ${({ theme, variant, disabled }) => {
if (disabled) { if (disabled) {
if (variant === 'primary') { if (variant === 'primary') {
@ -105,6 +123,8 @@ const StyledButton = styled.button<
transition: background 0.1s ease; transition: background 0.1s ease;
white-space: nowrap;
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')}; width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
&:hover, &:hover,
@ -144,18 +164,24 @@ export function Button({
fullWidth = false, fullWidth = false,
variant = 'primary', variant = 'primary',
size = 'medium', size = 'medium',
position,
soon = false,
disabled = false,
...props ...props
}: Props) { }: ButtonProps) {
return ( return (
<StyledButton <StyledButton
fullWidth={fullWidth} fullWidth={fullWidth}
variant={variant} variant={variant}
size={size} size={size}
position={position}
disabled={soon || disabled}
title={title} title={title}
{...props} {...props}
> >
{icon} {icon}
{title} {title}
{soon && <SoonPill />}
</StyledButton> </StyledButton>
); );
} }

View File

@ -0,0 +1,43 @@
import React from 'react';
import styled from '@emotion/styled';
import { ButtonPosition, ButtonProps } from './Button';
const StyledButtonGroupContainer = styled.div`
border-radius: 8px;
display: flex;
`;
type ButtonGroupProps = Pick<ButtonProps, 'variant' | 'size'> & {
children: React.ReactElement[];
};
export function ButtonGroup({ children, variant, size }: ButtonGroupProps) {
return (
<StyledButtonGroupContainer>
{React.Children.map(children, (child, index) => {
let position: ButtonPosition;
if (index === 0) {
position = 'left';
} else if (index === children.length - 1) {
position = 'right';
} else {
position = 'middle';
}
const additionalProps: any = { position };
if (variant) {
additionalProps.variant = variant;
}
if (size) {
additionalProps.size = size;
}
return React.cloneElement(child, additionalProps);
})}
</StyledButtonGroupContainer>
);
}

View File

@ -8,6 +8,7 @@ type Props = {
title: string; title: string;
fullWidth?: boolean; fullWidth?: boolean;
variant?: Variant; variant?: Variant;
soon?: boolean;
} & React.ComponentProps<'button'>; } & React.ComponentProps<'button'>;
const StyledButton = styled.button<Pick<Props, 'fullWidth' | 'variant'>>` const StyledButton = styled.button<Pick<Props, 'fullWidth' | 'variant'>>`

View File

@ -1,3 +1,4 @@
import React from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { text, withKnobs } from '@storybook/addon-knobs'; import { text, withKnobs } from '@storybook/addon-knobs';
import { expect, jest } from '@storybook/jest'; import { expect, jest } from '@storybook/jest';
@ -8,6 +9,7 @@ import { IconSearch } from '@/ui/icons';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers'; import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { Button } from '../Button'; import { Button } from '../Button';
import { ButtonGroup } from '../ButtonGroup';
type ButtonProps = React.ComponentProps<typeof Button>; type ButtonProps = React.ComponentProps<typeof Button>;
@ -62,8 +64,6 @@ const meta: Meta<typeof Button> = {
export default meta; export default meta;
type Story = StoryObj<typeof Button>; type Story = StoryObj<typeof Button>;
const clickJestFn = jest.fn();
const variants: ButtonProps['variant'][] = [ const variants: ButtonProps['variant'][] = [
'primary', 'primary',
'secondary', 'secondary',
@ -73,148 +73,157 @@ const variants: ButtonProps['variant'][] = [
'danger', 'danger',
]; ];
const ButtonLine = (props: ButtonProps) => ( const clickJestFn = jest.fn();
const states = {
'with-icon': {
description: 'With icon',
extraProps: (variant: string) => ({
'data-testid': `${variant}-button-with-icon`,
icon: <IconSearch size={14} />,
}),
},
default: {
description: 'Default',
extraProps: (variant: string) => ({
'data-testid': `${variant}-button-default`,
onClick: clickJestFn,
}),
},
hover: {
description: 'Hover',
extraProps: (variant: string) => ({
id: `${variant}-button-hover`,
'data-testid': `${variant}-button-hover`,
}),
},
pressed: {
description: 'Pressed',
extraProps: (variant: string) => ({
id: `${variant}-button-pressed`,
'data-testid': `${variant}-button-pressed`,
}),
},
disabled: {
description: 'Disabled',
extraProps: (variant: string) => ({
'data-testid': `${variant}-button-disabled`,
disabled: true,
}),
},
soon: {
description: 'Soon',
extraProps: (variant: string) => ({
'data-testid': `${variant}-button-soon`,
soon: true,
}),
},
focus: {
description: 'Focus',
extraProps: (variant: string) => ({
id: `${variant}-button-focus`,
'data-testid': `${variant}-button-focus`,
}),
},
};
const ButtonLine: React.FC<ButtonProps> = ({ variant, ...props }) => (
<> <>
<StyledButtonContainer> {Object.entries(states).map(([state, { description, extraProps }]) => (
<StyledDescription>With icon</StyledDescription> <StyledButtonContainer key={`${variant}-container-${state}`}>
<Button <StyledDescription>{description}</StyledDescription>
data-testid={`${props.variant}-button-with-icon`} <Button {...props} {...extraProps(variant ?? '')} variant={variant} />
{...props} </StyledButtonContainer>
icon={<IconSearch size={14} />} ))}
/>
</StyledButtonContainer>
<StyledButtonContainer>
<StyledDescription>Default</StyledDescription>
<Button
data-testid={`${props.variant}-button-default`}
onClick={clickJestFn}
{...props}
/>
</StyledButtonContainer>
<StyledButtonContainer>
<StyledDescription>Hover</StyledDescription>
<Button
id={`${props.variant}-button-hover`}
data-testid={`${props.variant}-button-hover`}
{...props}
/>
</StyledButtonContainer>
<StyledButtonContainer>
<StyledDescription>Pressed</StyledDescription>
<Button
id={`${props.variant}-button-pressed`}
data-testid={`${props.variant}-button-pressed`}
{...props}
/>
</StyledButtonContainer>
<StyledButtonContainer>
<StyledDescription>Disabled</StyledDescription>
<Button
data-testid={`${props.variant}-button-disabled`}
{...props}
disabled
/>
</StyledButtonContainer>
<StyledButtonContainer>
<StyledDescription>Focus</StyledDescription>
<Button
id={`${props.variant}-button-focus`}
data-testid={`${props.variant}-button-focus`}
{...props}
/>
</StyledButtonContainer>
</> </>
); );
const ButtonContainer = (props: Partial<ButtonProps>) => { const ButtonGroupLine: React.FC<ButtonProps> = ({ variant, ...props }) => (
const title = text('Text', 'A button title'); <>
{Object.entries(states).map(([state, { description, extraProps }]) => (
<StyledButtonContainer key={`${variant}-group-container-${state}`}>
<StyledDescription>{description}</StyledDescription>
<ButtonGroup>
<Button
{...props}
{...extraProps(`${variant}-left`)}
variant={variant}
title="Left"
/>
<Button
{...props}
{...extraProps(`${variant}-center`)}
variant={variant}
title="Center"
/>
<Button
{...props}
{...extraProps(`${variant}-right`)}
variant={variant}
title="Right"
/>
</ButtonGroup>
</StyledButtonContainer>
))}
</>
);
return ( const generateStory = (
size: ButtonProps['size'],
type: 'button' | 'group',
LineComponent: React.ComponentType<ButtonProps>,
): Story => ({
render: getRenderWrapperForComponent(
<StyledContainer> <StyledContainer>
{variants.map((variant) => ( {variants.map((variant) => (
<div key={variant}> <div key={variant}>
<StyledTitle>{variant}</StyledTitle> <StyledTitle>{variant}</StyledTitle>
<StyledLine> <StyledLine>
<ButtonLine {...props} title={title} variant={variant} /> <LineComponent
size={size}
variant={variant}
title={text('Text', 'A button title')}
/>
</StyledLine> </StyledLine>
</div> </div>
))} ))}
</StyledContainer> </StyledContainer>,
); ),
};
// Medium size
export const MediumSize: Story = {
render: getRenderWrapperForComponent(<ButtonContainer />),
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
let button;
if (type === 'group') {
button = canvas.getByTestId(`primary-left-button-default`);
} else {
button = canvas.getByTestId(`primary-button-default`);
}
expect(clickJestFn).toHaveBeenCalledTimes(0); const numberOfClicks = clickJestFn.mock.calls.length;
const button = canvas.getByTestId('primary-button-default');
await userEvent.click(button); await userEvent.click(button);
expect(clickJestFn).toHaveBeenCalledTimes(numberOfClicks + 1);
},
parameters: {
pseudo: Object.keys(states).reduce(
(acc, state) => ({
...acc,
[state]: variants.map(
(variant) =>
variant &&
['#left', '#center', '#right'].map(
(pos) => `${pos}-${variant}-${type}-${state}`,
),
),
}),
{},
),
},
});
expect(clickJestFn).toHaveBeenCalledTimes(1); export const MediumSize = generateStory('medium', 'button', ButtonLine);
}, export const SmallSize = generateStory('small', 'button', ButtonLine);
}; export const MediumSizeGroup = generateStory(
MediumSize.parameters = { 'medium',
pseudo: { 'group',
hover: [ ButtonGroupLine,
'#primary-button-hover', );
'#secondary-button-hover', export const SmallSizeGroup = generateStory('small', 'group', ButtonGroupLine);
'#tertiary-button-hover',
'#tertiaryBold-button-hover',
'#tertiaryLight-button-hover',
'#danger-button-hover',
],
active: [
'#primary-button-pressed',
'#secondary-button-pressed',
'#tertiary-button-pressed',
'#tertiaryBold-button-pressed',
'#tertiaryLight-button-pressed',
'#danger-button-pressed',
],
focus: [
'#primary-button-focus',
'#secondary-button-focus',
'#tertiary-button-focus',
'#tertiaryBold-button-focus',
'#tertiaryLight-button-focus',
'#danger-button-focus',
],
},
};
// Small size
export const SmallSize: Story = {
render: getRenderWrapperForComponent(<ButtonContainer size="small" />),
};
SmallSize.parameters = {
pseudo: {
hover: [
'#primary-button-hover',
'#secondary-button-hover',
'#tertiary-button-hover',
'#tertiaryBold-button-hover',
'#tertiaryLight-button-hover',
'#danger-button-hover',
],
active: [
'#primary-button-pressed',
'#secondary-button-pressed',
'#tertiary-button-pressed',
'#tertiaryBold-button-pressed',
'#tertiaryLight-button-pressed',
'#danger-button-pressed',
],
focus: [
'#primary-button-focus',
'#secondary-button-focus',
'#tertiary-button-focus',
'#tertiaryBold-button-focus',
'#tertiaryLight-button-focus',
'#danger-button-focus',
],
},
};

View File

@ -9,7 +9,7 @@ type OwnProps = {
export function TableActionBarButtonToggleComments({ onClick }: OwnProps) { export function TableActionBarButtonToggleComments({ onClick }: OwnProps) {
return ( return (
<EntityTableActionBarButton <EntityTableActionBarButton
label="Notes" label="Note"
icon={<IconNotes size={16} />} icon={<IconNotes size={16} />}
onClick={onClick} onClick={onClick}
/> />

View File

@ -33,3 +33,5 @@ export { IconFileUpload } from '@tabler/icons-react';
export { IconChevronsRight } from '@tabler/icons-react'; export { IconChevronsRight } from '@tabler/icons-react';
export { IconNotes } from '@tabler/icons-react'; export { IconNotes } from '@tabler/icons-react';
export { IconCirclePlus } from '@tabler/icons-react'; export { IconCirclePlus } from '@tabler/icons-react';
export { IconCheckbox } from '@tabler/icons-react';
export { IconTimelineEvent } from '@tabler/icons-react';

View File

@ -65,16 +65,16 @@ const StyledItemLabel = styled.div`
`; `;
const StyledSoonPill = styled.div` const StyledSoonPill = styled.div`
display: flex;
justify-content: center;
align-items: center; align-items: center;
border-radius: 50px;
background-color: ${({ theme }) => theme.background.transparent.light}; background-color: ${({ theme }) => theme.background.transparent.light};
border-radius: 50px;
display: flex;
font-size: ${({ theme }) => theme.font.size.xs}; font-size: ${({ theme }) => theme.font.size.xs};
height: 16px; height: 16px;
justify-content: center;
margin-left: auto;
padding-left: ${({ theme }) => theme.spacing(2)}; padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)}; padding-right: ${({ theme }) => theme.spacing(2)};
margin-left: auto; // this aligns the pill to the right
`; `;
function NavItem({ label, icon, to, onClick, active, danger, soon }: OwnProps) { function NavItem({ label, icon, to, onClick, active, danger, soon }: OwnProps) {

View File

@ -32,7 +32,7 @@ export const Default: Story = {
), ),
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const notesButton = await canvas.findByText('Notes'); const notesButton = await canvas.findByText('Note');
await notesButton.click(); await notesButton.click();
}, },
parameters: { parameters: {