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 --------------------- .../molecules/buttons/back-to-top.module.scss | 5 +- .../molecules/buttons/back-to-top.stories.tsx | 6 +- .../molecules/buttons/back-to-top.test.tsx | 11 +- src/components/molecules/buttons/back-to-top.tsx | 28 ++-- src/components/molecules/buttons/help-button.tsx | 4 +- src/components/molecules/layout/card.tsx | 31 ++-- src/components/molecules/nav/pagination.tsx | 40 +++-- src/components/organisms/layout/footer.tsx | 16 +- src/components/organisms/layout/posts-list.tsx | 134 ++++++++------- src/components/organisms/layout/summary.tsx | 16 +- src/components/templates/layout/layout.tsx | 2 +- .../templates/page/page-layout.stories.tsx | 20 ++- 29 files changed, 942 insertions(+), 817 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') 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)}); - } - } - } -} diff --git a/src/components/molecules/buttons/back-to-top.module.scss b/src/components/molecules/buttons/back-to-top.module.scss index f5b3acd..7eae03b 100644 --- a/src/components/molecules/buttons/back-to-top.module.scss +++ b/src/components/molecules/buttons/back-to-top.module.scss @@ -4,6 +4,7 @@ .link { width: clamp(#{fun.convert-px(48)}, 8vw, #{fun.convert-px(55)}); height: clamp(#{fun.convert-px(48)}, 8vw, #{fun.convert-px(55)}); + padding: 0; svg { width: 100%; @@ -18,7 +19,9 @@ .arrow-bar { opacity: 0; transform: translateY(30%) scaleY(0); - transition: transform 0.45s ease-in-out 0s, opacity 0.1s linear 0.2s; + transition: + transform 0.45s ease-in-out 0s, + opacity 0.1s linear 0.2s; } } diff --git a/src/components/molecules/buttons/back-to-top.stories.tsx b/src/components/molecules/buttons/back-to-top.stories.tsx index 5de12d4..40acd33 100644 --- a/src/components/molecules/buttons/back-to-top.stories.tsx +++ b/src/components/molecules/buttons/back-to-top.stories.tsx @@ -1,4 +1,4 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; import { BackToTop as BackToTopComponent } from './back-to-top'; /** @@ -21,7 +21,7 @@ export default { required: false, }, }, - target: { + to: { control: { type: 'text', }, @@ -43,5 +43,5 @@ const Template: ComponentStory = (args) => ( */ export const BackToTop = Template.bind({}); BackToTop.args = { - target: 'top', + to: 'top', }; diff --git a/src/components/molecules/buttons/back-to-top.test.tsx b/src/components/molecules/buttons/back-to-top.test.tsx index aaae3ef..a775841 100644 --- a/src/components/molecules/buttons/back-to-top.test.tsx +++ b/src/components/molecules/buttons/back-to-top.test.tsx @@ -1,11 +1,14 @@ import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; import { BackToTop } from './back-to-top'; describe('BackToTop', () => { it('renders a BackToTop link', () => { - render(); - expect(screen.getByRole('link')).toHaveAccessibleName('Back to top'); - expect(screen.getByRole('link')).toHaveAttribute('href', '#top'); + const id = 'top'; + + render(); + + expect(rtlScreen.getByRole('link')).toHaveAccessibleName('Back to top'); + expect(rtlScreen.getByRole('link')).toHaveAttribute('href', `#${id}`); }); }); diff --git a/src/components/molecules/buttons/back-to-top.tsx b/src/components/molecules/buttons/back-to-top.tsx index d28d6c1..6ca6f10 100644 --- a/src/components/molecules/buttons/back-to-top.tsx +++ b/src/components/molecules/buttons/back-to-top.tsx @@ -1,13 +1,13 @@ -import { FC } from 'react'; +import type { FC, HTMLAttributes } from 'react'; import { useIntl } from 'react-intl'; -import { Arrow, ButtonLink, type ButtonLinkProps } from '../../atoms'; +import { Arrow, ButtonLink } from '../../atoms'; import styles from './back-to-top.module.scss'; -export type BackToTopProps = Pick & { +export type BackToTopProps = HTMLAttributes & { /** - * Set additional classnames to the button wrapper. + * Define the element id to us as anchor. */ - className?: string; + to: string; }; /** @@ -15,23 +15,31 @@ export type BackToTopProps = Pick & { * * Render a back to top link. */ -export const BackToTop: FC = ({ className = '', target }) => { +export const BackToTop: FC = ({ + className = '', + to, + ...props +}) => { const intl = useIntl(); const linkName = intl.formatMessage({ defaultMessage: 'Back to top', description: 'BackToTop: link text', id: 'm+SUSR', }); + const btnClass = `${styles.wrapper} ${className}`; + const anchor = `#${to}`; return ( -
+
- + {/* eslint-disable-next-line react/jsx-no-literals -- Direction allowed */} +
); diff --git a/src/components/molecules/buttons/help-button.tsx b/src/components/molecules/buttons/help-button.tsx index 1234835..7a01b14 100644 --- a/src/components/molecules/buttons/help-button.tsx +++ b/src/components/molecules/buttons/help-button.tsx @@ -1,11 +1,11 @@ -import { forwardRef, ForwardRefRenderFunction } from 'react'; +import { forwardRef, type ForwardRefRenderFunction } from 'react'; import { useIntl } from 'react-intl'; import { Button, type ButtonProps } from '../../atoms'; import styles from './help-button.module.scss'; export type HelpButtonProps = Pick< ButtonProps, - 'aria-pressed' | 'className' | 'onClick' + 'className' | 'isPressed' | 'onClick' >; const HelpButtonWithRef: ForwardRefRenderFunction< diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx index c342d0e..f39a430 100644 --- a/src/components/molecules/layout/card.tsx +++ b/src/components/molecules/layout/card.tsx @@ -1,9 +1,9 @@ -import { FC } from 'react'; -import { type Image } from '../../../types'; +import type { FC } from 'react'; +import type { Image as Img } from '../../../types'; import { ButtonLink, Heading, type HeadingLevel } from '../../atoms'; import { ResponsiveImage } from '../images'; -import { Meta, type MetaData } from './meta'; import styles from './card.module.scss'; +import { Meta, type MetaData } from './meta'; export type CardProps = { /** @@ -13,7 +13,7 @@ export type CardProps = { /** * The card cover. */ - cover?: Image; + cover?: Img; /** * The card id. */ @@ -55,29 +55,32 @@ export const Card: FC = ({ titleLevel, url, }) => { + const cardClass = `${styles.wrapper} ${className}`; + const headingId = `${id}-heading`; + return ( - +
- {cover && } + {cover ? ( + + ) : null} {title}
- {tagline &&
{tagline}
} - {meta && ( + {tagline ?
{tagline}
: null} + {meta ? (
= ({ valueClassName={styles.meta__value} />
- )} + ) : null}
); diff --git a/src/components/molecules/nav/pagination.tsx b/src/components/molecules/nav/pagination.tsx index 6fa69f0..27ef1ec 100644 --- a/src/components/molecules/nav/pagination.tsx +++ b/src/components/molecules/nav/pagination.tsx @@ -1,4 +1,5 @@ -import { FC, Fragment, ReactNode } from 'react'; +/* eslint-disable max-statements */ +import { type FC, Fragment, type ReactNode } from 'react'; import { useIntl } from 'react-intl'; import { ButtonLink } from '../../atoms'; import styles from './pagination.module.scss'; @@ -78,11 +79,8 @@ export const Pagination: FC = ({ * @param {number} end - The last value. * @returns {number[]} An array from start value to end value. */ - const range = (start: number, end: number): number[] => { - const length = end - start + 1; - - return Array.from({ length }, (_, index) => index + start); - }; + const range = (start: number, end: number): number[] => + Array.from({ length: end - start + 1 }, (_, index) => index + start); /** * Get the pagination range. @@ -138,21 +136,17 @@ export const Pagination: FC = ({ const getItem = (id: string, body: ReactNode, link?: string): JSX.Element => { const linkModifier = id.startsWith('page') ? 'link--number' : ''; const kind = id === 'previous' || id === 'next' ? 'tertiary' : 'secondary'; + const linkClass = `${styles.link} ${styles[linkModifier]}`; + const disabledLinkClass = `${styles.link} ${styles['link--disabled']}`; return (
  • {link ? ( - + {body} ) : ( - - {body} - + {body} )}
  • ); @@ -187,6 +181,7 @@ export const Pagination: FC = ({ { number: page, a11y: (chunks: ReactNode) => ( + // eslint-disable-next-line react/jsx-no-literals {page === currentPage && currentPagePrefix} {chunks} @@ -199,19 +194,20 @@ export const Pagination: FC = ({ ? undefined : `${baseUrl}${page}`; - return {getItem(id, body, url)}; + return {getItem(id, body, url)}; }); }; + const navClass = `${styles.wrapper} ${className}`; + const listClass = `${styles.list} ${styles['list--pages']}`; return ( -