Skip to content

Commit

Permalink
Add Button/ButtonGroup components
Browse files Browse the repository at this point in the history
  • Loading branch information
jessepinho committed Jul 19, 2024
1 parent 46438af commit 580e6c7
Show file tree
Hide file tree
Showing 8 changed files with 394 additions and 0 deletions.
29 changes: 29 additions & 0 deletions packages/ui/src/Button/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { getBackgroundColor } from './helpers';
import { DefaultTheme } from 'styled-components';

describe('getBackgroundColor()', () => {
const theme = {
color: { primary: { main: '#aaa' }, neutral: { main: '#ccc' }, destructive: { main: '#f00' } },
} as DefaultTheme;

describe('when `variant` is `primary`', () => {
it('returns the primary color for the `accent` action type', () => {
expect(getBackgroundColor('accent', 'primary', theme)).toBe('#aaa');
});

it('returns the neutral color for the `default` action type', () => {
expect(getBackgroundColor('default', 'primary', theme)).toBe('#ccc');
});

it('returns the corresponding color for other action types', () => {
expect(getBackgroundColor('destructive', 'primary', theme)).toBe('#f00');
});
});

describe('when `variant` is `secondary`', () => {
it('returns `transparent`', () => {
expect(getBackgroundColor('accent', 'secondary', theme)).toBe('transparent');
});
});
});
23 changes: 23 additions & 0 deletions packages/ui/src/Button/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { DefaultTheme } from 'styled-components';
import { Variant, ActionType } from '../utils/button';

export const getBackgroundColor = (
actionType: ActionType,
variant: Variant,
theme: DefaultTheme,
): string => {
if (variant === 'secondary') {
return 'transparent';
}

switch (actionType) {
case 'accent':
return theme.color.primary.main;

case 'default':
return theme.color.neutral.main;

default:
return theme.color[actionType].main;
}
};
33 changes: 33 additions & 0 deletions packages/ui/src/Button/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from '@storybook/react';

import { Button } from '.';
import { ArrowLeftRight, Check } from 'lucide-react';

const meta: Meta<typeof Button> = {
component: Button,
title: 'Button',
tags: ['autodocs', '!dev'],
argTypes: {
icon: {
control: 'select',
options: ['None', 'Check', 'ArrowLeftRight'],
mapping: { None: undefined, Check, ArrowLeftRight },
},
onClick: { control: false },
},
};
export default meta;

type Story = StoryObj<typeof Button>;

export const Basic: Story = {
args: {
size: 'sparse',
children: 'Save',
actionType: 'default',
variant: 'primary',
disabled: false,
icon: Check,
iconOnly: false,
},
};
17 changes: 17 additions & 0 deletions packages/ui/src/Button/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it, vi } from 'vitest';
import { Button } from '.';
import { fireEvent, render } from '@testing-library/react';
import { ThemeProvider } from '../ThemeProvider';

describe('<Button />', () => {
it('calls the passed-in click handler when clicked', () => {
const onClick = vi.fn();
const { getByText } = render(<Button onClick={onClick}>Click me</Button>, {
wrapper: ThemeProvider,
});

fireEvent.click(getByText('Click me'));

expect(onClick).toHaveBeenCalledOnce();
});
});
175 changes: 175 additions & 0 deletions packages/ui/src/Button/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { MouseEventHandler } from 'react';
import styled, { css, DefaultTheme } from 'styled-components';
import { asTransientProps } from '../utils/asTransientProps';
import { Size, Variant, ActionType } from '../utils/button';
import { getBackgroundColor } from './helpers';
import { button } from '../utils/typography';
import { LucideIcon } from 'lucide-react';

const dense = css<StyledButtonProps>`
border-radius: ${props => props.theme.borderRadius.full};
padding-left: ${props => props.theme.spacing(props.$iconOnly ? 2 : 4)};
padding-right: ${props => props.theme.spacing(props.$iconOnly ? 2 : 4)};
height: 40px;
min-width: 40px;
`;

const sparse = css<StyledButtonProps>`
border-radius: ${props => props.theme.borderRadius.sm};
padding-left: ${props => props.theme.spacing(4)};
padding-right: ${props => props.theme.spacing(4)};
height: 56px;
width: ${props => (props.$iconOnly ? '56px' : '100%')};
`;

const outlineColorByActionType: Record<ActionType, keyof DefaultTheme['color']['action']> = {
default: 'neutralFocusOutline',
accent: 'primaryFocusOutline',
unshield: 'unshieldFocusOutline',
destructive: 'destructiveFocusOutline',
};

const borderColorByActionType: Record<
ActionType,
'neutral' | 'primary' | 'unshield' | 'destructive'
> = {
default: 'neutral',
accent: 'primary',
unshield: 'unshield',
destructive: 'destructive',
};

interface StyledButtonProps {
$iconOnly?: boolean;
$actionType: ActionType;
$variant: Variant;
$size: Size;
}

const StyledButton = styled.button<StyledButtonProps>`
${button}
background-color: ${props => getBackgroundColor(props.$actionType, props.$variant, props.theme)};
border: none;
outline: ${props =>
props.$variant === 'secondary'
? `1px solid ${props.theme.color[borderColorByActionType[props.$actionType]].main}`
: 'none'};
outline-offset: -1px;
display: flex;
gap: ${props => props.theme.spacing(2)};
align-items: center;
justify-content: center;
color: ${props => props.theme.color.neutral.contrast};
cursor: pointer;
overflow: hidden;
position: relative;
${props => (props.$size === 'dense' ? dense : sparse)}
&:hover::before {
content: '';
position: absolute;
inset: 0;
background-color: ${props => props.theme.color.action.hoverOverlay};
z-index: 1;
}
&:active::before {
content: '';
position: absolute;
inset: 0;
background-color: ${props => props.theme.color.action.activeOverlay};
z-index: 1;
}
&:focus {
outline: 2px solid
${props => props.theme.color.action[outlineColorByActionType[props.$actionType]]};
}
&:disabled::before {
content: '';
position: absolute;
inset: 0;
background-color: ${props => props.theme.color.action.disabledOverlay};
z-index: 1;
cursor: not-allowed;
}
`;

interface BaseButtonProps {
children: string;
size?: Size;
actionType?: ActionType;
variant?: Variant;
disabled?: boolean;
onClick?: MouseEventHandler<HTMLButtonElement>;
}

interface IconOnlyProps {
/**
* When `true`, will render just an icon button. The label text passed via
* `children` will be used as the `aria-label`.
*/
iconOnly: true;
/**
* The icon import from `lucide-react` to render. If `iconOnly` is `true`, no
* label will be rendered -- just the icon. Otherwise, the icon will be
* rendered to the left of the label.
*
* ```tsx
* import { ChevronRight } from 'lucide-react';
* <Button icon={ChevronRight}>Label</Button>
* <Button icon={ChevronRight} iconOnly>Label</Button>
* ```
*/
icon: LucideIcon;
}

interface RegularProps {
/**
* When `true`, will render just an icon button. The label text passed via
* `children` will be used as the `aria-label`.
*/
iconOnly?: false;
/**
* The icon import from `lucide-react` to render. If `iconOnly` is `true`, no
* label will be rendered -- just the icon. Otherwise, the icon will be
* rendered to the left of the label.
*
* ```tsx
* import { ChevronRight } from 'lucide-react';
* <Button icon={ChevronRight}>Label</Button>
* <Button icon={ChevronRight} iconOnly>Label</Button>
* ```
*/
icon?: LucideIcon;
}

export type ButtonProps = BaseButtonProps & (IconOnlyProps | RegularProps);

export const Button = ({
children,
disabled = false,
onClick,
icon: IconComponent,
iconOnly,
size = 'sparse',
actionType = 'default',
variant = 'primary',
}: ButtonProps) => {
return (
<StyledButton
{...asTransientProps({ iconOnly, size, actionType, variant })}
disabled={disabled}
onClick={onClick}
aria-label={children}
>
{IconComponent && <IconComponent size={size === 'sparse' && iconOnly ? 24 : 16} />}

{!iconOnly && children}
</StyledButton>
);
};
36 changes: 36 additions & 0 deletions packages/ui/src/ButtonGroup/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react';

import { ButtonGroup } from '.';
import { Ban, HandCoins, Send } from 'lucide-react';

const meta: Meta<typeof ButtonGroup> = {
component: ButtonGroup,
tags: ['autodocs', '!dev'],
argTypes: {
buttons: { control: false },
},
};
export default meta;

type Story = StoryObj<typeof ButtonGroup>;

export const Basic: Story = {
args: {
actionType: 'default',
size: 'sparse',
buttons: [
{
label: 'Delegate',
icon: Send,
},
{
label: 'Undelegate',
icon: HandCoins,
},
{
label: 'Cancel',
icon: Ban,
},
],
},
};
76 changes: 76 additions & 0 deletions packages/ui/src/ButtonGroup/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { LucideIcon } from 'lucide-react';
import { MouseEventHandler } from 'react';
import { ActionType, Size } from '../utils/button';
import { Button } from '../Button';
import styled from 'styled-components';
import { media } from '../utils/media';

const Root = styled.div<{ $size: Size }>`
display: flex;
flex-direction: ${props => (props.$size === 'sparse' ? 'column' : 'row')};
gap: ${props => props.theme.spacing(2)};
${props => media.tablet`
flex-direction: row;
gap: ${props.theme.spacing(props.$size === 'sparse' ? 4 : 2)};
`}
`;

const ButtonWrapper = styled.div<{ $size: Size }>`
flex-grow: ${props => (props.$size === 'sparse' ? 1 : 0)};
flex-shrink: ${props => (props.$size === 'sparse' ? 1 : 0)};
`;

interface ButtonDescription {
label: string;
icon?: LucideIcon;
onClick?: MouseEventHandler<HTMLButtonElement>;
}

export interface ButtonGroupProps {
/**
* An array of objects, each describing a button to render. The first will be
* rendered with the `primary` variant, the rest with the `secondary` variant.
*
* Minimum length: 1. Maximum length: 3.
*/
buttons:
| [ButtonDescription]
| [ButtonDescription, ButtonDescription]
| [ButtonDescription, ButtonDescription, ButtonDescription];
/**
* The action type of the button group. Will be used for all buttons in the
* group.
*/
actionType?: ActionType;
/** Will be used for all buttons in the group. */
size?: Size;
}

/**
* Use a `<ButtonGroup />` to render multiple buttons in a group with the same
* `actionType` and `size`.
*
* When rendering multiple Penumbra UI buttons together, always use a `<ButtonGroup />` rather than individual `<Button />`s. This ensures that they always meet Penumbra UI guidelines. (For example, all buttons in a group should have the same `actionType`; and the first button in a group should be the `primary` variant, while subsequent buttons are the `secondary` variant.)
*/
export const ButtonGroup = ({
buttons,
actionType = 'default',
size = 'sparse',
}: ButtonGroupProps) => (
<Root $size={size}>
{buttons.map((action, index) => (
<ButtonWrapper key={index} $size={size}>
<Button
icon={action.icon}
actionType={actionType}
onClick={action.onClick}
variant={index === 0 ? 'primary' : 'secondary'}
size={size}
>
{action.label}
</Button>
</ButtonWrapper>
))}
</Root>
);
Loading

0 comments on commit 580e6c7

Please sign in to comment.