diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-09-26 18:43:11 +0200 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-10-24 12:25:00 +0200 |
| commit | 388e687857345c85ee550cd5da472675e05a6ff5 (patch) | |
| tree | 0f035a3cad57a75959c028949a57227a83d480e2 /src/components/atoms/buttons/button | |
| parent | 70efcfeaa0603415dd992cb662d8efb960e6e49a (diff) | |
refactor(components): rewrite Button and ButtonLink components
Both:
* move styles to Sass placeholders
Button:
* add `isPressed` prop to Button
* add `isLoading` prop to Button (to differentiate state from
disabled)
ButtonLink:
* replace `external` prop with `isExternal` prop
* replace `href` prop with `to` prop
Diffstat (limited to 'src/components/atoms/buttons/button')
| -rw-r--r-- | src/components/atoms/buttons/button/button.module.scss | 37 | ||||
| -rw-r--r-- | src/components/atoms/buttons/button/button.stories.tsx | 172 | ||||
| -rw-r--r-- | src/components/atoms/buttons/button/button.test.tsx | 133 | ||||
| -rw-r--r-- | src/components/atoms/buttons/button/button.tsx | 98 | ||||
| -rw-r--r-- | src/components/atoms/buttons/button/index.ts | 1 |
5 files changed, 441 insertions, 0 deletions
diff --git a/src/components/atoms/buttons/button/button.module.scss b/src/components/atoms/buttons/button/button.module.scss new file mode 100644 index 0000000..508ff9a --- /dev/null +++ b/src/components/atoms/buttons/button/button.module.scss @@ -0,0 +1,37 @@ +@use "../../../../styles/abstracts/placeholders"; + +.btn { + @extend %button; + + &--initial { + border-radius: 0; + } + + &--circle { + @extend %circle-button; + } + + &--rectangle { + @extend %rectangle-button; + } + + &--square { + @extend %square-button; + } + + &--neutral { + background: inherit; + } + + &--primary { + @extend %primary-button; + } + + &--secondary { + @extend %secondary-button; + } + + &--tertiary { + @extend %tertiary-button; + } +} diff --git a/src/components/atoms/buttons/button/button.stories.tsx b/src/components/atoms/buttons/button/button.stories.tsx new file mode 100644 index 0000000..5ce28fb --- /dev/null +++ b/src/components/atoms/buttons/button/button.stories.tsx @@ -0,0 +1,172 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Button } from './button'; + +/** + * Button - Storybook Meta + */ +export default { + title: 'Atoms/Buttons/Button', + component: Button, + args: { + type: 'button', + }, + argTypes: { + children: { + control: { + type: 'text', + }, + description: 'The button body.', + type: { + name: 'string', + required: true, + }, + }, + isDisabled: { + control: { + type: 'boolean', + }, + description: 'Should the button be disabled?', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + isLoading: { + control: { + type: 'boolean', + }, + description: + 'Should the button be disabled because it is loading something?', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + isPressed: { + control: { + type: 'boolean', + }, + description: 'Define if the button is currently pressed.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + kind: { + control: { + type: 'select', + }, + description: 'Button kind.', + options: ['primary', 'secondary', 'tertiary', 'neutral'], + table: { + category: 'Options', + defaultValue: { summary: 'secondary' }, + }, + type: { + name: 'string', + required: false, + }, + }, + onClick: { + control: { + type: null, + }, + description: 'A callback function to handle click.', + table: { + category: 'Events', + }, + type: { + name: 'function', + required: false, + }, + }, + shape: { + control: { + type: 'select', + }, + description: 'The link shape.', + options: ['circle', 'rectangle', 'square', 'initial'], + table: { + category: 'Options', + defaultValue: { summary: 'rectangle' }, + }, + type: { + name: 'string', + required: false, + }, + }, + type: { + control: { + type: 'select', + }, + description: 'Button type attribute.', + options: ['button', 'reset', 'submit'], + table: { + category: 'Options', + defaultValue: { summary: 'button' }, + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof Button>; + +const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />; + +const logClick = () => { + console.log('Button has been clicked!'); +}; + +/** + * Button Story - Primary + */ +export const Primary = Template.bind({}); +Primary.args = { + children: 'Click on the button', + kind: 'primary', + onClick: logClick, +}; + +/** + * Button Story - Secondary + */ +export const Secondary = Template.bind({}); +Secondary.args = { + children: 'Click on the button', + kind: 'secondary', + onClick: logClick, +}; + +/** + * Button Story - Tertiary + */ +export const Tertiary = Template.bind({}); +Tertiary.args = { + children: 'Click on the button', + kind: 'tertiary', + onClick: logClick, +}; + +/** + * Button Story - Neutral + */ +export const Neutral = Template.bind({}); +Neutral.args = { + children: 'Click on the button', + kind: 'neutral', + onClick: logClick, +}; diff --git a/src/components/atoms/buttons/button/button.test.tsx b/src/components/atoms/buttons/button/button.test.tsx new file mode 100644 index 0000000..f7de1b3 --- /dev/null +++ b/src/components/atoms/buttons/button/button.test.tsx @@ -0,0 +1,133 @@ +/* eslint-disable max-statements */ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { Button } from './button'; + +describe('Button', () => { + it('renders the button body', () => { + const body = 'aliquid'; + + render(<Button>{body}</Button>); + expect(rtlScreen.getByRole('button')).toHaveTextContent(body); + }); + + it('renders a disabled button', () => { + const body = 'quod'; + + render(<Button isDisabled>{body}</Button>); + + expect(rtlScreen.getByRole('button', { name: body })).toBeDisabled(); + }); + + it('renders a button currently loading something', () => { + const body = 'quod'; + + render(<Button isLoading>{body}</Button>); + + expect(rtlScreen.getByRole('button', { busy: true })).toHaveAccessibleName( + body + ); + }); + + it('renders a pressed button', () => { + const body = 'quod'; + + render(<Button isPressed>{body}</Button>); + + expect( + rtlScreen.getByRole('button', { pressed: true }) + ).toHaveAccessibleName(body); + }); + + it('renders a submit button', () => { + const body = 'dolorum'; + + render(<Button type="submit">{body}</Button>); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveAttribute( + 'type', + 'submit' + ); + }); + + it('renders a reset button', () => { + const body = 'consectetur'; + + render(<Button type="reset">{body}</Button>); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveAttribute( + 'type', + 'reset' + ); + }); + + it('renders a primary button', () => { + const body = 'iure'; + + render(<Button kind="primary">{body}</Button>); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveClass( + 'btn--primary' + ); + }); + + it('renders a secondary button', () => { + const body = 'et'; + + render(<Button kind="secondary">{body}</Button>); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveClass( + 'btn--secondary' + ); + }); + + it('renders a tertiary button', () => { + const body = 'quo'; + + render(<Button kind="tertiary">{body}</Button>); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveClass( + 'btn--tertiary' + ); + }); + + it('renders a neutral button', () => { + const body = 'voluptatem'; + + render(<Button kind="neutral">{body}</Button>); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveClass( + 'btn--neutral' + ); + }); + + it('renders a circle button', () => { + const body = 'laudantium'; + + render(<Button shape="circle">{body}</Button>); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveClass( + 'btn--circle' + ); + }); + + it('renders a rectangle button', () => { + const body = 'ut'; + + render(<Button shape="rectangle">{body}</Button>); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveClass( + 'btn--rectangle' + ); + }); + + it('renders a square button', () => { + const body = 'non'; + + render(<Button shape="square">{body}</Button>); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveClass( + 'btn--square' + ); + }); +}); diff --git a/src/components/atoms/buttons/button/button.tsx b/src/components/atoms/buttons/button/button.tsx new file mode 100644 index 0000000..8489b31 --- /dev/null +++ b/src/components/atoms/buttons/button/button.tsx @@ -0,0 +1,98 @@ +import { + type ButtonHTMLAttributes, + forwardRef, + type ForwardRefRenderFunction, + type ReactNode, +} from 'react'; +import styles from './button.module.scss'; + +export type ButtonProps = Omit< + ButtonHTMLAttributes<HTMLButtonElement>, + 'aria-busy' | 'aria-disabled' | 'aria-pressed' | 'aria-selected' | 'disabled' +> & { + /** + * The button body. + */ + children: ReactNode; + /** + * Should the button be disabled? + * + * @default undefined + */ + isDisabled?: boolean; + /** + * Is the button already executing some action? + * + * @default undefined + */ + isLoading?: boolean; + /** + * Is the button a toggle and is it currently pressed? + * + * @default undefined + */ + isPressed?: boolean; + /** + * Button kind. + * + * @default 'secondary' + */ + kind?: 'primary' | 'secondary' | 'tertiary' | 'neutral'; + /** + * Button shape. + * + * @default 'rectangle' + */ + shape?: 'circle' | 'rectangle' | 'square' | 'initial'; + /** + * Button type attribute. + * + * @default 'button' + */ + type?: 'button' | 'reset' | 'submit'; +}; + +const ButtonWithRef: ForwardRefRenderFunction< + HTMLButtonElement, + ButtonProps +> = ( + { + className = '', + children, + isPressed, + isDisabled, + isLoading, + kind = 'secondary', + shape = 'rectangle', + type = 'button', + ...props + }, + ref +) => { + const kindClass = styles[`btn--${kind}`]; + const shapeClass = styles[`btn--${shape}`]; + const btnClass = `${styles.btn} ${kindClass} ${shapeClass} ${className}`; + + return ( + <button + {...props} + aria-busy={isLoading} + aria-disabled={isDisabled} + aria-pressed={isPressed} + className={btnClass} + disabled={isDisabled ?? isLoading} + ref={ref} + // eslint-disable-next-line react/button-has-type -- Default value is set. + type={type} + > + {children} + </button> + ); +}; + +/** + * Button component + * + * Use a button as call to action. + */ +export const Button = forwardRef(ButtonWithRef); diff --git a/src/components/atoms/buttons/button/index.ts b/src/components/atoms/buttons/button/index.ts new file mode 100644 index 0000000..eaf5eea --- /dev/null +++ b/src/components/atoms/buttons/button/index.ts @@ -0,0 +1 @@ +export * from './button'; |
