From 388e687857345c85ee550cd5da472675e05a6ff5 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 26 Sep 2023 18:43:11 +0200 Subject: 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 --- .../atoms/buttons/button-link.stories.tsx | 153 ------------------ src/components/atoms/buttons/button-link.test.tsx | 10 -- src/components/atoms/buttons/button-link.tsx | 55 ------- .../buttons/button-link/button-link.module.scss | 29 ++++ .../buttons/button-link/button-link.stories.tsx | 114 +++++++++++++ .../atoms/buttons/button-link/button-link.test.tsx | 129 +++++++++++++++ .../atoms/buttons/button-link/button-link.tsx | 67 ++++++++ src/components/atoms/buttons/button-link/index.ts | 1 + src/components/atoms/buttons/button.stories.tsx | 172 -------------------- src/components/atoms/buttons/button.test.tsx | 19 --- src/components/atoms/buttons/button.tsx | 77 --------- .../atoms/buttons/button/button.module.scss | 37 +++++ .../atoms/buttons/button/button.stories.tsx | 172 ++++++++++++++++++++ .../atoms/buttons/button/button.test.tsx | 133 +++++++++++++++ src/components/atoms/buttons/button/button.tsx | 98 +++++++++++ src/components/atoms/buttons/button/index.ts | 1 + src/components/atoms/buttons/buttons.module.scss | 179 --------------------- 17 files changed, 781 insertions(+), 665 deletions(-) delete mode 100644 src/components/atoms/buttons/button-link.stories.tsx delete mode 100644 src/components/atoms/buttons/button-link.test.tsx delete mode 100644 src/components/atoms/buttons/button-link.tsx create mode 100644 src/components/atoms/buttons/button-link/button-link.module.scss create mode 100644 src/components/atoms/buttons/button-link/button-link.stories.tsx create mode 100644 src/components/atoms/buttons/button-link/button-link.test.tsx create mode 100644 src/components/atoms/buttons/button-link/button-link.tsx create mode 100644 src/components/atoms/buttons/button-link/index.ts delete mode 100644 src/components/atoms/buttons/button.stories.tsx delete mode 100644 src/components/atoms/buttons/button.test.tsx delete mode 100644 src/components/atoms/buttons/button.tsx create mode 100644 src/components/atoms/buttons/button/button.module.scss create mode 100644 src/components/atoms/buttons/button/button.stories.tsx create mode 100644 src/components/atoms/buttons/button/button.test.tsx create mode 100644 src/components/atoms/buttons/button/button.tsx create mode 100644 src/components/atoms/buttons/button/index.ts delete mode 100644 src/components/atoms/buttons/buttons.module.scss (limited to 'src/components/atoms') diff --git a/src/components/atoms/buttons/button-link.stories.tsx b/src/components/atoms/buttons/button-link.stories.tsx deleted file mode 100644 index 32c2a7f..0000000 --- a/src/components/atoms/buttons/button-link.stories.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { ButtonLink } from './button-link'; - -/** - * ButtonLink - Storybook Meta - */ -export default { - title: 'Atoms/Buttons/ButtonLink', - component: ButtonLink, - args: { - external: false, - shape: 'rectangle', - }, - argTypes: { - 'aria-label': { - control: { - type: 'text', - }, - description: 'An accessible label.', - table: { - category: 'Accessibility', - }, - type: { - name: 'string', - required: false, - }, - }, - 'aria-labelledby': { - control: { - type: null, - }, - description: 'One or more ids that refer to an accessible label.', - table: { - category: 'Accessibility', - }, - type: { - name: 'string', - required: false, - }, - }, - children: { - control: { - type: 'text', - }, - description: 'The link body.', - type: { - name: 'string', - required: true, - }, - }, - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the button link.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - external: { - control: { - type: 'boolean', - }, - description: 'Determine if the link is an external link.', - table: { - category: 'Options', - defaultValue: { summary: false }, - }, - type: { - name: 'boolean', - required: false, - }, - }, - kind: { - control: { - type: 'select', - }, - description: 'The link kind.', - options: ['primary', 'secondary', 'tertiary'], - table: { - category: 'Options', - defaultValue: { summary: 'secondary' }, - }, - type: { - name: 'string', - required: false, - }, - }, - shape: { - control: { - type: 'select', - }, - description: 'The link shape.', - options: ['rectangle', 'square'], - table: { - category: 'Options', - defaultValue: { summary: 'rectangle' }, - }, - type: { - name: 'string', - required: false, - }, - }, - target: { - control: { - type: null, - }, - description: 'The link target.', - type: { - name: 'string', - required: true, - }, - }, - }, -} as ComponentMeta; - -const Template: ComponentStory = (args) => ( - -); - -/** - * ButtonLink Story - Primary - */ -export const Primary = Template.bind({}); -Primary.args = { - children: 'Link', - kind: 'primary', - target: '#', -}; - -/** - * ButtonLink Story - Secondary - */ -export const Secondary = Template.bind({}); -Secondary.args = { - children: 'Link', - kind: 'secondary', - target: '#', -}; - -/** - * ButtonLink Story - Tertiary - */ -export const Tertiary = Template.bind({}); -Tertiary.args = { - children: 'Link', - kind: 'tertiary', - target: '#', -}; diff --git a/src/components/atoms/buttons/button-link.test.tsx b/src/components/atoms/buttons/button-link.test.tsx deleted file mode 100644 index 8491101..0000000 --- a/src/components/atoms/buttons/button-link.test.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; -import { ButtonLink } from './button-link'; - -describe('ButtonLink', () => { - it('renders a ButtonLink component', () => { - render(Button Link); - expect(screen.getByRole('link')).toHaveTextContent('Button Link'); - }); -}); diff --git a/src/components/atoms/buttons/button-link.tsx b/src/components/atoms/buttons/button-link.tsx deleted file mode 100644 index c8180c9..0000000 --- a/src/components/atoms/buttons/button-link.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import Link from 'next/link'; -import { AnchorHTMLAttributes, FC, ReactNode } from 'react'; -import styles from './buttons.module.scss'; - -export type ButtonLinkProps = AnchorHTMLAttributes & { - /** - * The button link body. - */ - children: ReactNode; - /** - * True if it is an external link. Default: false. - */ - external?: boolean; - /** - * ButtonLink kind. Default: secondary. - */ - kind?: 'primary' | 'secondary' | 'tertiary'; - /** - * ButtonLink shape. Default: rectangle. - */ - shape?: 'circle' | 'rectangle' | 'square'; - /** - * Define an URL as target. - */ - target: string; -}; - -/** - * ButtonLink component - * - * Use a button-like link as call to action. - */ -export const ButtonLink: FC = ({ - children, - className, - target, - kind = 'secondary', - shape = 'rectangle', - external = false, - ...props -}) => { - const kindClass = styles[`btn--${kind}`]; - const shapeClass = styles[`btn--${shape}`]; - const btnClass = `${styles.btn} ${kindClass} ${shapeClass} ${className}`; - - return external ? ( - - {children} - - ) : ( - - {children} - - ); -}; diff --git a/src/components/atoms/buttons/button-link/button-link.module.scss b/src/components/atoms/buttons/button-link/button-link.module.scss new file mode 100644 index 0000000..0f35a24 --- /dev/null +++ b/src/components/atoms/buttons/button-link/button-link.module.scss @@ -0,0 +1,29 @@ +@use "../../../../styles/abstracts/placeholders"; + +.btn { + @extend %button; + + &--circle { + @extend %circle-button; + } + + &--rectangle { + @extend %rectangle-button; + } + + &--square { + @extend %square-button; + } + + &--primary { + @extend %primary-button; + } + + &--secondary { + @extend %secondary-button; + } + + &--tertiary { + @extend %tertiary-button; + } +} diff --git a/src/components/atoms/buttons/button-link/button-link.stories.tsx b/src/components/atoms/buttons/button-link/button-link.stories.tsx new file mode 100644 index 0000000..f048ce9 --- /dev/null +++ b/src/components/atoms/buttons/button-link/button-link.stories.tsx @@ -0,0 +1,114 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { ButtonLink } from './button-link'; + +/** + * ButtonLink - Storybook Meta + */ +export default { + title: 'Atoms/Buttons/ButtonLink', + component: ButtonLink, + args: { + isExternal: false, + shape: 'rectangle', + }, + argTypes: { + children: { + control: { + type: 'text', + }, + description: 'The link body.', + type: { + name: 'string', + required: true, + }, + }, + isExternal: { + control: { + type: 'boolean', + }, + description: 'Determine if the link is an external link.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + kind: { + control: { + type: 'select', + }, + description: 'The link kind.', + options: ['primary', 'secondary', 'tertiary'], + table: { + category: 'Options', + defaultValue: { summary: 'secondary' }, + }, + type: { + name: 'string', + required: false, + }, + }, + shape: { + control: { + type: 'select', + }, + description: 'The link shape.', + options: ['circle', 'rectangle', 'square'], + table: { + category: 'Options', + defaultValue: { summary: 'rectangle' }, + }, + type: { + name: 'string', + required: false, + }, + }, + to: { + control: { + type: 'text', + }, + description: 'The link target.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + +); + +/** + * ButtonLink Story - Primary + */ +export const Primary = Template.bind({}); +Primary.args = { + children: 'Link', + kind: 'primary', + to: '#', +}; + +/** + * ButtonLink Story - Secondary + */ +export const Secondary = Template.bind({}); +Secondary.args = { + children: 'Link', + kind: 'secondary', + to: '#', +}; + +/** + * ButtonLink Story - Tertiary + */ +export const Tertiary = Template.bind({}); +Tertiary.args = { + children: 'Link', + kind: 'tertiary', + to: '#', +}; diff --git a/src/components/atoms/buttons/button-link/button-link.test.tsx b/src/components/atoms/buttons/button-link/button-link.test.tsx new file mode 100644 index 0000000..d18120b --- /dev/null +++ b/src/components/atoms/buttons/button-link/button-link.test.tsx @@ -0,0 +1,129 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { ButtonLink } from './button-link'; + +describe('ButtonLink', () => { + it('renders a link with anchor and href', () => { + const target = 'eum'; + const body = 'est eaque nostrum'; + + render({body}); + + expect(rtlScreen.getByRole('link', { name: body })).toHaveAttribute( + 'href', + target + ); + }); + + it('renders an external link', () => { + const target = 'voluptatem'; + const body = 'impedit'; + + render( + + {body} + + ); + + expect(rtlScreen.getByRole('link', { name: body })).toHaveAttribute( + 'rel', + expect.stringContaining('external') + ); + }); + + it('renders a primary button', () => { + const target = 'vero'; + const body = 'iure'; + + render( + // eslint-disable-next-line react/jsx-no-literals -- Ignore kind. + + {body} + + ); + + expect(rtlScreen.getByRole('link', { name: body })).toHaveClass( + 'btn--primary' + ); + }); + + it('renders a secondary button', () => { + const target = 'voluptatem'; + const body = 'et'; + + render( + // eslint-disable-next-line react/jsx-no-literals -- Ignore kind. + + {body} + + ); + + expect(rtlScreen.getByRole('link', { name: body })).toHaveClass( + 'btn--secondary' + ); + }); + + it('renders a tertiary button', () => { + const target = 'vitae'; + const body = 'quo'; + + render( + // eslint-disable-next-line react/jsx-no-literals -- Ignore kind. + + {body} + + ); + + expect(rtlScreen.getByRole('link', { name: body })).toHaveClass( + 'btn--tertiary' + ); + }); + + it('renders a circle button', () => { + const target = 'praesentium'; + const body = 'laudantium'; + + render( + // eslint-disable-next-line react/jsx-no-literals -- Ignore kind. + + {body} + + ); + + expect(rtlScreen.getByRole('link', { name: body })).toHaveClass( + 'btn--circle' + ); + }); + + it('renders a rectangle button', () => { + const target = 'tempora'; + const body = 'ut'; + + render( + // eslint-disable-next-line react/jsx-no-literals -- Ignore kind. + + {body} + + ); + + expect(rtlScreen.getByRole('link', { name: body })).toHaveClass( + 'btn--rectangle' + ); + }); + + it('renders a square button', () => { + const target = 'quia'; + const body = 'non'; + + render( + // eslint-disable-next-line react/jsx-no-literals -- Ignore kind. + + {body} + + ); + + expect(rtlScreen.getByRole('link', { name: body })).toHaveClass( + 'btn--square' + ); + }); +}); diff --git a/src/components/atoms/buttons/button-link/button-link.tsx b/src/components/atoms/buttons/button-link/button-link.tsx new file mode 100644 index 0000000..f8bbadc --- /dev/null +++ b/src/components/atoms/buttons/button-link/button-link.tsx @@ -0,0 +1,67 @@ +import Link from 'next/link'; +import type { AnchorHTMLAttributes, FC, ReactNode } from 'react'; +import styles from './button-link.module.scss'; + +export type ButtonLinkProps = Omit< + AnchorHTMLAttributes, + 'href' +> & { + /** + * The button link body. + */ + children: ReactNode; + /** + * True if it is an external link. + * + * @default false + */ + isExternal?: boolean; + /** + * Define the button kind. + * + * @default 'secondary' + */ + kind?: 'primary' | 'secondary' | 'tertiary'; + /** + * Define the button shape. + * + * @default 'rectangle' + */ + shape?: 'circle' | 'rectangle' | 'square'; + /** + * Define an URL or anchor as target. + */ + to: string; +}; + +/** + * ButtonLink component + * + * Use a button-like link as call to action. + */ +export const ButtonLink: FC = ({ + children, + className = '', + kind = 'secondary', + shape = 'rectangle', + isExternal = false, + rel = '', + to, + ...props +}) => { + const kindClass = styles[`btn--${kind}`]; + const shapeClass = styles[`btn--${shape}`]; + const btnClass = `${styles.btn} ${kindClass} ${shapeClass} ${className}`; + const linkRel = + isExternal && !rel.includes('external') ? `external ${rel}` : rel; + + return isExternal ? ( + + {children} + + ) : ( + + {children} + + ); +}; diff --git a/src/components/atoms/buttons/button-link/index.ts b/src/components/atoms/buttons/button-link/index.ts new file mode 100644 index 0000000..68d0a03 --- /dev/null +++ b/src/components/atoms/buttons/button-link/index.ts @@ -0,0 +1 @@ +export * from './button-link'; diff --git a/src/components/atoms/buttons/button.stories.tsx b/src/components/atoms/buttons/button.stories.tsx deleted file mode 100644 index ba09a0d..0000000 --- a/src/components/atoms/buttons/button.stories.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { Button } from './button'; - -/** - * Button - Storybook Meta - */ -export default { - title: 'Atoms/Buttons/Button', - component: Button, - args: { - disabled: false, - type: 'button', - }, - argTypes: { - 'aria-label': { - control: { - type: 'text', - }, - description: 'An accessible label.', - table: { - category: 'Accessibility', - }, - type: { - name: 'string', - required: false, - }, - }, - children: { - control: { - type: 'text', - }, - description: 'The button body.', - type: { - name: 'string', - required: true, - }, - }, - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the button wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - disabled: { - control: { - type: 'boolean', - }, - description: 'Render button as disabled.', - 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; - -const Template: ComponentStory = (args) => { - const { children, type, ...props } = args; - - const getBody = () => { - if (children) return children; - - switch (type) { - case 'reset': - return 'Reset'; - case 'submit': - return 'Submit'; - case 'button': - default: - return 'Button'; - } - }; - - return ( - - ); -}; - -/** - * Button Story - Primary - */ -export const Primary = Template.bind({}); -Primary.args = { - kind: 'primary', -}; - -/** - * Button Story - Secondary - */ -export const Secondary = Template.bind({}); -Secondary.args = { - kind: 'secondary', -}; - -/** - * Button Story - Tertiary - */ -export const Tertiary = Template.bind({}); -Tertiary.args = { - kind: 'tertiary', -}; diff --git a/src/components/atoms/buttons/button.test.tsx b/src/components/atoms/buttons/button.test.tsx deleted file mode 100644 index b6bfc5d..0000000 --- a/src/components/atoms/buttons/button.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; -import { Button } from './button'; - -describe('Button', () => { - it('renders the Button component', () => { - render(); - expect(screen.getByRole('button')).toBeInTheDocument(); - }); - - it('renders the Button component with disabled state', () => { - render( - - ); - expect(screen.getByRole('button')).toBeDisabled(); - }); -}); diff --git a/src/components/atoms/buttons/button.tsx b/src/components/atoms/buttons/button.tsx deleted file mode 100644 index 6ef5775..0000000 --- a/src/components/atoms/buttons/button.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { - ButtonHTMLAttributes, - forwardRef, - ForwardRefRenderFunction, - ReactNode, -} from 'react'; -import styles from './buttons.module.scss'; - -export type ButtonProps = ButtonHTMLAttributes & { - /** - * The button body. - */ - children: ReactNode; - /** - * Button state. - * - * @default false - */ - disabled?: 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, - disabled = false, - 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 component - * - * Use a button as call to action. - */ -export const Button = forwardRef(ButtonWithRef); 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; + +const Template: ComponentStory = (args) => ); + expect(rtlScreen.getByRole('button')).toHaveTextContent(body); + }); + + it('renders a disabled button', () => { + const body = 'quod'; + + render(); + + expect(rtlScreen.getByRole('button', { name: body })).toBeDisabled(); + }); + + it('renders a button currently loading something', () => { + const body = 'quod'; + + render(); + + expect(rtlScreen.getByRole('button', { busy: true })).toHaveAccessibleName( + body + ); + }); + + it('renders a pressed button', () => { + const body = 'quod'; + + render(); + + expect( + rtlScreen.getByRole('button', { pressed: true }) + ).toHaveAccessibleName(body); + }); + + it('renders a submit button', () => { + const body = 'dolorum'; + + render(); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveAttribute( + 'type', + 'submit' + ); + }); + + it('renders a reset button', () => { + const body = 'consectetur'; + + render(); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveAttribute( + 'type', + 'reset' + ); + }); + + it('renders a primary button', () => { + const body = 'iure'; + + render(); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveClass( + 'btn--primary' + ); + }); + + it('renders a secondary button', () => { + const body = 'et'; + + render(); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveClass( + 'btn--secondary' + ); + }); + + it('renders a tertiary button', () => { + const body = 'quo'; + + render(); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveClass( + 'btn--tertiary' + ); + }); + + it('renders a neutral button', () => { + const body = 'voluptatem'; + + render(); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveClass( + 'btn--neutral' + ); + }); + + it('renders a circle button', () => { + const body = 'laudantium'; + + render(); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveClass( + 'btn--circle' + ); + }); + + it('renders a rectangle button', () => { + const body = 'ut'; + + render(); + + expect(rtlScreen.getByRole('button', { name: body })).toHaveClass( + 'btn--rectangle' + ); + }); + + it('renders a square button', () => { + const body = 'non'; + + render(); + + 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, + '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 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'; diff --git a/src/components/atoms/buttons/buttons.module.scss b/src/components/atoms/buttons/buttons.module.scss deleted file mode 100644 index a46f55c..0000000 --- a/src/components/atoms/buttons/buttons.module.scss +++ /dev/null @@ -1,179 +0,0 @@ -@use "../../../styles/abstracts/functions" as fun; - -.btn { - display: inline-flex; - place-content: center; - align-items: center; - border: none; - border-radius: fun.convert-px(5); - font-size: var(--font-size-md); - font-weight: 600; - text-decoration: none; - transition: all 0.3s ease-in-out 0s; - - &--initial { - border-radius: 0; - } - - &--rectangle { - padding: var(--spacing-2xs) var(--spacing-sm); - } - - &--square, - &--circle { - min-width: fit-content; - min-height: fit-content; - padding: var(--spacing-xs); - aspect-ratio: 1 / 1; - } - - &--circle { - border-radius: 50%; - } - - &:disabled { - cursor: wait; - } - - &--neutral { - background: inherit; - } - - &--primary { - background: var(--color-primary); - border: fun.convert-px(2) solid var(--color-bg); - box-shadow: 0 0 0 fun.convert-px(2) var(--color-primary), - 0 0 0 fun.convert-px(3) var(--color-primary-darker), - fun.convert-px(2) fun.convert-px(2) 0 fun.convert-px(3) - var(--color-primary-dark); - color: var(--color-fg-inverted); - text-shadow: fun.convert-px(2) fun.convert-px(2) 0 var(--color-shadow); - - &:disabled { - background: var(--color-primary-darker); - } - - &:not(:disabled) { - &:hover, - &:focus { - background: var(--color-primary-light); - box-shadow: 0 0 0 fun.convert-px(2) var(--color-primary-light), - 0 0 0 fun.convert-px(3) var(--color-primary-darker), - fun.convert-px(7) fun.convert-px(7) 0 fun.convert-px(2) - var(--color-primary-dark); - color: var(--color-fg-inverted); - transform: translateX(#{fun.convert-px(-4)}) - translateY(#{fun.convert-px(-4)}); - } - - &:focus { - text-decoration: underline solid var(--color-fg-inverted) - fun.convert-px(2); - } - - &:active { - background: var(--color-primary-dark); - box-shadow: 0 0 0 fun.convert-px(2) var(--color-primary), - 0 0 0 fun.convert-px(3) var(--color-primary-darker), - 0 0 0 0 var(--color-primary-dark); - text-decoration: none; - transform: translateX(#{fun.convert-px(4)}) - translateY(#{fun.convert-px(4)}); - } - } - } - - &--secondary { - background: var(--color-bg); - border: fun.convert-px(3) solid var(--color-primary); - box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) - var(--color-shadow), - fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2) - var(--color-shadow), - fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4) - var(--color-shadow); - color: var(--color-primary); - - &:disabled { - border-color: var(--color-border-dark); - color: var(--color-fg-light); - } - - &:not(:disabled) { - &:hover, - &:focus { - border-color: var(--color-primary-light); - color: var(--color-primary-light); - box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) - var(--color-shadow-light), - fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) - fun.convert-px(-2) var(--color-shadow-light), - fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) - fun.convert-px(-4) var(--color-shadow-light), - fun.convert-px(7) fun.convert-px(10) fun.convert-px(12) - fun.convert-px(-3) var(--color-shadow-light); - transform: scale(var(--scale-up, 1.1)); - } - - &:focus { - text-decoration: underline var(--color-primary-light) fun.convert-px(3); - } - - &:active { - border-color: var(--color-primary-dark); - box-shadow: 0 0 0 0 var(--color-shadow); - color: var(--color-primary-dark); - text-decoration: none; - transform: scale(var(--scale-down, 0.94)); - } - } - } - - &--tertiary { - background: var(--color-bg); - border: fun.convert-px(3) solid var(--color-primary); - box-shadow: fun.convert-px(2) fun.convert-px(2) 0 0 var(--color-bg), - fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-primary-dark), - fun.convert-px(5) fun.convert-px(5) 0 0 var(--color-bg), - fun.convert-px(6) fun.convert-px(6) 0 0 var(--color-primary-dark); - color: var(--color-primary); - - &:disabled { - color: var(--color-fg-light); - border-color: var(--color-border-dark); - box-shadow: fun.convert-px(2) fun.convert-px(2) 0 0 var(--color-bg), - fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-primary-darker), - fun.convert-px(5) fun.convert-px(5) 0 0 var(--color-bg), - fun.convert-px(6) fun.convert-px(6) 0 0 var(--color-primary-darker); - } - - &:not(:disabled) { - &:hover, - &:focus { - border-color: var(--color-primary-light); - box-shadow: fun.convert-px(2) fun.convert-px(3) 0 0 var(--color-bg), - fun.convert-px(4) fun.convert-px(5) 0 0 var(--color-primary), - fun.convert-px(6) fun.convert-px(8) 0 0 var(--color-bg), - fun.convert-px(8) fun.convert-px(10) 0 0 var(--color-primary), - fun.convert-px(10) fun.convert-px(12) fun.convert-px(1) 0 - var(--color-shadow-light), - fun.convert-px(10) fun.convert-px(12) fun.convert-px(5) - fun.convert-px(1) var(--color-shadow-light); - color: var(--color-primary-light); - transform: translateX(#{fun.convert-px(-3)}) - translateY(#{fun.convert-px(-5)}); - } - - &:focus { - text-decoration: underline var(--color-primary) fun.convert-px(2); - } - - &:active { - box-shadow: 0 0 0 0 var(--color-shadow); - text-decoration: none; - transform: translateX(#{fun.convert-px(5)}) - translateY(#{fun.convert-px(6)}); - } - } - } -} -- cgit v1.2.3