Skip to content

Commit

Permalink
Merge pull request #23765 from storybookjs/charles-improve-buttons
Browse files Browse the repository at this point in the history
UI: Improve new `Button` component
  • Loading branch information
kasperpeulen committed Aug 9, 2023
2 parents 8ee6798 + c75ecd2 commit a4c6298
Show file tree
Hide file tree
Showing 6 changed files with 245 additions and 120 deletions.
2 changes: 1 addition & 1 deletion code/ui/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"@storybook/client-logger": "workspace:*",
"@storybook/csf": "^0.1.0",
"@storybook/global": "^5.0.0",
"@storybook/icons": "^1.1.5",
"@storybook/icons": "^1.1.6",
"@storybook/theming": "workspace:*",
"@storybook/types": "workspace:*",
"memoizerific": "^1.11.3",
Expand Down
100 changes: 37 additions & 63 deletions code/ui/components/src/new/Button/Button.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react';
import React from 'react';

import { Icon } from '@storybook/components/experimental';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
Expand All @@ -17,110 +15,86 @@ export const Base = {
args: { children: 'Button' },
};

export const Types: Story = {
export const Variants: Story = {
args: {
...Base.args,
},
render: () => (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="tertiary">Tertiary</Button>
<Button variant="solid">Solid</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
</div>
),
};

export const Active: Story = {
render: () => (
args: {
...Base.args,
},
render: (args) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Button variant="primary" active>
Primary
<Button variant="solid" active {...args}>
Solid
</Button>
<Button variant="secondary" active>
Secondary
<Button variant="outline" active {...args}>
Outline
</Button>
<Button variant="tertiary" active>
Tertiary
<Button variant="ghost" active {...args}>
Ghost
</Button>
</div>
),
};

export const WithIcon: Story = {
render: () => (
args: {
...Base.args,
icon: 'FaceHappy',
},
render: ({ icon, children }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Button variant="primary" icon={<Icon.FaceHappy />}>
Primary
<Button variant="solid" icon={icon}>
{children}
</Button>
<Button variant="secondary" icon={<Icon.FaceHappy />}>
Secondary
<Button variant="outline" icon={icon}>
{children}
</Button>
<Button variant="tertiary" icon={<Icon.FaceHappy />}>
Tertiary
<Button variant="ghost" icon={icon}>
{children}
</Button>
</div>
),
};

export const Sizes: Story = {
args: {
...Base.args,
},
render: () => (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Button size="small" icon={<Icon.FaceHappy />}>
<Button size="small" icon="FaceHappy">
Small Button
</Button>
<Button size="small" icon={<Icon.FaceHappy />} iconOnly />
<Button size="medium" icon={<Icon.FaceHappy />}>
<Button size="medium" icon="FaceHappy">
Medium Button
</Button>
<Button size="medium" icon={<Icon.FaceHappy />} iconOnly />
</div>
),
};

export const IconOnly: Story = {
parameters: {
docs: {
description: {
story: 'This is a story that shows how to use the `iconOnly` prop.',
},
source: {
type: 'dynamic',
},
},
},
render: () => (
<>
<Button size="small" variant="primary" iconOnly icon={<Icon.FaceHappy />} />
<Button size="small" variant="secondary" iconOnly icon={<Icon.FaceHappy />} />
<Button size="small" variant="tertiary" iconOnly icon={<Icon.FaceHappy />} />
<Button size="medium" variant="primary" iconOnly icon={<Icon.FaceHappy />} />
<Button size="medium" variant="secondary" iconOnly icon={<Icon.FaceHappy />} />
<Button size="medium" variant="tertiary" iconOnly icon={<Icon.FaceHappy />} />
</>
),
decorators: [
(Story) => <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>{Story()}</div>,
],
};

export const IconOnlyActive: Story = {
render: () => (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Button size="small" variant="primary" iconOnly icon={<Icon.FaceHappy />} active />
<Button size="small" variant="secondary" iconOnly icon={<Icon.FaceHappy />} active />
<Button size="small" variant="tertiary" iconOnly icon={<Icon.FaceHappy />} active />
<Button size="medium" variant="primary" iconOnly icon={<Icon.FaceHappy />} active />
<Button size="medium" variant="secondary" iconOnly icon={<Icon.FaceHappy />} active />
<Button size="medium" variant="tertiary" iconOnly icon={<Icon.FaceHappy />} active />
</div>
),
};

export const Disabled: Story = {
args: {
...Base.args,
disabled: true,
children: 'Disabled Button',
},
};

export const WithHref: Story = {
args: {
...Base.args,
},
render: () => (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Button onClick={() => console.log('Hello')}>I am a button using onClick</Button>
Expand Down
80 changes: 29 additions & 51 deletions code/ui/components/src/new/Button/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,34 @@
import type { ReactNode } from 'react';
import React, { forwardRef } from 'react';
import { styled } from '@storybook/theming';
import { darken, lighten, rgba, transparentize } from 'polished';
import type { Icons } from '@storybook/icons';
import type { PropsOf } from '../utils/types';
import { Icon } from '../Icon/Icon';

interface CommonProps<T extends React.ElementType = React.ElementType> {
interface ButtonProps<T extends React.ElementType = React.ElementType> {
children: string;
as?: T;
size?: 'small' | 'medium';
variant?: 'primary' | 'secondary' | 'tertiary';
variant?: 'solid' | 'outline' | 'ghost';
onClick?: () => void;
disabled?: boolean;
active?: boolean;
icon?: Icons;
}

type ButtonIconOnlyProps = {
iconOnly: true;
icon: ReactNode;
children?: never;
};

type ButtonWithTextProps = {
iconOnly?: false;
icon?: ReactNode;
children: string;
};

type ButtonProps<T extends React.ElementType = React.ElementType> = CommonProps<T> &
(ButtonIconOnlyProps | ButtonWithTextProps);

export const Button: {
<E extends React.ElementType = 'button'>(
props: ButtonProps<E> & Omit<PropsOf<E>, keyof ButtonProps>
): JSX.Element;
displayName?: string;
} = forwardRef(
({ as, children, icon, ...props }: ButtonProps, ref: React.Ref<HTMLButtonElement>) => {
const LocalIcon = Icon[icon];

return (
<StyledButton as={as} ref={ref} {...props}>
{icon}
{!props.iconOnly && children}
{icon && <LocalIcon />}
{children}
</StyledButton>
);
}
Expand All @@ -47,14 +37,7 @@ export const Button: {
Button.displayName = 'Button';

const StyledButton = styled.button<Omit<ButtonProps, 'children'>>(
({
theme,
variant = 'primary',
size = 'medium',
disabled = false,
active = false,
iconOnly = false,
}) => ({
({ theme, variant = 'solid', size = 'medium', disabled = false, active = false }) => ({
border: 0,
cursor: disabled ? 'not-allowed' : 'pointer',
display: 'inline-flex',
Expand All @@ -63,15 +46,10 @@ const StyledButton = styled.button<Omit<ButtonProps, 'children'>>(
justifyContent: 'center',
overflow: 'hidden',
padding: `${(() => {
if (!iconOnly && size === 'small') return '0 10px';
if (!iconOnly && size === 'medium') return '0 12px';
if (size === 'small') return '0 10px';
if (size === 'medium') return '0 12px';
return 0;
})()}`,
width: `${(() => {
if (iconOnly && size === 'small') return '28px';
if (iconOnly && size === 'medium') return '32px';
return 'auto';
})()}`,
height: size === 'small' ? '28px' : '32px',
position: 'relative',
textAlign: 'center',
Expand All @@ -88,41 +66,41 @@ const StyledButton = styled.button<Omit<ButtonProps, 'children'>>(
fontWeight: theme.typography.weight.bold,
lineHeight: '1',
background: `${(() => {
if (variant === 'primary') return theme.color.secondary;
if (variant === 'secondary') return theme.button.background;
if (variant === 'tertiary' && active) return theme.background.hoverable;
if (variant === 'solid') return theme.color.secondary;
if (variant === 'outline') return theme.button.background;
if (variant === 'ghost' && active) return theme.background.hoverable;
return 'transparent';
})()}`,
color: `${(() => {
if (variant === 'primary') return theme.color.lightest;
if (variant === 'secondary') return theme.input.color;
if (variant === 'tertiary' && active) return theme.color.secondary;
if (variant === 'tertiary') return theme.color.mediumdark;
if (variant === 'solid') return theme.color.lightest;
if (variant === 'outline') return theme.input.color;
if (variant === 'ghost' && active) return theme.color.secondary;
if (variant === 'ghost') return theme.color.mediumdark;
return theme.input.color;
})()}`,
boxShadow: variant === 'secondary' ? `${theme.button.border} 0 0 0 1px inset` : 'none',
boxShadow: variant === 'outline' ? `${theme.button.border} 0 0 0 1px inset` : 'none',
borderRadius: theme.input.borderRadius,

'&:hover': {
color: variant === 'tertiary' ? theme.color.secondary : null,
color: variant === 'ghost' ? theme.color.secondary : null,
background: `${(() => {
let bgColor = theme.color.secondary;
if (variant === 'primary') bgColor = theme.color.secondary;
if (variant === 'secondary') bgColor = theme.button.background;
if (variant === 'solid') bgColor = theme.color.secondary;
if (variant === 'outline') bgColor = theme.button.background;
if (variant === 'tertiary') return transparentize(0.86, theme.color.secondary);
if (variant === 'ghost') return transparentize(0.86, theme.color.secondary);
return theme.base === 'light' ? darken(0.02, bgColor) : lighten(0.03, bgColor);
})()}`,
},

'&:active': {
color: variant === 'tertiary' ? theme.color.secondary : null,
color: variant === 'ghost' ? theme.color.secondary : null,
background: `${(() => {
let bgColor = theme.color.secondary;
if (variant === 'primary') bgColor = theme.color.secondary;
if (variant === 'secondary') bgColor = theme.button.background;
if (variant === 'solid') bgColor = theme.color.secondary;
if (variant === 'outline') bgColor = theme.button.background;
if (variant === 'tertiary') return theme.background.hoverable;
if (variant === 'ghost') return theme.background.hoverable;
return theme.base === 'light' ? darken(0.02, bgColor) : lighten(0.03, bgColor);
})()}`,
},
Expand Down
62 changes: 62 additions & 0 deletions code/ui/components/src/new/IconButton/IconButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/react';
import React from 'react';
import { IconButton } from './IconButton';

const meta: Meta<typeof IconButton> = {
title: 'IconButton',
component: IconButton,
tags: ['autodocs'],
};

export default meta;
type Story = StoryObj<typeof IconButton>;

export const Base = {
args: { icon: 'FaceHappy' },
};

export const Types: Story = {
render: () => (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<IconButton variant="solid" icon="FaceHappy" />
<IconButton variant="outline" icon="FaceHappy" />
<IconButton variant="ghost" icon="FaceHappy" />
</div>
),
};

export const Active: Story = {
render: () => (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<IconButton variant="solid" icon="FaceHappy" active />
<IconButton variant="outline" icon="FaceHappy" active />
<IconButton variant="ghost" icon="FaceHappy" active />
</div>
),
};

export const Sizes: Story = {
render: () => (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<IconButton size="small" icon="FaceHappy" />
<IconButton size="medium" icon="FaceHappy" />
</div>
),
};

export const Disabled: Story = {
args: {
...Base.args,
icon: 'FaceHappy',
disabled: true,
},
};

export const WithHref: Story = {
render: () => (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<IconButton icon="FaceHappy" onClick={() => console.log('Hello')} />
<IconButton as="a" href="https://storybook.js.org/" icon="FaceHappy" />
</div>
),
};

0 comments on commit a4c6298

Please sign in to comment.