aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/atoms/buttons/button
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-09-26 18:43:11 +0200
committerArmand Philippot <git@armandphilippot.com>2023-10-24 12:25:00 +0200
commit388e687857345c85ee550cd5da472675e05a6ff5 (patch)
tree0f035a3cad57a75959c028949a57227a83d480e2 /src/components/atoms/buttons/button
parent70efcfeaa0603415dd992cb662d8efb960e6e49a (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.scss37
-rw-r--r--src/components/atoms/buttons/button/button.stories.tsx172
-rw-r--r--src/components/atoms/buttons/button/button.test.tsx133
-rw-r--r--src/components/atoms/buttons/button/button.tsx98
-rw-r--r--src/components/atoms/buttons/button/index.ts1
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';