docs: allow custom props in catalog decorator (#916)

Relates to #702
This commit is contained in:
Thaïs
2023-07-25 20:02:13 +02:00
committed by GitHub
parent a2ccb643ff
commit a5ca913158
6 changed files with 296 additions and 340 deletions

View File

@ -4,17 +4,25 @@ import styled from '@emotion/styled';
import { SoonPill } from '@/ui/pill/components/SoonPill';
import { rgba } from '@/ui/themes/colors';
export type ButtonVariant =
| 'primary'
| 'secondary'
| 'tertiary'
| 'tertiaryBold'
| 'tertiaryLight'
| 'danger';
export enum ButtonSize {
Medium = 'medium',
Small = 'small',
}
export type ButtonSize = 'medium' | 'small';
export enum ButtonPosition {
Left = 'left',
Middle = 'middle',
Right = 'right',
}
export type ButtonPosition = 'left' | 'middle' | 'right' | undefined;
export enum ButtonVariant {
Primary = 'primary',
Secondary = 'secondary',
Tertiary = 'tertiary',
TertiaryBold = 'tertiaryBold',
TertiaryLight = 'tertiaryLight',
Danger = 'danger',
}
export type ButtonProps = {
icon?: React.ReactNode;
@ -24,6 +32,7 @@ export type ButtonProps = {
size?: ButtonSize;
position?: ButtonPosition;
soon?: boolean;
disabled?: boolean;
} & React.ComponentProps<'button'>;
const StyledButton = styled.button<
@ -172,8 +181,8 @@ export function Button({
icon,
title,
fullWidth = false,
variant = 'primary',
size = 'medium',
variant = ButtonVariant.Primary,
size = ButtonSize.Medium,
position,
soon = false,
disabled = false,

View File

@ -1,145 +1,30 @@
import styled from '@emotion/styled';
import { expect, jest } from '@storybook/jest';
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { IconSearch } from '@/ui/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { Button } from '../Button';
import { ButtonGroup } from '../ButtonGroup';
type ButtonProps = React.ComponentProps<typeof Button>;
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
padding: 20px;
width: 800px;
> * + * {
margin-top: ${({ theme }) => theme.spacing(4)};
}
`;
const StyledTitle = styled.h1`
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(2)};
margin-top: ${({ theme }) => theme.spacing(3)};
`;
const StyledDescription = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
text-align: center;
text-transform: uppercase;
`;
const StyledLine = styled.div`
display: flex;
flex: 1;
flex-direction: row;
justify-content: space-between;
width: 100%;
`;
const StyledButtonContainer = styled.div`
border: 1px solid ${({ theme }) => theme.color.gray20};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(2)};
`;
const variants: ButtonProps['variant'][] = [
'primary',
'secondary',
'tertiary',
'tertiaryBold',
'tertiaryLight',
'danger',
];
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`,
}),
},
};
import { Button, ButtonPosition, ButtonSize, ButtonVariant } from '../Button';
const meta: Meta<typeof Button> = {
title: 'UI/Button/Button',
component: Button,
decorators: [
(Story) => (
<StyledContainer>
<Story />
</StyledContainer>
),
],
parameters: {
pseudo: Object.keys(states).reduce(
(acc, state) => ({
...acc,
[state]: variants.map(
(variant) =>
variant &&
['#left', '#center', '#right'].map(
(pos) => `${pos}-${variant}-button-${state}`,
),
),
}),
{},
),
argTypes: {
icon: {
type: 'boolean',
mapping: {
true: <IconSearch size={14} />,
false: undefined,
},
},
position: {
control: 'radio',
options: [undefined, ...Object.values(ButtonPosition)],
},
},
argTypes: { icon: { control: false }, variant: { control: false } },
args: { title: 'A button title' },
args: { title: 'Lorem ipsum' },
};
export default meta;
@ -147,34 +32,12 @@ type Story = StoryObj<typeof Button>;
const clickJestFn = jest.fn();
export const MediumSize: Story = {
args: { size: 'medium' },
render: (args) => (
<>
{variants.map((variant) => (
<div key={variant}>
<StyledTitle>{variant}</StyledTitle>
<StyledLine>
{Object.entries(states).map(
([state, { description, extraProps }]) => (
<StyledButtonContainer key={`${variant}-container-${state}`}>
<StyledDescription>{description}</StyledDescription>
<Button
{...args}
{...extraProps(variant ?? '')}
variant={variant}
/>
</StyledButtonContainer>
),
)}
</StyledLine>
</div>
))}
</>
),
export const Default: Story = {
args: { onClick: clickJestFn },
decorators: [ComponentDecorator],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByTestId('primary-button-default');
const button = canvas.getByRole('button');
const numberOfClicks = clickJestFn.mock.calls.length;
await userEvent.click(button);
@ -182,54 +45,78 @@ export const MediumSize: Story = {
},
};
export const SmallSize: Story = {
...MediumSize,
args: { size: 'small' },
};
export const MediumSizeGroup: Story = {
args: { size: 'medium' },
render: (args) => (
<>
{variants.map((variant) => (
<div key={variant}>
<StyledTitle>{variant}</StyledTitle>
<StyledLine>
{Object.entries(states).map(
([state, { description, extraProps }]) => (
<StyledButtonContainer
key={`${variant}-group-container-${state}`}
>
<StyledDescription>{description}</StyledDescription>
<ButtonGroup>
{['Left', 'Center', 'Right'].map((position) => (
<Button
{...args}
{...extraProps(`${variant}-${position.toLowerCase()}`)}
variant={variant}
title={position}
/>
))}
</ButtonGroup>
</StyledButtonContainer>
),
)}
</StyledLine>
</div>
))}
</>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByTestId('primary-left-button-default');
const numberOfClicks = clickJestFn.mock.calls.length;
await userEvent.click(button);
expect(clickJestFn).toHaveBeenCalledTimes(numberOfClicks + 1);
export const Sizes: Story = {
argTypes: {
size: { control: false },
},
parameters: {
catalog: [
{
name: 'sizes',
values: Object.values(ButtonSize),
props: (size: ButtonSize) => ({ size }),
},
],
},
decorators: [CatalogDecorator],
};
export const SmallSizeGroup: Story = {
...MediumSizeGroup,
args: { size: 'small' },
export const Variants: Story = {
argTypes: {
disabled: { control: false },
variant: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.active'], focus: ['.focus'] },
catalog: [
{
name: 'state',
values: ['default', 'disabled', 'hover', 'active', 'focus'],
props: (state: string) => {
if (state === 'disabled') return { disabled: true };
if (state === 'default') return {};
return { className: state };
},
},
{
name: 'variants',
values: Object.values(ButtonVariant),
props: (variant: ButtonVariant) => ({ variant }),
},
],
},
decorators: [CatalogDecorator],
};
export const Positions: Story = {
argTypes: {
position: { control: false },
},
parameters: {
catalog: [
{
name: 'positions',
values: ['none', ...Object.values(ButtonPosition)],
props: (position: ButtonPosition | 'none') =>
position === 'none' ? {} : { position },
},
],
},
decorators: [CatalogDecorator],
};
export const WithAdornments: Story = {
parameters: {
catalog: [
{
name: 'adornments',
values: ['with icon', 'with soon pill'],
props: (value: string) =>
value === 'with icon'
? { icon: <IconSearch size={14} /> }
: { soon: true },
},
],
},
decorators: [CatalogDecorator],
};

View File

@ -0,0 +1,36 @@
import { expect, jest } from '@storybook/jest';
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { Button, ButtonPosition } from '../Button';
import { ButtonGroup } from '../ButtonGroup';
const clickJestFn = jest.fn();
const meta: Meta<typeof ButtonGroup> = {
title: 'UI/Button/ButtonGroup',
component: ButtonGroup,
decorators: [ComponentDecorator],
argTypes: { children: { control: false } },
args: {
children: Object.values(ButtonPosition).map((position) => (
<Button title={position} onClick={clickJestFn} />
)),
},
};
export default meta;
type Story = StoryObj<typeof ButtonGroup>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const leftButton = canvas.getByRole('button', { name: 'left' });
const numberOfClicks = clickJestFn.mock.calls.length;
await userEvent.click(leftButton);
expect(clickJestFn).toHaveBeenCalledTimes(numberOfClicks + 1);
},
};

View File

@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { ExhaustiveComponentDecorator } from '~/testing/decorators/ExhaustiveComponentDecorator';
import { Chip, ChipAccent, ChipSize, ChipVariant } from '../Chip';
@ -27,7 +27,7 @@ export const Default: Story = {
};
export const Catalog: Story = {
args: { size: ChipSize.Large, clickable: true, label: 'Hello' },
args: { clickable: true, label: 'Hello' },
argTypes: {
size: { control: false },
variant: { control: false },
@ -38,14 +38,29 @@ export const Catalog: Story = {
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.active'] },
variants: [
ChipVariant.Highlighted,
ChipVariant.Regular,
ChipVariant.Transparent,
catalog: [
{
name: 'variants',
values: Object.values(ChipVariant),
props: (variant: ChipVariant) => ({ variant }),
},
{
name: 'sizes',
values: Object.values(ChipSize),
props: (size: ChipSize) => ({ size }),
},
{
name: 'accents',
values: Object.values(ChipAccent),
props: (accent: ChipAccent) => ({ accent }),
},
{
name: 'states',
values: ['default', 'hover', 'active', 'disabled'],
props: (state: string) =>
state === 'default' ? {} : { className: state },
},
],
sizes: [ChipSize.Small, ChipSize.Large],
accents: [ChipAccent.TextPrimary, ChipAccent.TextSecondary],
states: ['default', 'hover', 'active', 'disabled'],
},
decorators: [ExhaustiveComponentDecorator],
decorators: [CatalogDecorator],
};

View File

@ -0,0 +1,124 @@
import styled from '@emotion/styled';
import { Decorator } from '@storybook/react';
const ColumnTitle = styled.h1`
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin: ${({ theme }) => theme.spacing(2)};
`;
const RowsTitle = styled.h2`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin: ${({ theme }) => theme.spacing(2)};
width: 100px;
`;
const RowTitle = styled.h3`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin: ${({ theme }) => theme.spacing(2)};
width: 100px;
`;
export const ElementTitle = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
text-align: center;
text-transform: uppercase;
`;
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
`;
const ColumnContainer = styled.div`
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(2)};
`;
const RowsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
const RowContainer = styled.div`
display: flex;
flex: 1;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
`;
export const ElementContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(2)};
`;
const emptyVariable = {
name: '',
values: [undefined],
props: () => ({}),
};
export const CatalogDecorator: Decorator = (Story, context) => {
const { catalog } = context.parameters;
const [
variable1,
variable2 = emptyVariable,
variable3 = emptyVariable,
variable4 = emptyVariable,
] = catalog;
return (
<StyledContainer>
{variable4.values.map((value4: string) => (
<ColumnContainer key={value4}>
{(variable4.labels?.(value4) || value4) && (
<ColumnTitle>{variable4.labels?.(value4) || value4}</ColumnTitle>
)}
{variable3.values.map((value3: string) => (
<RowsContainer key={value3}>
{(variable3.labels?.(value3) || value3) && (
<RowsTitle>{variable3.labels?.(value3) || value3}</RowsTitle>
)}
{variable2.values.map((value2: string) => (
<RowContainer key={value2}>
{(variable2.labels?.(value2) || value2) && (
<RowTitle>{variable2.labels?.(value2) || value2}</RowTitle>
)}
{variable1.values.map((value1: string) => (
<ElementContainer key={value1}>
{(variable1.labels?.(value1) || value1) && (
<ElementTitle>
{variable1.labels?.(value1) || value1}
</ElementTitle>
)}
<Story
args={{
...context.args,
...variable1.props(value1),
...variable2.props(value2),
...variable3.props(value3),
...variable4.props(value4),
}}
/>
</ElementContainer>
))}
</RowContainer>
))}
</RowsContainer>
))}
</ColumnContainer>
))}
</StyledContainer>
);
};

View File

@ -1,115 +0,0 @@
import styled from '@emotion/styled';
import { Decorator } from '@storybook/react';
function stateProps(state: string) {
switch (state) {
case 'default':
return {};
case 'hover':
return { className: 'hover' };
case 'active':
return { className: 'active' };
case 'disabled':
return { disabled: true };
default:
return {};
}
}
const StyledSizeTitle = styled.h1`
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin: ${({ theme }) => theme.spacing(2)};
`;
const StyledVariantTitle = styled.h2`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin: ${({ theme }) => theme.spacing(2)};
width: 100px;
`;
const StyledAccentTitle = styled.h3`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin: ${({ theme }) => theme.spacing(2)};
width: 100px;
`;
const StyledStateTitle = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
text-align: center;
text-transform: uppercase;
`;
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
`;
const StyledSizeContainer = styled.div`
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledVariantContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledAccentContainer = styled.div`
display: flex;
flex: 1;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledStateContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(2)};
`;
export const ExhaustiveComponentDecorator: Decorator = (Story, context) => {
const parameters = context.parameters;
return (
<StyledContainer>
{parameters.sizes.map((size: string) => (
<StyledSizeContainer key={size}>
<StyledSizeTitle>{size}</StyledSizeTitle>
{parameters.variants.map((variant: string) => (
<StyledVariantContainer key={variant}>
<StyledVariantTitle>{variant}</StyledVariantTitle>
{parameters.accents.map((accent: string) => (
<StyledAccentContainer key={accent}>
<StyledAccentTitle>{accent}</StyledAccentTitle>
{parameters.states.map((state: string) => (
<StyledStateContainer key={state}>
<StyledStateTitle>{state}</StyledStateTitle>
<Story
args={{
...context.args,
accent: accent,
variant: variant,
...stateProps(state),
}}
/>
</StyledStateContainer>
))}
</StyledAccentContainer>
))}
</StyledVariantContainer>
))}
</StyledSizeContainer>
))}
</StyledContainer>
);
};