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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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 { CommentableEntity } from '@/comments/types/CommentableEntity';
import { CommentThreadForDrawer } from '@/comments/types/CommentThreadForDrawer';
import { TableActionBarButtonToggleComments } from '@/ui/components/table/action-bar/TableActionBarButtonOpenComments';
import { IconCirclePlus, IconNotes } from '@/ui/icons/index';
import {
beautifyExactDate,
@ -17,6 +16,8 @@ import {
useGetCommentThreadsByTargetsQuery,
} from '~/generated/graphql';
import { CommentThreadCreateButton } from '../comment-thread/CommentThreadCreateButton';
const StyledMainContainer = styled.div`
align-items: flex-start;
align-self: stretch;
@ -208,8 +209,8 @@ export function Timeline({ entity }: { entity: CommentableEntity }) {
<StyledTimelineEmptyContainer>
<StyledEmptyTimelineTitle>No activity yet</StyledEmptyTimelineTitle>
<StyledEmptyTimelineSubTitle>Create one:</StyledEmptyTimelineSubTitle>
<TableActionBarButtonToggleComments
onClick={() => openCreateCommandThread(entity)}
<CommentThreadCreateButton
onNoteClick={() => openCreateCommandThread(entity)}
/>
</StyledTimelineEmptyContainer>
);
@ -223,8 +224,8 @@ export function Timeline({ entity }: { entity: CommentableEntity }) {
<IconCirclePlus />
</StyledIconContainer>
<TableActionBarButtonToggleComments
onClick={() => openCreateCommandThread(entity)}
<CommentThreadCreateButton
onNoteClick={() => openCreateCommandThread(entity)}
/>
</StyledTimelineItemContainer>
</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';
type Variant =
import { SoonPill } from '../accessories/SoonPill';
export type ButtonVariant =
| 'primary'
| 'secondary'
| 'tertiary'
@ -11,18 +13,22 @@ type Variant =
| 'tertiaryLight'
| '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;
title?: string;
fullWidth?: boolean;
variant?: Variant;
size?: Size;
variant?: ButtonVariant;
size?: ButtonSize;
position?: ButtonPosition;
soon?: boolean;
} & React.ComponentProps<'button'>;
const StyledButton = styled.button<
Pick<Props, 'fullWidth' | 'variant' | 'size' | 'title'>
Pick<ButtonProps, 'fullWidth' | 'variant' | 'size' | 'position' | 'title'>
>`
align-items: center;
background: ${({ theme, variant, disabled }) => {
@ -49,7 +55,18 @@ const StyledButton = styled.button<
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 }) => {
switch (variant) {
case 'primary':
@ -59,6 +76,7 @@ const StyledButton = styled.button<
return 'none';
}
}};
color: ${({ theme, variant, disabled }) => {
if (disabled) {
if (variant === 'primary') {
@ -105,6 +123,8 @@ const StyledButton = styled.button<
transition: background 0.1s ease;
white-space: nowrap;
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
&:hover,
@ -144,18 +164,24 @@ export function Button({
fullWidth = false,
variant = 'primary',
size = 'medium',
position,
soon = false,
disabled = false,
...props
}: Props) {
}: ButtonProps) {
return (
<StyledButton
fullWidth={fullWidth}
variant={variant}
size={size}
position={position}
disabled={soon || disabled}
title={title}
{...props}
>
{icon}
{title}
{soon && <SoonPill />}
</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;
fullWidth?: boolean;
variant?: Variant;
soon?: boolean;
} & React.ComponentProps<'button'>;
const StyledButton = styled.button<Pick<Props, 'fullWidth' | 'variant'>>`

View File

@ -1,3 +1,4 @@
import React from 'react';
import styled from '@emotion/styled';
import { text, withKnobs } from '@storybook/addon-knobs';
import { expect, jest } from '@storybook/jest';
@ -8,6 +9,7 @@ import { IconSearch } from '@/ui/icons';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { Button } from '../Button';
import { ButtonGroup } from '../ButtonGroup';
type ButtonProps = React.ComponentProps<typeof Button>;
@ -62,8 +64,6 @@ const meta: Meta<typeof Button> = {
export default meta;
type Story = StoryObj<typeof Button>;
const clickJestFn = jest.fn();
const variants: ButtonProps['variant'][] = [
'primary',
'secondary',
@ -73,148 +73,157 @@ const variants: ButtonProps['variant'][] = [
'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>
<StyledDescription>With icon</StyledDescription>
<Button
data-testid={`${props.variant}-button-with-icon`}
{...props}
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>
{Object.entries(states).map(([state, { description, extraProps }]) => (
<StyledButtonContainer key={`${variant}-container-${state}`}>
<StyledDescription>{description}</StyledDescription>
<Button {...props} {...extraProps(variant ?? '')} variant={variant} />
</StyledButtonContainer>
))}
</>
);
const ButtonContainer = (props: Partial<ButtonProps>) => {
const title = text('Text', 'A button title');
const ButtonGroupLine: React.FC<ButtonProps> = ({ variant, ...props }) => (
<>
{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>
{variants.map((variant) => (
<div key={variant}>
<StyledTitle>{variant}</StyledTitle>
<StyledLine>
<ButtonLine {...props} title={title} variant={variant} />
<LineComponent
size={size}
variant={variant}
title={text('Text', 'A button title')}
/>
</StyledLine>
</div>
))}
</StyledContainer>
);
};
// Medium size
export const MediumSize: Story = {
render: getRenderWrapperForComponent(<ButtonContainer />),
</StyledContainer>,
),
play: async ({ 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 button = canvas.getByTestId('primary-button-default');
const numberOfClicks = clickJestFn.mock.calls.length;
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);
},
};
MediumSize.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',
],
},
};
// 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',
],
},
};
export const MediumSize = generateStory('medium', 'button', ButtonLine);
export const SmallSize = generateStory('small', 'button', ButtonLine);
export const MediumSizeGroup = generateStory(
'medium',
'group',
ButtonGroupLine,
);
export const SmallSizeGroup = generateStory('small', 'group', ButtonGroupLine);

View File

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

View File

@ -33,3 +33,5 @@ export { IconFileUpload } from '@tabler/icons-react';
export { IconChevronsRight } from '@tabler/icons-react';
export { IconNotes } 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`
display: flex;
justify-content: center;
align-items: center;
border-radius: 50px;
background-color: ${({ theme }) => theme.background.transparent.light};
border-radius: 50px;
display: flex;
font-size: ${({ theme }) => theme.font.size.xs};
height: 16px;
justify-content: center;
margin-left: auto;
padding-left: ${({ 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) {

View File

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