Skip to content

Commit

Permalink
Create <SegmentedPicker />, <Button />, <ButtonGroup /> compone…
Browse files Browse the repository at this point in the history
…nts (#1516)

* Add missing 'tab' css

* Provided default motion duration

* Create SegmentedPicker component

* Add Button/ButtonGroup components

* Allow for icon-only button groups

* Extract button interaction styles

* Tweak docs

* Remove unnecessary title

* Add more tests

* Simplify syntax

* Make it impossible to set 'variant' manually; add more comments

* Remove unneeded whitespace

* Tweak comment

* Fix button sizes

* Remove the length restrictions on button groups

* Format comment

* Add ButtonGroup tests

* Rename variant -> priority

* Changeset

* Set a default 'type' attr

* Use a title attr for an icon-only button

* Move context file
  • Loading branch information
jessepinho authored Jul 23, 2024
1 parent 4c40bd9 commit 54a5d66
Show file tree
Hide file tree
Showing 16 changed files with 859 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/witty-eels-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@repo/ui': minor
---

Add Button/ButtonGroup/SegmentedPicker components
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 `priority` 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 `priority` 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 { Priority, ActionType } from '../utils/button';

export const getBackgroundColor = (
actionType: ActionType,
priority: Priority,
theme: DefaultTheme,
): string => {
if (priority === '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;
}
};
31 changes: 31 additions & 0 deletions packages/ui/src/Button/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from '@storybook/react';

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

const meta: Meta<typeof Button> = {
component: 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',
disabled: false,
icon: Check,
iconOnly: false,
},
};
51 changes: 51 additions & 0 deletions packages/ui/src/Button/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest';
import { Button } from '.';
import { fireEvent, render } from '@testing-library/react';
import { ThemeProvider } from '../ThemeProvider';
import { Check } from 'lucide-react';

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();
});

describe('when `iconOnly` is falsey', () => {
it('renders `children` as the button text', () => {
const { queryByText } = render(<Button>Label</Button>, { wrapper: ThemeProvider });

expect(queryByText('Label')).toBeTruthy();
});
});

describe('when `iconOnly` is `true`', () => {
it('renders `children` as the button label', () => {
const { queryByText, queryByLabelText } = render(
<Button iconOnly icon={Check}>
Label
</Button>,
{ wrapper: ThemeProvider },
);

expect(queryByText('Label')).toBeNull();
expect(queryByLabelText('Label')).toBeTruthy();
});

it('renders `children` as the `title`', () => {
const { queryByTitle } = render(
<Button iconOnly icon={Check}>
Label
</Button>,
{ wrapper: ThemeProvider },
);

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

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: 32px;
min-width: 32px;
`;

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: 48px;
width: ${props => (props.$iconOnly ? '48px' : '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;
$priority: Priority;
$size: Size;
$getFocusOutlineColor: (theme: DefaultTheme) => string;
$getBorderRadius: (theme: DefaultTheme) => string;
}

const StyledButton = styled.button<StyledButtonProps>`
${button}
background-color: ${props => getBackgroundColor(props.$actionType, props.$priority, props.theme)};
border: none;
outline: ${props =>
props.$priority === '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)}
${buttonInteractions}
&::after {
outline-offset: -2px;
}
`;

interface BaseButtonProps {
type?: HTMLButtonElement['type'];
/**
* The button label. If `iconOnly` is `true`, this will be used as the
* `aria-label` attribute.
*/
children: string;
/**
* Set to `sparse` for more loosely arranged layouts, such as when this is the
* submit button for a form. Use `dense` when, e.g., this button appears next
* to every item in a dense list of data.
*
* Default: `sparse`.
*/
size?: Size;
/**
* What type of action is this button related to? Leave as `default` for most
* buttons, set to `accent` for the single most important action on a given
* page, set to `unshield` for actions that will unshield the user's funds,
* and set to `destructive` for destructive actions.
*
* Default: `default`
*/
actionType?: ActionType;
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);

/** A component for all your button needs! */
export const Button = ({
children,
disabled = false,
onClick,
icon: IconComponent,
iconOnly,
size = 'sparse',
actionType = 'default',
type = 'button',
}: ButtonProps) => {
const priority = useContext(ButtonPriorityContext);

return (
<StyledButton
{...asTransientProps({ iconOnly, size, actionType, priority })}
type={type}
disabled={disabled}
onClick={onClick}
aria-label={iconOnly ? children : undefined}
title={iconOnly ? children : undefined}
$getFocusOutlineColor={theme => theme.color.action[outlineColorByActionType[actionType]]}
$getBorderRadius={theme =>
size === 'sparse' ? theme.borderRadius.sm : theme.borderRadius.full
}
>
{IconComponent && <IconComponent size={size === 'sparse' && iconOnly ? 24 : 16} />}

{!iconOnly && children}
</StyledButton>
);
};
37 changes: 37 additions & 0 deletions packages/ui/src/ButtonGroup/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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',
iconOnly: false,
buttons: [
{
label: 'Delegate',
icon: Send,
},
{
label: 'Undelegate',
icon: HandCoins,
},
{
label: 'Cancel',
icon: Ban,
},
],
},
};
Loading

0 comments on commit 54a5d66

Please sign in to comment.