diff options
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; +} |
