-
Notifications
You must be signed in to change notification settings - Fork 17
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Create
<SegmentedPicker />
, <Button />
, <ButtonGroup />
compone…
…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
1 parent
4c40bd9
commit 54a5d66
Showing
16 changed files
with
859 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@repo/ui': minor | ||
--- | ||
|
||
Add Button/ButtonGroup/SegmentedPicker components |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
], | ||
}, | ||
}; |
Oops, something went wrong.