aboutsummaryrefslogtreecommitdiffstats
path: root/src
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
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')
-rw-r--r--src/components/atoms/buttons/button-link.test.tsx10
-rw-r--r--src/components/atoms/buttons/button-link.tsx55
-rw-r--r--src/components/atoms/buttons/button-link/button-link.module.scss29
-rw-r--r--src/components/atoms/buttons/button-link/button-link.stories.tsx (renamed from src/components/atoms/buttons/button-link.stories.tsx)57
-rw-r--r--src/components/atoms/buttons/button-link/button-link.test.tsx129
-rw-r--r--src/components/atoms/buttons/button-link/button-link.tsx67
-rw-r--r--src/components/atoms/buttons/button-link/index.ts1
-rw-r--r--src/components/atoms/buttons/button.test.tsx19
-rw-r--r--src/components/atoms/buttons/button/button.module.scss37
-rw-r--r--src/components/atoms/buttons/button/button.stories.tsx (renamed from src/components/atoms/buttons/button.stories.tsx)82
-rw-r--r--src/components/atoms/buttons/button/button.test.tsx133
-rw-r--r--src/components/atoms/buttons/button/button.tsx (renamed from src/components/atoms/buttons/button.tsx)41
-rw-r--r--src/components/atoms/buttons/button/index.ts1
-rw-r--r--src/components/atoms/buttons/buttons.module.scss179
-rw-r--r--src/components/molecules/buttons/back-to-top.module.scss5
-rw-r--r--src/components/molecules/buttons/back-to-top.stories.tsx6
-rw-r--r--src/components/molecules/buttons/back-to-top.test.tsx11
-rw-r--r--src/components/molecules/buttons/back-to-top.tsx28
-rw-r--r--src/components/molecules/buttons/help-button.tsx4
-rw-r--r--src/components/molecules/layout/card.tsx31
-rw-r--r--src/components/molecules/nav/pagination.tsx40
-rw-r--r--src/components/organisms/layout/footer.tsx16
-rw-r--r--src/components/organisms/layout/posts-list.tsx134
-rw-r--r--src/components/organisms/layout/summary.tsx16
-rw-r--r--src/components/templates/layout/layout.tsx2
-rw-r--r--src/components/templates/page/page-layout.stories.tsx20
-rw-r--r--src/pages/article/[slug].tsx14
-rw-r--r--src/pages/index.tsx18
-rw-r--r--src/styles/abstracts/_placeholders.scss1
-rw-r--r--src/styles/abstracts/placeholders/_buttons.scss193
30 files changed, 848 insertions, 531 deletions
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(<ButtonLink target="#">Button Link</ButtonLink>);
- 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<HTMLAnchorElement> & {
- /**
- * 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<ButtonLinkProps> = ({
- 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 ? (
- <a {...props} className={btnClass} href={target}>
- {children}
- </a>
- ) : (
- <Link {...props} className={btnClass} href={target}>
- {children}
- </Link>
- );
-};
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.stories.tsx b/src/components/atoms/buttons/button-link/button-link.stories.tsx
index 32c2a7f..f048ce9 100644
--- a/src/components/atoms/buttons/button-link.stories.tsx
+++ b/src/components/atoms/buttons/button-link/button-link.stories.tsx
@@ -1,4 +1,4 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
import { ButtonLink } from './button-link';
/**
@@ -8,36 +8,10 @@ export default {
title: 'Atoms/Buttons/ButtonLink',
component: ButtonLink,
args: {
- external: false,
+ isExternal: 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',
@@ -48,20 +22,7 @@ export default {
required: true,
},
},
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the button link.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- external: {
+ isExternal: {
control: {
type: 'boolean',
},
@@ -95,7 +56,7 @@ export default {
type: 'select',
},
description: 'The link shape.',
- options: ['rectangle', 'square'],
+ options: ['circle', 'rectangle', 'square'],
table: {
category: 'Options',
defaultValue: { summary: 'rectangle' },
@@ -105,9 +66,9 @@ export default {
required: false,
},
},
- target: {
+ to: {
control: {
- type: null,
+ type: 'text',
},
description: 'The link target.',
type: {
@@ -129,7 +90,7 @@ export const Primary = Template.bind({});
Primary.args = {
children: 'Link',
kind: 'primary',
- target: '#',
+ to: '#',
};
/**
@@ -139,7 +100,7 @@ export const Secondary = Template.bind({});
Secondary.args = {
children: 'Link',
kind: 'secondary',
- target: '#',
+ to: '#',
};
/**
@@ -149,5 +110,5 @@ export const Tertiary = Template.bind({});
Tertiary.args = {
children: 'Link',
kind: 'tertiary',
- target: '#',
+ 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(<ButtonLink to={target}>{body}</ButtonLink>);
+
+ expect(rtlScreen.getByRole('link', { name: body })).toHaveAttribute(
+ 'href',
+ target
+ );
+ });
+
+ it('renders an external link', () => {
+ const target = 'voluptatem';
+ const body = 'impedit';
+
+ render(
+ <ButtonLink isExternal to={target}>
+ {body}
+ </ButtonLink>
+ );
+
+ 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.
+ <ButtonLink kind="primary" to={target}>
+ {body}
+ </ButtonLink>
+ );
+
+ 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.
+ <ButtonLink kind="secondary" to={target}>
+ {body}
+ </ButtonLink>
+ );
+
+ 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.
+ <ButtonLink kind="tertiary" to={target}>
+ {body}
+ </ButtonLink>
+ );
+
+ 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.
+ <ButtonLink shape="circle" to={target}>
+ {body}
+ </ButtonLink>
+ );
+
+ 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.
+ <ButtonLink shape="rectangle" to={target}>
+ {body}
+ </ButtonLink>
+ );
+
+ 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.
+ <ButtonLink shape="square" to={target}>
+ {body}
+ </ButtonLink>
+ );
+
+ 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<HTMLAnchorElement>,
+ '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<ButtonLinkProps> = ({
+ 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 ? (
+ <a {...props} className={btnClass} href={to} rel={linkRel}>
+ {children}
+ </a>
+ ) : (
+ <Link {...props} className={btnClass} href={to} rel={rel}>
+ {children}
+ </Link>
+ );
+};
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.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(<Button onClick={() => null}>Button</Button>);
- expect(screen.getByRole('button')).toBeInTheDocument();
- });
-
- it('renders the Button component with disabled state', () => {
- render(
- <Button onClick={() => null} disabled={true}>
- Disabled Button
- </Button>
- );
- expect(screen.getByRole('button')).toBeDisabled();
- });
-});
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.stories.tsx b/src/components/atoms/buttons/button/button.stories.tsx
index ba09a0d..5ce28fb 100644
--- a/src/components/atoms/buttons/button.stories.tsx
+++ b/src/components/atoms/buttons/button/button.stories.tsx
@@ -1,4 +1,4 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
import { Button } from './button';
/**
@@ -8,51 +8,53 @@ export default {
title: 'Atoms/Buttons/Button',
component: Button,
args: {
- disabled: false,
type: 'button',
},
argTypes: {
- 'aria-label': {
+ children: {
control: {
type: 'text',
},
- description: 'An accessible label.',
- table: {
- category: 'Accessibility',
- },
+ description: 'The button body.',
type: {
name: 'string',
- required: false,
+ required: true,
},
},
- children: {
+ isDisabled: {
control: {
- type: 'text',
+ type: 'boolean',
+ },
+ description: 'Should the button be disabled?',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
},
- description: 'The button body.',
type: {
- name: 'string',
- required: true,
+ name: 'boolean',
+ required: false,
},
},
- className: {
+ isLoading: {
control: {
- type: 'text',
+ type: 'boolean',
},
- description: 'Set additional classnames to the button wrapper.',
+ description:
+ 'Should the button be disabled because it is loading something?',
table: {
- category: 'Styles',
+ category: 'Options',
+ defaultValue: { summary: false },
},
type: {
- name: 'string',
+ name: 'boolean',
required: false,
},
},
- disabled: {
+ isPressed: {
control: {
type: 'boolean',
},
- description: 'Render button as disabled.',
+ description: 'Define if the button is currently pressed.',
table: {
category: 'Options',
defaultValue: { summary: false },
@@ -123,28 +125,10 @@ export default {
},
} as ComponentMeta<typeof Button>;
-const Template: ComponentStory<typeof Button> = (args) => {
- const { children, type, ...props } = args;
-
- const getBody = () => {
- if (children) return children;
+const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />;
- switch (type) {
- case 'reset':
- return 'Reset';
- case 'submit':
- return 'Submit';
- case 'button':
- default:
- return 'Button';
- }
- };
-
- return (
- <Button type={type} {...props}>
- {getBody()}
- </Button>
- );
+const logClick = () => {
+ console.log('Button has been clicked!');
};
/**
@@ -152,7 +136,9 @@ const Template: ComponentStory<typeof Button> = (args) => {
*/
export const Primary = Template.bind({});
Primary.args = {
+ children: 'Click on the button',
kind: 'primary',
+ onClick: logClick,
};
/**
@@ -160,7 +146,9 @@ Primary.args = {
*/
export const Secondary = Template.bind({});
Secondary.args = {
+ children: 'Click on the button',
kind: 'secondary',
+ onClick: logClick,
};
/**
@@ -168,5 +156,17 @@ Secondary.args = {
*/
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.tsx b/src/components/atoms/buttons/button/button.tsx
index 6ef5775..8489b31 100644
--- a/src/components/atoms/buttons/button.tsx
+++ b/src/components/atoms/buttons/button/button.tsx
@@ -1,22 +1,37 @@
import {
- ButtonHTMLAttributes,
+ type ButtonHTMLAttributes,
forwardRef,
- ForwardRefRenderFunction,
- ReactNode,
+ type ForwardRefRenderFunction,
+ type ReactNode,
} from 'react';
-import styles from './buttons.module.scss';
+import styles from './button.module.scss';
-export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
+export type ButtonProps = Omit<
+ ButtonHTMLAttributes<HTMLButtonElement>,
+ 'aria-busy' | 'aria-disabled' | 'aria-pressed' | 'aria-selected' | 'disabled'
+> & {
/**
* The button body.
*/
children: ReactNode;
/**
- * Button state.
+ * Should the button be disabled?
*
- * @default false
+ * @default undefined
*/
- disabled?: boolean;
+ 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.
*
@@ -44,7 +59,9 @@ const ButtonWithRef: ForwardRefRenderFunction<
{
className = '',
children,
- disabled = false,
+ isPressed,
+ isDisabled,
+ isLoading,
kind = 'secondary',
shape = 'rectangle',
type = 'button',
@@ -59,9 +76,13 @@ const ButtonWithRef: ForwardRefRenderFunction<
return (
<button
{...props}
+ aria-busy={isLoading}
+ aria-disabled={isDisabled}
+ aria-pressed={isPressed}
className={btnClass}
- disabled={disabled}
+ disabled={isDisabled ?? isLoading}
ref={ref}
+ // eslint-disable-next-line react/button-has-type -- Default value is set.
type={type}
>
{children}
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<typeof BackToTopComponent> = (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(<BackToTop target="top" />);
- expect(screen.getByRole('link')).toHaveAccessibleName('Back to top');
- expect(screen.getByRole('link')).toHaveAttribute('href', '#top');
+ const id = 'top';
+
+ render(<BackToTop to={id} />);
+
+ 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<ButtonLinkProps, 'target'> & {
+export type BackToTopProps = HTMLAttributes<HTMLDivElement> & {
/**
- * 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<ButtonLinkProps, 'target'> & {
*
* Render a back to top link.
*/
-export const BackToTop: FC<BackToTopProps> = ({ className = '', target }) => {
+export const BackToTop: FC<BackToTopProps> = ({
+ 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 (
- <div className={`${styles.wrapper} ${className}`}>
+ <div {...props} className={btnClass}>
<ButtonLink
- shape="square"
- target={`#${target}`}
aria-label={linkName}
className={styles.link}
+ // eslint-disable-next-line react/jsx-no-literals -- Shape allowed
+ shape="square"
+ to={anchor}
>
- <Arrow aria-hidden={true} direction="top" />
+ {/* eslint-disable-next-line react/jsx-no-literals -- Direction allowed */}
+ <Arrow aria-hidden direction="top" />
</ButtonLink>
</div>
);
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<CardProps> = ({
titleLevel,
url,
}) => {
+ const cardClass = `${styles.wrapper} ${className}`;
+ const headingId = `${id}-heading`;
+
return (
- <ButtonLink
- aria-labelledby={`${id}-heading`}
- className={`${styles.wrapper} ${className}`}
- target={url}
- >
+ <ButtonLink aria-labelledby={headingId} className={cardClass} to={url}>
<article className={styles.article}>
<header className={styles.header}>
- {cover && <ResponsiveImage {...cover} className={styles.cover} />}
+ {cover ? (
+ <ResponsiveImage {...cover} className={styles.cover} />
+ ) : null}
<Heading
+ // eslint-disable-next-line react/jsx-no-literals -- Hardcoded config
alignment="center"
className={styles.title}
- id={`${id}-heading`}
+ id={headingId}
level={titleLevel}
>
{title}
</Heading>
</header>
- {tagline && <div className={styles.tagline}>{tagline}</div>}
- {meta && (
+ {tagline ? <div className={styles.tagline}>{tagline}</div> : null}
+ {meta ? (
<footer className={styles.footer}>
<Meta
data={meta}
+ // eslint-disable-next-line react/jsx-no-literals -- Hardcoded config
layout="inline"
className={styles.list}
groupClassName={styles.meta__item}
@@ -85,7 +88,7 @@ export const Card: FC<CardProps> = ({
valueClassName={styles.meta__value}
/>
</footer>
- )}
+ ) : null}
</article>
</ButtonLink>
);
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<PaginationProps> = ({
* @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<PaginationProps> = ({
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 (
<li className={styles.item}>
{link ? (
- <ButtonLink
- kind={kind}
- target={link}
- className={`${styles.link} ${styles[linkModifier]}`}
- >
+ <ButtonLink className={linkClass} kind={kind} to={link}>
{body}
</ButtonLink>
) : (
- <span className={`${styles.link} ${styles['link--disabled']}`}>
- {body}
- </span>
+ <span className={disabledLinkClass}>{body}</span>
)}
</li>
);
@@ -187,6 +181,7 @@ export const Pagination: FC<PaginationProps> = ({
{
number: page,
a11y: (chunks: ReactNode) => (
+ // eslint-disable-next-line react/jsx-no-literals
<span className="screen-reader-text">
{page === currentPage && currentPagePrefix}
{chunks}
@@ -199,19 +194,20 @@ export const Pagination: FC<PaginationProps> = ({
? undefined
: `${baseUrl}${page}`;
- return <Fragment key={`item-${id}`}>{getItem(id, body, url)}</Fragment>;
+ return <Fragment key={id}>{getItem(id, body, url)}</Fragment>;
});
};
+ const navClass = `${styles.wrapper} ${className}`;
+ const listClass = `${styles.list} ${styles['list--pages']}`;
return (
- <nav {...props} className={`${styles.wrapper} ${className}`}>
- <ul className={`${styles.list} ${styles['list--pages']}`}>
- {getPages(current, totalPages)}
- </ul>
+ <nav {...props} className={navClass}>
+ <ul className={listClass}>{getPages(current, totalPages)}</ul>
<ul className={styles.list}>
- {hasPreviousPage &&
- getItem('previous', previousPageName, previousPageUrl)}
- {hasNextPage && getItem('next', nextPageName, nextPageUrl)}
+ {hasPreviousPage
+ ? getItem('previous', previousPageName, previousPageUrl)
+ : null}
+ {hasNextPage ? getItem('next', nextPageName, nextPageUrl) : null}
</ul>
</nav>
);
diff --git a/src/components/organisms/layout/footer.tsx b/src/components/organisms/layout/footer.tsx
index f1f3236..36e85a7 100644
--- a/src/components/organisms/layout/footer.tsx
+++ b/src/components/organisms/layout/footer.tsx
@@ -1,4 +1,4 @@
-import { FC } from 'react';
+import type { FC } from 'react';
import { useIntl } from 'react-intl';
import { Copyright, type CopyrightProps } from '../../atoms';
import {
@@ -50,26 +50,26 @@ export const Footer: FC<FooterProps> = ({
description: 'Footer: an accessible name for footer nav',
id: 'd4N8nD',
});
+ const footerClass = `${styles.wrapper} ${className}`;
+ const btnClass = `${styles['back-to-top']} ${backToTopClassName}`;
return (
- <footer className={`${styles.wrapper} ${className}`}>
+ <footer className={footerClass}>
<Copyright
dates={copyright.dates}
icon={copyright.icon}
owner={copyright.owner}
/>
- {navItems && (
+ {navItems ? (
<Nav
aria-label={ariaLabel}
className={styles.nav}
items={navItems}
+ // eslint-disable-next-line react/jsx-no-literals -- Hardcoded config
kind="footer"
/>
- )}
- <BackToTop
- className={`${styles['back-to-top']} ${backToTopClassName}`}
- target={topId}
- />
+ ) : null}
+ <BackToTop className={btnClass} to={topId} />
</footer>
);
};
diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx
index e214ca7..f04ba74 100644
--- a/src/components/organisms/layout/posts-list.tsx
+++ b/src/components/organisms/layout/posts-list.tsx
@@ -1,4 +1,5 @@
-import { FC, Fragment, useRef } from 'react';
+/* eslint-disable max-statements */
+import { type FC, Fragment, useRef, useCallback } from 'react';
import { useIntl } from 'react-intl';
import { useIsMounted, useSettings } from '../../../utils/hooks';
import {
@@ -20,9 +21,7 @@ export type Post = Omit<SummaryProps, 'titleLevel'> & {
id: string | number;
};
-export type YearCollection = {
- [key: string]: Post[];
-};
+export type YearCollection = Record<string, Post[]>;
export type PostsListProps = Pick<PaginationProps, 'baseUrl' | 'siblings'> &
Pick<NoResultsProps, 'searchPage'> & {
@@ -67,16 +66,16 @@ export type PostsListProps = Pick<PaginationProps, 'baseUrl' | 'siblings'> &
* @returns {YearCollection} The posts sorted by year.
*/
const sortPostsByYear = (data: Post[]): YearCollection => {
- const yearCollection: YearCollection = {};
+ const yearCollection: Partial<YearCollection> = {};
data.forEach((post) => {
const postYear = new Date(post.meta.dates.publication)
.getFullYear()
.toString();
- yearCollection[postYear] = [...(yearCollection[postYear] || []), post];
+ yearCollection[postYear] = [...(yearCollection[postYear] ?? []), post];
});
- return yearCollection;
+ return yearCollection as YearCollection;
};
/**
@@ -102,7 +101,6 @@ export const PostsList: FC<PostsListProps> = ({
const lastPostRef = useRef<HTMLSpanElement>(null);
const isMounted = useIsMounted(listRef);
const { blog } = useSettings();
-
const lastPostId = posts.length ? posts[posts.length - 1].id : 0;
/**
@@ -115,24 +113,22 @@ export const PostsList: FC<PostsListProps> = ({
const getList = (
allPosts: Post[],
headingLevel: HeadingLevel = 2
- ): JSX.Element => {
- return (
- <ol className={styles.list} ref={listRef}>
- {allPosts.map(({ id, ...post }) => (
- <Fragment key={id}>
- <li className={styles.item}>
- <Summary {...post} titleLevel={headingLevel} />
+ ): JSX.Element => (
+ <ol className={styles.list} ref={listRef}>
+ {allPosts.map(({ id, ...post }) => (
+ <Fragment key={id}>
+ <li className={styles.item}>
+ <Summary {...post} titleLevel={headingLevel} />
+ </li>
+ {id === lastPostId && (
+ <li>
+ <span ref={lastPostRef} tabIndex={-1} />
</li>
- {id === lastPostId && (
- <li>
- <span ref={lastPostRef} tabIndex={-1} />
- </li>
- )}
- </Fragment>
- ))}
- </ol>
- );
- };
+ )}
+ </Fragment>
+ ))}
+ </ol>
+ );
/**
* Retrieve the list of posts.
@@ -140,23 +136,21 @@ export const PostsList: FC<PostsListProps> = ({
* @returns {JSX.Element | JSX.Element[]} The posts list.
*/
const getPosts = (): JSX.Element | JSX.Element[] => {
- const firstLevel = titleLevel || 2;
+ const firstLevel = titleLevel ?? 2;
if (!byYear) return getList(posts, firstLevel);
const postsPerYear = sortPostsByYear(posts);
const years = Object.keys(postsPerYear).reverse();
const nextLevel = (firstLevel + 1) as HeadingLevel;
- return years.map((year) => {
- return (
- <section key={year} className={styles.section}>
- <Heading level={firstLevel} className={styles.year}>
- {year}
- </Heading>
- {getList(postsPerYear[year], nextLevel)}
- </section>
- );
- });
+ return years.map((year) => (
+ <section key={year} className={styles.section}>
+ <Heading level={firstLevel} className={styles.year}>
+ {year}
+ </Heading>
+ {getList(postsPerYear[year], nextLevel)}
+ </section>
+ ));
};
const progressInfo = intl.formatMessage(
@@ -166,7 +160,7 @@ export const PostsList: FC<PostsListProps> = ({
description: 'PostsList: loaded articles progress',
id: '9MeLN3',
},
- { articlesCount: posts.length, total: total }
+ { articlesCount: posts.length, total }
);
const loadMoreBody = intl.formatMessage({
@@ -178,41 +172,43 @@ export const PostsList: FC<PostsListProps> = ({
/**
* Load more posts handler.
*/
- const loadMorePosts = () => {
+ const loadMorePosts = useCallback(() => {
if (lastPostRef.current) {
lastPostRef.current.focus();
}
- loadMore && loadMore();
- };
+ if (loadMore) loadMore();
+ }, [loadMore]);
- const getProgressBar = () => {
- return (
- <>
- <ProgressBar
- aria-label={progressInfo}
- current={posts.length}
- id="loaded-posts"
- label={progressInfo}
- min={1}
- max={total}
- />
- {showLoadMoreBtn && (
- <Button
- kind="tertiary"
- onClick={loadMorePosts}
- disabled={isLoading}
- className={styles.btn}
- >
- {loadMoreBody}
- </Button>
- )}
- </>
- );
- };
+ const getProgressBar = () => (
+ <>
+ <ProgressBar
+ aria-label={progressInfo}
+ current={posts.length}
+ // eslint-disable-next-line react/jsx-no-literals -- Id allowed.
+ id="loaded-posts"
+ label={progressInfo}
+ min={1}
+ max={total}
+ />
+ {showLoadMoreBtn ? (
+ <Button
+ className={styles.btn}
+ isDisabled={isLoading}
+ // eslint-disable-next-line react/jsx-no-literals -- Kind allowed.
+ kind="tertiary"
+ onClick={loadMorePosts}
+ >
+ {loadMoreBody}
+ </Button>
+ ) : null}
+ </>
+ );
const getPagination = () => {
- return posts.length <= blog.postsPerPage ? (
+ if (posts.length < blog.postsPerPage) return null;
+
+ return (
<Pagination
baseUrl={baseUrl}
current={pageNumber}
@@ -220,19 +216,15 @@ export const PostsList: FC<PostsListProps> = ({
siblings={siblings}
total={total}
/>
- ) : (
- <></>
);
};
- if (posts.length === 0) {
- return <NoResults searchPage={searchPage} />;
- }
+ if (posts.length === 0) return <NoResults searchPage={searchPage} />;
return (
<>
{getPosts()}
- {isLoading && <Spinner />}
+ {isLoading ? <Spinner /> : null}
{isMounted ? getProgressBar() : getPagination()}
</>
);
diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx
index cacd6d2..e7a5d48 100644
--- a/src/components/organisms/layout/summary.tsx
+++ b/src/components/organisms/layout/summary.tsx
@@ -1,6 +1,6 @@
-import { FC, ReactNode } from 'react';
+import type { FC, ReactNode } from 'react';
import { useIntl } from 'react-intl';
-import { type Article, type Meta as MetaType } from '../../../types';
+import type { Article, Meta as MetaType } from '../../../types';
import { useReadingTime } from '../../../utils/hooks';
import {
Arrow,
@@ -70,6 +70,7 @@ export const Summary: FC<SummaryProps> = ({
{
title,
a11y: (chunks: ReactNode) => (
+ // eslint-disable-next-line react/jsx-no-literals -- SR class allowed
<span className="screen-reader-text">{chunks}</span>
),
}
@@ -99,7 +100,7 @@ export const Summary: FC<SummaryProps> = ({
)),
comments: {
about: title,
- count: commentsCount || 0,
+ count: commentsCount ?? 0,
target: `${url}#comments`,
},
};
@@ -107,7 +108,7 @@ export const Summary: FC<SummaryProps> = ({
return (
<article className={styles.wrapper}>
- {cover && <ResponsiveImage className={styles.cover} {...cover} />}
+ {cover ? <ResponsiveImage className={styles.cover} {...cover} /> : null}
<header className={styles.header}>
<Link href={url} className={styles.link}>
<Heading level={titleLevel} className={styles.title}>
@@ -116,13 +117,16 @@ export const Summary: FC<SummaryProps> = ({
</Link>
</header>
<div className={styles.body}>
+ {/* eslint-disable-next-line react/no-danger -- Not safe but intro can
+ * contains links or formatting so we need it. */}
<div dangerouslySetInnerHTML={{ __html: intro }} />
- <ButtonLink target={url} className={styles['read-more']}>
+ <ButtonLink className={styles['read-more']} to={url}>
<>
{readMore}
<Arrow
aria-hidden={true}
className={styles.icon}
+ // eslint-disable-next-line react/jsx-no-literals -- Direction allowed
direction="right"
/>
</>
@@ -133,7 +137,9 @@ export const Summary: FC<SummaryProps> = ({
className={styles.meta}
data={getMeta()}
groupClassName={styles.meta__item}
+ // eslint-disable-next-line react/jsx-no-literals -- Layout allowed
itemsLayout="stacked"
+ // eslint-disable-next-line react/jsx-no-literals -- Layout allowed
layout="column"
withSeparator={false}
/>
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
index 7c97901..b284e29 100644
--- a/src/components/templates/layout/layout.tsx
+++ b/src/components/templates/layout/layout.tsx
@@ -236,7 +236,7 @@ export const Layout: FC<LayoutProps> = ({
<div className={styles['noscript-spacing']} />
</noscript>
<span ref={topRef} tabIndex={-1} />
- <ButtonLink target="#main" className="screen-reader-text">
+ <ButtonLink className="screen-reader-text" to="#main">
{skipToContent}
</ButtonLink>
<Header
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 146204e..68df415 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -1,4 +1,4 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
import { ButtonLink, Heading, Link } from '../../atoms';
import { LinksListWidget, PostsList, Sharing } from '../../organisms';
import { comments } from '../../organisms/layout/comments-list.fixture';
@@ -287,7 +287,7 @@ Post.args = {
footerMeta: {
custom: {
label: 'Read more about:',
- value: <ButtonLink target="#">Topic 1</ButtonLink>,
+ value: <ButtonLink to="#">Topic 1</ButtonLink>,
},
},
children: (
@@ -335,7 +335,7 @@ Post.args = {
/>,
],
withToC: true,
- comments: comments,
+ comments,
allowComments: true,
};
@@ -363,14 +363,12 @@ Blog.args = {
title: 'Blog',
headerMeta: { total: posts.length },
children: (
- <>
- <PostsList
- posts={posts}
- byYear={true}
- total={posts.length}
- searchPage="#"
- />
- </>
+ <PostsList
+ posts={posts}
+ byYear={true}
+ total={posts.length}
+ searchPage="#"
+ />
),
widgets: [
<LinksListWidget
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 9ecd8e1..3e4c38f 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -85,13 +85,11 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
? { date: dates.update }
: undefined,
readingTime,
- thematics: thematics
- ? thematics.map((thematic) => (
- <Link key={thematic.id} href={thematic.url}>
- {thematic.name}
- </Link>
- ))
- : undefined,
+ thematics: thematics?.map((thematic) => (
+ <Link key={thematic.id} href={thematic.url}>
+ {thematic.name}
+ </Link>
+ )),
};
const footerMetaLabel = intl.formatMessage({
@@ -104,7 +102,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
custom: topics && {
label: footerMetaLabel,
value: topics.map((topic) => (
- <ButtonLink key={topic.id} target={topic.url} className={styles.btn}>
+ <ButtonLink className={styles.btn} key={topic.id} to={topic.url}>
{topic.logo ? <ResponsiveImage {...topic.logo} /> : null} {topic.name}
</ButtonLink>
)),
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index 9cecfcf..816e44e 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -40,7 +40,7 @@ const CodingLinks: FC = () => {
{
id: 'web-development',
value: (
- <ButtonLink target={ROUTES.THEMATICS.WEB_DEV}>
+ <ButtonLink to={ROUTES.THEMATICS.WEB_DEV}>
{intl.formatMessage({
defaultMessage: 'Web development',
description: 'HomePage: link to web development thematic',
@@ -52,7 +52,7 @@ const CodingLinks: FC = () => {
{
id: 'projects',
value: (
- <ButtonLink target={ROUTES.PROJECTS}>
+ <ButtonLink to={ROUTES.PROJECTS}>
{intl.formatMessage({
defaultMessage: 'Projects',
description: 'HomePage: link to projects',
@@ -82,7 +82,7 @@ const ColdarkRepos: FC = () => {
{
id: 'coldark-github',
value: (
- <ButtonLink target={repo.github} external={true}>
+ <ButtonLink isExternal to={repo.github}>
{intl.formatMessage({
defaultMessage: 'Github',
description: 'HomePage: Github link',
@@ -94,7 +94,7 @@ const ColdarkRepos: FC = () => {
{
id: 'coldark-gitlab',
value: (
- <ButtonLink target={repo.gitlab} external={true}>
+ <ButtonLink isExternal to={repo.gitlab}>
{intl.formatMessage({
defaultMessage: 'Gitlab',
description: 'HomePage: Gitlab link',
@@ -120,7 +120,7 @@ const LibreLinks: FC = () => {
{
id: 'free',
value: (
- <ButtonLink target={ROUTES.THEMATICS.FREE}>
+ <ButtonLink to={ROUTES.THEMATICS.FREE}>
{intl.formatMessage({
defaultMessage: 'Free',
description: 'HomePage: link to free thematic',
@@ -132,7 +132,7 @@ const LibreLinks: FC = () => {
{
id: 'linux',
value: (
- <ButtonLink target={ROUTES.THEMATICS.LINUX}>
+ <ButtonLink to={ROUTES.THEMATICS.LINUX}>
{intl.formatMessage({
defaultMessage: 'Linux',
description: 'HomePage: link to Linux thematic',
@@ -159,7 +159,7 @@ const ShaarliLink: FC = () => {
{
id: 'shaarli',
value: (
- <ButtonLink target={shaarliUrl}>
+ <ButtonLink isExternal to={shaarliUrl}>
{intl.formatMessage({
defaultMessage: 'Shaarli',
description: 'HomePage: link to Shaarli',
@@ -186,7 +186,7 @@ const MoreLinks: FC = () => {
{
id: 'contact-me',
value: (
- <ButtonLink target={ROUTES.CONTACT}>
+ <ButtonLink to={ROUTES.CONTACT}>
<Envelop aria-hidden={true} className={styles.icon} />
{intl.formatMessage({
defaultMessage: 'Contact me',
@@ -199,7 +199,7 @@ const MoreLinks: FC = () => {
{
id: 'rss-feed',
value: (
- <ButtonLink target={ROUTES.RSS}>
+ <ButtonLink to={ROUTES.RSS}>
<FeedIcon aria-hidden={true} className={feedIconClass} />
{intl.formatMessage({
defaultMessage: 'Subscribe',
diff --git a/src/styles/abstracts/_placeholders.scss b/src/styles/abstracts/_placeholders.scss
index 18b1c03..7729e84 100644
--- a/src/styles/abstracts/_placeholders.scss
+++ b/src/styles/abstracts/_placeholders.scss
@@ -1,4 +1,5 @@
@forward "./placeholders/animations";
+@forward "./placeholders/buttons";
@forward "./placeholders/clearfix";
@forward "./placeholders/layout";
@forward "./placeholders/list";
diff --git a/src/styles/abstracts/placeholders/_buttons.scss b/src/styles/abstracts/placeholders/_buttons.scss
new file mode 100644
index 0000000..38388a1
--- /dev/null
+++ b/src/styles/abstracts/placeholders/_buttons.scss
@@ -0,0 +1,193 @@
+@use "../functions" as fun;
+
+%button {
+ display: inline-flex;
+ place-content: center;
+ align-items: center;
+ gap: var(--spacing-2xs);
+ 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;
+
+ &[aria-busy="true"] {
+ cursor: wait;
+ }
+
+ &[aria-disabled="true"] {
+ cursor: not-allowed;
+ }
+}
+
+%primary-button {
+ 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:not(:hover) {
+ text-decoration: underline solid var(--color-fg-inverted)
+ fun.convert-px(2);
+ }
+
+ &:active,
+ &[aria-pressed="true"] {
+ 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);
+ transform: translateX(#{fun.convert-px(4)})
+ translateY(#{fun.convert-px(4)});
+
+ &:not(:hover, :focus) {
+ background: var(--color-primary-dark);
+ }
+ }
+ }
+}
+
+%secondary-button {
+ 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:not(:hover) {
+ text-decoration: underline var(--color-primary-light) fun.convert-px(3);
+ }
+
+ &:active,
+ &[aria-pressed="true"] {
+ box-shadow: 0 0 0 0 var(--color-shadow);
+ transform: scale(var(--scale-down, 0.94));
+
+ &:not(:hover, :focus) {
+ border-color: var(--color-primary-dark);
+ color: var(--color-primary-dark);
+ }
+ }
+ }
+}
+
+%tertiary-button {
+ 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:not(:hover) {
+ text-decoration: underline var(--color-primary) fun.convert-px(2);
+ }
+
+ &:active,
+ &[aria-pressed="true"] {
+ box-shadow: 0 0 0 0 var(--color-shadow);
+ transform: translateX(#{fun.convert-px(5)})
+ translateY(#{fun.convert-px(6)});
+ }
+ }
+}
+
+%circle-or-square-button {
+ width: min-content;
+ padding: var(--spacing-md);
+ aspect-ratio: 1 / 1;
+}
+
+%circle-button {
+ @extend %circle-or-square-button;
+
+ border-radius: 50%;
+}
+
+%rectangle-button {
+ padding: var(--spacing-2xs) var(--spacing-sm);
+}
+
+%square-button {
+ @extend %circle-or-square-button;
+}