diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-09-27 17:38:23 +0200 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-10-24 12:25:00 +0200 |
| commit | 7255d25f6834a208c0ed44636356cc260f6ab6ba (patch) | |
| tree | 88016a958190f766a3ac0ab4b77f4732e17502e8 | |
| parent | ba793e043e4d8515b1a9ea490ee2c5f92b1fd6c2 (diff) | |
refactor(components): rewrite Heading component
* remove `alignment` and `withMargin` props (consumer should handle
that)
* move styles to Sass placeholders to avoid repeats with headings
coming from WordPress
* refactor some other components that depend on Heading to avoid ESlint
errors
30 files changed, 601 insertions, 550 deletions
diff --git a/src/components/atoms/heading/heading.module.scss b/src/components/atoms/heading/heading.module.scss new file mode 100644 index 0000000..a2e339a --- /dev/null +++ b/src/components/atoms/heading/heading.module.scss @@ -0,0 +1,27 @@ +@use "../../../styles/abstracts/placeholders"; + +.heading { + &--1 { + @extend %h1; + } + + &--2 { + @extend %h2; + } + + &--3 { + @extend %h3; + } + + &--4 { + @extend %h4; + } + + &--5 { + @extend %h5; + } + + &--6 { + @extend %h6; + } +} diff --git a/src/components/atoms/headings/heading.stories.tsx b/src/components/atoms/heading/heading.stories.tsx index 4aa79c2..c5ac4a0 100644 --- a/src/components/atoms/headings/heading.stories.tsx +++ b/src/components/atoms/heading/heading.stories.tsx @@ -1,46 +1,16 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; import { Heading } from './heading'; /** * Heading - Storybook Meta */ export default { - title: 'Atoms/Typography/Headings', + title: 'Atoms/Headings', component: Heading, args: { - alignment: 'left', isFake: false, - withMargin: true, }, argTypes: { - alignment: { - control: { - type: 'select', - }, - description: 'The title alignment.', - options: ['center', 'left'], - table: { - category: 'Options', - defaultValue: { summary: 'left' }, - }, - type: { - name: 'string', - required: false, - }, - }, - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, children: { description: 'Heading body.', type: { @@ -48,16 +18,6 @@ export default { required: true, }, }, - id: { - control: { - type: 'text', - }, - description: 'An unique id.', - type: { - name: 'string', - required: false, - }, - }, isFake: { control: { type: 'boolean', @@ -84,20 +44,6 @@ export default { required: true, }, }, - withMargin: { - control: { - type: 'boolean', - }, - description: 'Adds margin.', - table: { - category: 'Options', - defaultValue: { summary: true }, - }, - type: { - name: 'boolean', - required: false, - }, - }, }, } as ComponentMeta<typeof Heading>; diff --git a/src/components/atoms/heading/heading.test.tsx b/src/components/atoms/heading/heading.test.tsx new file mode 100644 index 0000000..39b23ad --- /dev/null +++ b/src/components/atoms/heading/heading.test.tsx @@ -0,0 +1,80 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { Heading } from './heading'; + +describe('Heading', () => { + it('renders a h1', () => { + const body = 'provident'; + + render(<Heading level={1}>{body}</Heading>); + + expect(rtlScreen.getByRole('heading', { level: 1 })).toHaveTextContent( + body + ); + }); + + it('renders a h2', () => { + const body = 'iure'; + + render(<Heading level={2}>{body}</Heading>); + + expect(rtlScreen.getByRole('heading', { level: 2 })).toHaveTextContent( + body + ); + }); + + it('renders a h3', () => { + const body = 'ut'; + + render(<Heading level={3}>{body}</Heading>); + + expect(rtlScreen.getByRole('heading', { level: 3 })).toHaveTextContent( + body + ); + }); + + it('renders a h4', () => { + const body = 'dolor'; + + render(<Heading level={4}>{body}</Heading>); + + expect(rtlScreen.getByRole('heading', { level: 4 })).toHaveTextContent( + body + ); + }); + + it('renders a h5', () => { + const body = 'temporibus'; + + render(<Heading level={5}>{body}</Heading>); + + expect(rtlScreen.getByRole('heading', { level: 5 })).toHaveTextContent( + body + ); + }); + + it('renders a h6', () => { + const body = 'at'; + + render(<Heading level={6}>{body}</Heading>); + + expect(rtlScreen.getByRole('heading', { level: 6 })).toHaveTextContent( + body + ); + }); + + it('renders a fake heading', () => { + const body = 'dignissimos'; + + render( + <Heading isFake level={2}> + {body} + </Heading> + ); + + expect( + rtlScreen.queryByRole('heading', { level: 2 }) + ).not.toBeInTheDocument(); + expect(rtlScreen.getByText(body)).toHaveClass('heading--2'); + }); +}); diff --git a/src/components/atoms/heading/heading.tsx b/src/components/atoms/heading/heading.tsx new file mode 100644 index 0000000..6cdb578 --- /dev/null +++ b/src/components/atoms/heading/heading.tsx @@ -0,0 +1,57 @@ +import { + type ForwardedRef, + forwardRef, + type ForwardRefRenderFunction, + type HTMLAttributes, + type ReactNode, +} from 'react'; +import styles from './heading.module.scss'; + +// eslint-disable-next-line @typescript-eslint/no-magic-numbers +export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; + +export type HeadingProps = HTMLAttributes<HTMLHeadingElement> & { + /** + * The heading body. + */ + children: ReactNode; + /** + * Use an heading element or only its styles. + * + * @default false + */ + isFake?: boolean; + /** + * HTML heading level. + */ + level: HeadingLevel; +}; + +const HeadingWithRef: ForwardRefRenderFunction< + HTMLHeadingElement | HTMLParagraphElement, + HeadingProps +> = ( + { children, className = '', isFake = false, level, ...props }, + ref: ForwardedRef<HTMLHeadingElement | HTMLParagraphElement> +) => { + const HeadingTag = `h${level}` as const; + const levelClass = styles[`heading--${level}`]; + const headingClass = `${levelClass} ${className}`; + + return isFake ? ( + <p {...props} className={headingClass} ref={ref}> + {children} + </p> + ) : ( + <HeadingTag {...props} className={headingClass} ref={ref}> + {children} + </HeadingTag> + ); +}; + +/** + * Heading component. + * + * Render an HTML heading element or a paragraph with heading styles. + */ +export const Heading = forwardRef(HeadingWithRef); diff --git a/src/components/atoms/headings/index.ts b/src/components/atoms/heading/index.ts index 3de265c..3de265c 100644 --- a/src/components/atoms/headings/index.ts +++ b/src/components/atoms/heading/index.ts diff --git a/src/components/atoms/headings/heading.module.scss b/src/components/atoms/headings/heading.module.scss deleted file mode 100644 index 1c898e6..0000000 --- a/src/components/atoms/headings/heading.module.scss +++ /dev/null @@ -1,69 +0,0 @@ -@use "../../../styles/abstracts/functions" as fun; - -.heading { - color: var(--color-primary-dark); - font-family: var(--font-family-secondary); - letter-spacing: 0.01ex; - - &--regular { - margin-bottom: 0; - margin-top: 0; - } - - &--left { - text-align: left; - } - - &--center { - width: fit-content; - margin-left: auto; - margin-right: auto; - } - - &--margin { - margin-top: 0; - margin-bottom: var(--spacing-sm); - - & + & { - margin-top: var(--spacing-md); - } - } - - &--1 { - font-size: var(--font-size-3xl); - font-weight: 500; - } - - &--2 { - padding-bottom: fun.convert-px(3); - background: linear-gradient( - to top, - var(--color-primary-dark) 0.3rem, - transparent 0.3rem - ) - 0 0 / 3rem 100% no-repeat; - font-size: var(--font-size-2xl); - font-weight: 500; - text-shadow: fun.convert-px(1) fun.convert-px(1) 0 var(--color-shadow-light); - } - - &--3 { - font-size: var(--font-size-xl); - font-weight: 500; - } - - &--4 { - font-size: var(--font-size-lg); - font-weight: 500; - } - - &--5 { - font-size: var(--font-size-md); - font-weight: 600; - } - - &--6 { - font-size: var(--font-size-md); - font-weight: 500; - } -} diff --git a/src/components/atoms/headings/heading.test.tsx b/src/components/atoms/headings/heading.test.tsx deleted file mode 100644 index 61d7f8e..0000000 --- a/src/components/atoms/headings/heading.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; -import { Heading } from './heading'; - -describe('Heading', () => { - it('renders a h1', () => { - render(<Heading level={1}>Level 1</Heading>); - expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( - 'Level 1' - ); - }); - - it('renders a h2', () => { - render(<Heading level={2}>Level 2</Heading>); - expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent( - 'Level 2' - ); - }); - - it('renders a h3', () => { - render(<Heading level={3}>Level 3</Heading>); - expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent( - 'Level 3' - ); - }); - - it('renders a h4', () => { - render(<Heading level={4}>Level 4</Heading>); - expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent( - 'Level 4' - ); - }); - - it('renders a h5', () => { - render(<Heading level={5}>Level 5</Heading>); - expect(screen.getByRole('heading', { level: 5 })).toHaveTextContent( - 'Level 5' - ); - }); - - it('renders a h6', () => { - render(<Heading level={6}>Level 6</Heading>); - expect(screen.getByRole('heading', { level: 6 })).toHaveTextContent( - 'Level 6' - ); - }); - - it('renders a text with heading styles', () => { - render( - <Heading isFake={true} level={2}> - Fake heading - </Heading> - ); - expect(screen.queryByRole('heading', { level: 2 })).not.toBeInTheDocument(); - expect(screen.getByText('Fake heading')).toHaveClass('heading'); - }); -}); diff --git a/src/components/atoms/headings/heading.tsx b/src/components/atoms/headings/heading.tsx deleted file mode 100644 index b1b6164..0000000 --- a/src/components/atoms/headings/heading.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { - createElement, - ForwardedRef, - forwardRef, - ForwardRefRenderFunction, - HTMLAttributes, - ReactNode, -} from 'react'; -import styles from './heading.module.scss'; - -export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; - -export type HeadingProps = HTMLAttributes<HTMLHeadingElement> & { - /** - * The title alignment. Default: left; - */ - alignment?: 'center' | 'left'; - /** - * The heading body. - */ - children: ReactNode; - /** - * Use an heading element or only its styles. Default: false. - */ - isFake?: boolean; - /** - * HTML heading level. - */ - level: HeadingLevel; - /** - * Adds margin. Default: true. - */ - withMargin?: boolean; -}; - -type TitleTagProps = Pick<HeadingProps, 'children' | 'className' | 'id'> & { - tagName: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p'; -}; - -const TitleTag = forwardRef< - HTMLHeadingElement | HTMLParagraphElement, - TitleTagProps ->( - ( - { children, tagName, ...props }, - ref: ForwardedRef<HTMLHeadingElement | HTMLParagraphElement> - ) => { - return createElement(tagName, { ...props, ref }, children); - } -); -TitleTag.displayName = 'TitleTag'; - -const HeadingWithRef: ForwardRefRenderFunction< - HTMLHeadingElement | HTMLParagraphElement, - HeadingProps -> = ( - { - alignment = 'left', - children, - className = '', - id, - isFake = false, - level, - withMargin = true, - ...props - }, - ref: ForwardedRef<HTMLHeadingElement | HTMLParagraphElement> -) => { - const tagName = isFake ? 'p' : (`h${level}` as TitleTagProps['tagName']); - const levelClass = `heading--${level}`; - const alignmentClass = `heading--${alignment}`; - const marginClass = withMargin ? 'heading--margin' : 'heading--regular'; - const headingClass = `${styles.heading} ${styles[levelClass]} ${styles[alignmentClass]} ${styles[marginClass]} ${className}`; - - return ( - <TitleTag - {...props} - className={headingClass} - id={id} - ref={ref} - tagName={tagName} - > - {children} - </TitleTag> - ); -}; - -/** - * Heading component. - * - * Render an HTML heading element or a paragraph with heading styles. - */ -export const Heading = forwardRef(HeadingWithRef); diff --git a/src/components/atoms/index.ts b/src/components/atoms/index.ts index d9cf865..672440c 100644 --- a/src/components/atoms/index.ts +++ b/src/components/atoms/index.ts @@ -1,6 +1,6 @@ export * from './buttons'; export * from './forms'; -export * from './headings'; +export * from './heading'; export * from './icons'; export * from './images'; export * from './layout'; diff --git a/src/components/atoms/layout/section/section.module.scss b/src/components/atoms/layout/section/section.module.scss index 771b8e3..3da74a2 100644 --- a/src/components/atoms/layout/section/section.module.scss +++ b/src/components/atoms/layout/section/section.module.scss @@ -21,5 +21,6 @@ > * { grid-column: 2; + margin-block: 0; } } diff --git a/src/components/atoms/layout/section/section.stories.tsx b/src/components/atoms/layout/section/section.stories.tsx index 0a3388b..e21209b 100644 --- a/src/components/atoms/layout/section/section.stories.tsx +++ b/src/components/atoms/layout/section/section.stories.tsx @@ -1,5 +1,5 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { Heading } from '../../headings'; +import { Heading } from '../../heading'; import { Section } from './section'; /** diff --git a/src/components/atoms/modal/modal.stories.tsx b/src/components/atoms/modal/modal.stories.tsx index d0c2f0b..0490a8f 100644 --- a/src/components/atoms/modal/modal.stories.tsx +++ b/src/components/atoms/modal/modal.stories.tsx @@ -1,6 +1,6 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Heading } from '../heading'; import { Modal } from './modal'; -import { Heading } from '../headings'; /** * Switch - Storybook Meta diff --git a/src/components/atoms/modal/modal.test.tsx b/src/components/atoms/modal/modal.test.tsx index 6e7d29e..dfa4a88 100644 --- a/src/components/atoms/modal/modal.test.tsx +++ b/src/components/atoms/modal/modal.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; -import { Heading } from '../headings'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import { Heading } from '../heading'; import { Modal } from './modal'; const title = 'A custom title'; @@ -16,11 +16,11 @@ describe('Modal', () => { {children} </Modal> ); - expect(screen.getByRole('heading', { level })).toHaveTextContent(title); + expect(rtlScreen.getByRole('heading', { level })).toHaveTextContent(title); }); it('renders the modal body', () => { render(<Modal>{children}</Modal>); - expect(screen.getByText(children)).toBeInTheDocument(); + expect(rtlScreen.getByText(children)).toBeInTheDocument(); }); }); diff --git a/src/components/atoms/modal/modal.tsx b/src/components/atoms/modal/modal.tsx index 78b4f6e..6f5506f 100644 --- a/src/components/atoms/modal/modal.tsx +++ b/src/components/atoms/modal/modal.tsx @@ -1,11 +1,11 @@ import { - ForwardRefRenderFunction, - HTMLAttributes, - ReactElement, - ReactNode, + type ForwardRefRenderFunction, + type HTMLAttributes, + type ReactElement, + type ReactNode, forwardRef, } from 'react'; -import { HeadingProps } from '../headings'; +import type { HeadingProps } from '../heading'; import styles from './modal.module.scss'; export type ModalProps = HTMLAttributes<HTMLDivElement> & { diff --git a/src/components/molecules/buttons/heading-button.tsx b/src/components/molecules/buttons/heading-button.tsx index 93ccdbe..97e2c84 100644 --- a/src/components/molecules/buttons/heading-button.tsx +++ b/src/components/molecules/buttons/heading-button.tsx @@ -1,4 +1,4 @@ -import { FC, SetStateAction } from 'react'; +import { useCallback, type FC, type SetStateAction } from 'react'; import { useIntl } from 'react-intl'; import { Heading, type HeadingProps, PlusMinus } from '../../atoms'; import styles from './heading-button.module.scss'; @@ -35,6 +35,7 @@ export const HeadingButton: FC<HeadingButtonProps> = ({ title, }) => { const intl = useIntl(); + const btnClass = `${styles.wrapper} ${className}`; const iconState = expanded ? 'minus' : 'plus'; const titlePrefix = expanded ? intl.formatMessage({ @@ -48,13 +49,15 @@ export const HeadingButton: FC<HeadingButtonProps> = ({ id: 'bcyOgC', }); + const toggleExpand = useCallback( + () => setExpanded((prev) => !prev), + [setExpanded] + ); + return ( - <button - className={`${styles.wrapper} ${className}`} - onClick={() => setExpanded(!expanded)} - type="button" - > - <Heading level={level} withMargin={false} className={styles.heading}> + <button className={btnClass} onClick={toggleExpand} type="button"> + <Heading className={styles.heading} level={level}> + {/* eslint-disable-next-line react/jsx-no-literals -- SR class allowed */} <span className="screen-reader-text">{titlePrefix} </span> {title} </Heading> diff --git a/src/components/molecules/layout/branding.tsx b/src/components/molecules/layout/branding.tsx index b105796..981da74 100644 --- a/src/components/molecules/layout/branding.tsx +++ b/src/components/molecules/layout/branding.tsx @@ -1,5 +1,5 @@ import Link from 'next/link'; -import { FC, useRef } from 'react'; +import { type FC, useRef } from 'react'; import { useIntl } from 'react-intl'; import { useStyles } from '../../../utils/hooks'; import { Heading } from '../../atoms'; @@ -90,7 +90,6 @@ export const Branding: FC<BrandingProps> = ({ className={styles.title} isFake={!isHome} level={1} - withMargin={false} ref={titleRef} > {withLink ? ( @@ -101,17 +100,11 @@ export const Branding: FC<BrandingProps> = ({ title )} </Heading> - {baseline && ( - <Heading - className={styles.baseline} - isFake={true} - level={4} - withMargin={false} - ref={baselineRef} - > + {baseline ? ( + <Heading className={styles.baseline} isFake level={4} ref={baselineRef}> {baseline} </Heading> - )} + ) : null} </div> ); }; diff --git a/src/components/molecules/layout/card.module.scss b/src/components/molecules/layout/card.module.scss index 8f9f4a5..31f6a4b 100644 --- a/src/components/molecules/layout/card.module.scss +++ b/src/components/molecules/layout/card.module.scss @@ -31,9 +31,8 @@ } .title { - flex: 1; - margin-top: var(--spacing-sm); - margin-bottom: var(--spacing-sm); + width: fit-content; + margin: var(--spacing-sm) auto; } h2.title { diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx index f39a430..722e5a5 100644 --- a/src/components/molecules/layout/card.tsx +++ b/src/components/molecules/layout/card.tsx @@ -65,13 +65,7 @@ export const Card: FC<CardProps> = ({ {cover ? ( <ResponsiveImage {...cover} className={styles.cover} /> ) : null} - <Heading - // eslint-disable-next-line react/jsx-no-literals -- Hardcoded config - alignment="center" - className={styles.title} - id={headingId} - level={titleLevel} - > + <Heading className={styles.title} id={headingId} level={titleLevel}> {title} </Heading> </header> diff --git a/src/components/molecules/layout/page-header.tsx b/src/components/molecules/layout/page-header.tsx index 9c11feb..04f2966 100644 --- a/src/components/molecules/layout/page-header.tsx +++ b/src/components/molecules/layout/page-header.tsx @@ -1,4 +1,4 @@ -import { FC, ReactNode } from 'react'; +import type { FC, ReactNode } from 'react'; import { Heading } from '../../atoms'; import { Meta, type MetaData } from './meta'; import styles from './page-header.module.scss'; @@ -11,7 +11,7 @@ export type PageHeaderProps = { /** * The page introduction. */ - intro?: string | JSX.Element; + intro?: string | ReactNode; /** * The page metadata. */ @@ -33,32 +33,39 @@ export const PageHeader: FC<PageHeaderProps> = ({ meta, title, }) => { + const headerClass = `${styles.wrapper} ${className}`; + const getIntro = () => { - return typeof intro === 'string' ? ( - <div - className={styles.intro} - dangerouslySetInnerHTML={{ __html: intro }} - /> - ) : ( - <div className={styles.intro}>{intro}</div> - ); + if (typeof intro === 'string') + return ( + <div + className={styles.intro} + /* eslint-disable-next-line react/no-danger -- Not safe but intro can + * contains links or formatting so we need it. */ + dangerouslySetInnerHTML={{ __html: intro }} + /> + ); + + return <div className={styles.intro}>{intro}</div>; }; return ( - <header className={`${styles.wrapper} ${className}`}> + <header className={headerClass}> <div className={styles.body}> - <Heading level={1} className={styles.title} withMargin={false}> + <Heading className={styles.title} level={1}> {title} </Heading> - {meta && ( + {meta ? ( <Meta - data={meta} className={styles.meta} - layout="column" + data={meta} + // eslint-disable-next-line react/jsx-no-literals -- Layout allowed itemsLayout="inline" + // eslint-disable-next-line react/jsx-no-literals -- Layout allowed + layout="column" /> - )} - {intro && getIntro()} + ) : null} + {intro ? getIntro() : null} </div> </header> ); diff --git a/src/components/organisms/forms/comment-form/comment-form.module.scss b/src/components/organisms/forms/comment-form/comment-form.module.scss index fbf8c96..a4de51e 100644 --- a/src/components/organisms/forms/comment-form/comment-form.module.scss +++ b/src/components/organisms/forms/comment-form/comment-form.module.scss @@ -8,6 +8,12 @@ } } +.title { + width: fit-content; + margin-inline: auto; + margin-bottom: var(--spacing-sm); +} + .field { width: 100%; } diff --git a/src/components/organisms/forms/comment-form/comment-form.tsx b/src/components/organisms/forms/comment-form/comment-form.tsx index be5d58f..e645ede 100644 --- a/src/components/organisms/forms/comment-form/comment-form.tsx +++ b/src/components/organisms/forms/comment-form/comment-form.tsx @@ -1,4 +1,14 @@ -import { ChangeEvent, FC, FormEvent, ReactNode, useState } from 'react'; +/* eslint-disable max-statements */ +import { + type ChangeEvent, + type FC, + type FormEvent, + type ReactNode, + useCallback, + useMemo, + useState, + useId, +} from 'react'; import { useIntl } from 'react-intl'; import { Button, @@ -6,7 +16,6 @@ import { type FormProps, Heading, type HeadingLevel, - type HeadingProps, Spinner, Input, TextArea, @@ -42,10 +51,6 @@ export type CommentFormProps = Pick<FormProps, 'className'> & { */ title?: string; /** - * The form title alignment. Default: left. - */ - titleAlignment?: HeadingProps['alignment']; - /** * The title level. Default: 2. */ titleLevel?: HeadingLevel; @@ -57,29 +62,30 @@ export const CommentForm: FC<CommentFormProps> = ({ parentId, saveComment, title, - titleAlignment, titleLevel = 2, ...props }) => { const formClass = `${styles.form} ${className}`; const intl = useIntl(); - const emptyForm: CommentFormData = { - author: '', - comment: '', - email: '', - parentId, - website: '', - }; + const emptyForm: CommentFormData = useMemo(() => { + return { + author: '', + comment: '', + email: '', + parentId, + website: '', + }; + }, [parentId]); const [data, setData] = useState(emptyForm); const [isSubmitting, setIsSubmitting] = useState<boolean>(false); /** * Reset all the form fields. */ - const resetForm = () => { + const resetForm = useCallback(() => { setData(emptyForm); setIsSubmitting(false); - }; + }, [emptyForm]); const nameLabel = intl.formatMessage({ defaultMessage: 'Name:', @@ -112,43 +118,47 @@ export const CommentForm: FC<CommentFormProps> = ({ }); const formAriaLabel = title ? undefined : formTitle; - const formId = 'comment-form-title'; + const formId = useId(); const formLabelledBy = title ? formId : undefined; - const updateForm = ( - e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> - ) => { - switch (e.target.name) { - case 'author': - setData((prevData) => { - return { ...prevData, author: e.target.value }; - }); - break; - case 'comment': - setData((prevData) => { - return { ...prevData, comment: e.target.value }; - }); - break; - case 'email': - setData((prevData) => { - return { ...prevData, email: e.target.value }; - }); - break; - case 'website': - setData((prevData) => { - return { ...prevData, website: e.target.value }; - }); - break; - default: - break; - } - }; + const updateForm = useCallback( + (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { + switch (e.target.name) { + case 'author': + setData((prevData) => { + return { ...prevData, author: e.target.value }; + }); + break; + case 'comment': + setData((prevData) => { + return { ...prevData, comment: e.target.value }; + }); + break; + case 'email': + setData((prevData) => { + return { ...prevData, email: e.target.value }; + }); + break; + case 'website': + setData((prevData) => { + return { ...prevData, website: e.target.value }; + }); + break; + default: + break; + } + }, + [] + ); - const submitHandler = (e: FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - saveComment(data, resetForm).then(() => setIsSubmitting(false)); - }; + const sendForm = useCallback( + (e: FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + saveComment(data, resetForm).then(() => setIsSubmitting(false)); + }, + [data, resetForm, saveComment] + ); return ( <Form @@ -156,13 +166,13 @@ export const CommentForm: FC<CommentFormProps> = ({ aria-label={formAriaLabel} aria-labelledby={formLabelledBy} className={formClass} - onSubmit={submitHandler} + onSubmit={sendForm} > - {title && ( - <Heading alignment={titleAlignment} id={formId} level={titleLevel}> + {title ? ( + <Heading className={styles.title} id={formId} level={titleLevel}> {title} </Heading> - )} + ) : null} <LabelledField className={styles.field} field={ @@ -236,7 +246,7 @@ export const CommentForm: FC<CommentFormProps> = ({ id: 'OL0Yzx', })} </Button> - {isSubmitting && ( + {isSubmitting ? ( <Spinner message={intl.formatMessage({ defaultMessage: 'Submitting...', @@ -244,7 +254,7 @@ export const CommentForm: FC<CommentFormProps> = ({ id: 'IY5ew6', })} /> - )} + ) : null} {Notice} </Form> ); diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss index d29df2c..09bb957 100644 --- a/src/components/templates/page/page-layout.module.scss +++ b/src/components/templates/page/page-layout.module.scss @@ -81,6 +81,12 @@ } } + &__title { + width: fit-content; + margin-bottom: var(--spacing-md); + margin-inline: auto; + } + &__no-comments { text-align: center; } @@ -90,3 +96,7 @@ margin: auto; } } + +.notice { + margin-top: var(--spacing-sm); +} diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx index 72bfd3f..dfd9353 100644 --- a/src/components/templates/page/page-layout.tsx +++ b/src/components/templates/page/page-layout.tsx @@ -1,15 +1,22 @@ import Script from 'next/script'; -import { FC, HTMLAttributes, ReactNode, useRef, useState } from 'react'; +import { + type FC, + type HTMLAttributes, + type ReactNode, + useRef, + useState, + useCallback, +} from 'react'; import { useIntl } from 'react-intl'; -import { BreadcrumbList } from 'schema-dts'; +import type { BreadcrumbList } from 'schema-dts'; import { sendComment } from '../../../services/graphql'; -import { SendCommentInput } from '../../../types'; +import type { Approved, SendCommentInput, SingleComment } from '../../../types'; import { useIsMounted } from '../../../utils/hooks'; import { Heading, Notice, type NoticeKind, Sidebar } from '../../atoms'; import { Breadcrumb, type BreadcrumbItem, - MetaData, + type MetaData, PageFooter, type PageFooterProps, PageHeader, @@ -24,6 +31,29 @@ import { } from '../../organisms'; import styles from './page-layout.module.scss'; +/** + * Check if there is at least one comment. + * + * @param {SingleComment[] | undefined} comments - The comments. + */ +const hasComments = ( + comments: SingleComment[] | undefined +): comments is SingleComment[] => + Array.isArray(comments) && comments.length > 0; + +/** + * Check if meta properties are defined. + * + * @param {MetaData} meta - The metadata. + */ +const hasMeta = (meta: MetaData) => Object.values(meta).every((value) => value); + +type CommentStatus = { + isReply: boolean; + kind: NoticeKind; + message: string; +}; + export type PageLayoutProps = { /** * True if the page accepts new comments. Default: false. @@ -118,67 +148,69 @@ export const PageLayout: FC<PageLayoutProps> = ({ const bodyRef = useRef<HTMLDivElement>(null); const isMounted = useIsMounted(bodyRef); - const hasComments = Array.isArray(comments) && comments.length > 0; - const [status, setStatus] = useState<NoticeKind>('info'); - const [statusMessage, setStatusMessage] = useState<string>(''); - const isReplyRef = useRef<boolean>(false); - - const saveComment: CommentFormProps['saveComment'] = async (data, reset) => { - if (!id) throw new Error('Page id missing. Cannot save comment.'); - - const { author, comment: commentBody, email, parentId, website } = data; - const commentData: SendCommentInput = { - author, - authorEmail: email, - authorUrl: website ?? '', - clientMutationId: 'contact', - commentOn: id, - content: commentBody, - parent: parentId, - }; - const { comment, success } = await sendComment(commentData); + const [commentStatus, setCommentStatus] = useState<CommentStatus | undefined>( + undefined + ); - isReplyRef.current = !!parentId; + const isSuccessStatus = useCallback( + (comment: Approved | null, isReply: boolean, isSuccess: boolean) => { + if (isSuccess) { + const successPrefix = intl.formatMessage({ + defaultMessage: 'Thanks, your comment was successfully sent.', + description: 'PageLayout: comment form success message', + id: 'B290Ph', + }); + const successMessage = comment?.approved + ? intl.formatMessage({ + defaultMessage: 'It has been approved.', + id: 'g3+Ahv', + description: 'PageLayout: comment approved.', + }) + : intl.formatMessage({ + defaultMessage: 'It is now awaiting moderation.', + id: 'Vmj5cw', + description: 'PageLayout: comment awaiting moderation', + }); + setCommentStatus({ + isReply, + kind: 'success', + message: `${successPrefix} ${successMessage}`, + }); + return true; + } - if (success) { - setStatus('success'); - const successPrefix = intl.formatMessage({ - defaultMessage: 'Thanks, your comment was successfully sent.', - description: 'PageLayout: comment form success message', - id: 'B290Ph', - }); - const successMessage = comment?.approved - ? intl.formatMessage({ - defaultMessage: 'It has been approved.', - id: 'g3+Ahv', - description: 'PageLayout: comment approved.', - }) - : intl.formatMessage({ - defaultMessage: 'It is now awaiting moderation.', - id: 'Vmj5cw', - description: 'PageLayout: comment awaiting moderation', - }); - setStatusMessage(`${successPrefix} ${successMessage}`); - reset(); - } else { const error = intl.formatMessage({ defaultMessage: 'An error occurred:', description: 'PageLayout: comment form error message', id: 'fkcTGp', }); - setStatus('error'); - setStatusMessage(error); - } - }; + setCommentStatus({ isReply, kind: 'error', message: error }); + return false; + }, + [intl] + ); - /** - * Check if meta properties are defined. - * - * @param {MetaData} meta - The metadata. - */ - const hasMeta = (meta: MetaData) => { - return Object.values(meta).every((value) => value); - }; + const saveComment: CommentFormProps['saveComment'] = useCallback( + async (data, reset) => { + if (!id) throw new Error('Page id missing. Cannot save comment.'); + + const { author, comment: commentBody, email, parentId, website } = data; + const isReply = !!parentId; + const commentData: SendCommentInput = { + author, + authorEmail: email, + authorUrl: website ?? '', + clientMutationId: 'comment', + commentOn: id, + content: commentBody, + parent: parentId, + }; + const { comment, success } = await sendComment(commentData); + + if (isSuccessStatus(comment, isReply, success)) reset(); + }, + [id, isSuccessStatus] + ); return ( <> @@ -198,7 +230,7 @@ export const PageLayout: FC<PageLayoutProps> = ({ meta={headerMeta} title={title} /> - {withToC && ( + {withToC ? ( <Sidebar aria-label={intl.formatMessage({ defaultMessage: 'Table of contents sidebar', @@ -207,11 +239,11 @@ export const PageLayout: FC<PageLayoutProps> = ({ })} className={`${styles.sidebar} ${styles['sidebar--first']}`} > - {isMounted && bodyRef.current && ( + {isMounted && bodyRef.current ? ( <TableOfContents wrapper={bodyRef.current} /> - )} + ) : null} </Sidebar> - )} + ) : null} {typeof children === 'string' ? ( <div {...bodyAttributes} @@ -224,9 +256,9 @@ export const PageLayout: FC<PageLayoutProps> = ({ {children} </div> )} - {footerMeta && hasMeta(footerMeta) && ( + {footerMeta && hasMeta(footerMeta) ? ( <PageFooter meta={footerMeta} className={styles.footer} /> - )} + ) : null} <Sidebar aria-label={intl.formatMessage({ defaultMessage: 'Sidebar', @@ -237,22 +269,22 @@ export const PageLayout: FC<PageLayoutProps> = ({ > {widgets} </Sidebar> - {allowComments && ( + {allowComments ? ( <div className={styles.comments} id="comments"> <section className={styles.comments__section}> - <Heading level={2} alignment="center"> + <Heading className={styles.comments__title} level={2}> {commentsTitle} </Heading> - {hasComments ? ( + {hasComments(comments) ? ( <CommentsList comments={comments} depth={2} Notice={ - isReplyRef.current === true && statusMessage ? ( + commentStatus?.isReply ? ( <Notice className={styles.notice} - kind={status} - message={statusMessage} + kind={commentStatus.kind} + message={commentStatus.message} /> ) : null } @@ -273,20 +305,19 @@ export const PageLayout: FC<PageLayoutProps> = ({ className={styles.comments__form} saveComment={saveComment} title={commentFormTitle} - titleAlignment="center" Notice={ - isReplyRef.current === false && statusMessage ? ( + commentStatus && !commentStatus.isReply ? ( <Notice className={styles.notice} - kind={status} - message={statusMessage} + kind={commentStatus.kind} + message={commentStatus.message} /> ) : null } /> </section> </div> - )} + ) : null} </> ); }; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index c06fb7e..d99462f 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -2,7 +2,7 @@ import type { MDXComponents } from 'mdx/types'; import type { GetStaticProps } from 'next'; import Head from 'next/head'; import Script from 'next/script'; -import type { FC } from 'react'; +import type { FC, HTMLAttributes } from 'react'; import { useIntl } from 'react-intl'; import FeedIcon from '../assets/images/icon-feed.svg'; import { @@ -19,6 +19,7 @@ import { ResponsiveImage, Section, type SectionProps, + Heading, } from '../components'; import HomePageContent from '../content/pages/homepage.mdx'; import { getArticlesCard } from '../services/graphql'; @@ -29,6 +30,60 @@ import { getSchemaJson, getWebPageSchema } from '../utils/helpers'; import { loadTranslation, type Messages } from '../utils/helpers/server'; import { useBreadcrumb, useSettings } from '../utils/hooks'; +const H1 = ({ + children = '', + ...props +}: HTMLAttributes<HTMLHeadingElement>) => ( + <Heading {...props} level={1}> + {children} + </Heading> +); + +const H2 = ({ + children = '', + ...props +}: HTMLAttributes<HTMLHeadingElement>) => ( + <Heading {...props} level={2}> + {children} + </Heading> +); + +const H3 = ({ + children = '', + ...props +}: HTMLAttributes<HTMLHeadingElement>) => ( + <Heading {...props} level={3}> + {children} + </Heading> +); + +const H4 = ({ + children = '', + ...props +}: HTMLAttributes<HTMLHeadingElement>) => ( + <Heading {...props} level={4}> + {children} + </Heading> +); + +const H5 = ({ + children = '', + ...props +}: HTMLAttributes<HTMLHeadingElement>) => ( + <Heading {...props} level={5}> + {children} + </Heading> +); + +const H6 = ({ + children = '', + ...props +}: HTMLAttributes<HTMLHeadingElement>) => ( + <Heading {...props} level={6}> + {children} + </Heading> +); + /** * Retrieve a list of coding links. * @@ -276,6 +331,12 @@ const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => { ColdarkRepos, Column, Columns: StyledColumns, + h1: H1, + h2: H2, + h3: H3, + h4: H4, + h5: H5, + h6: H6, Image: ResponsiveImage, LibreLinks, MoreLinks, diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx index 9981868..afcf060 100644 --- a/src/pages/projets/[slug].tsx +++ b/src/pages/projets/[slug].tsx @@ -5,7 +5,7 @@ import dynamic from 'next/dynamic'; import Head from 'next/head'; import { useRouter } from 'next/router'; import Script from 'next/script'; -import type { ComponentType } from 'react'; +import type { ComponentType, HTMLAttributes } from 'react'; import { useIntl } from 'react-intl'; import { Code, @@ -22,6 +22,7 @@ import { type SocialWebsite, Spinner, type MetaData, + Heading, } from '../../components'; import styles from '../../styles/pages/project.module.scss'; import type { NextPageWithLayout, ProjectPreview, Repos } from '../../types'; @@ -44,9 +45,69 @@ const BorderedImage = (props: ResponsiveImageProps) => ( <ResponsiveImage withBorders={true} {...props} /> ); +const H1 = ({ + children = '', + ...props +}: HTMLAttributes<HTMLHeadingElement>) => ( + <Heading {...props} level={1}> + {children} + </Heading> +); + +const H2 = ({ + children = '', + ...props +}: HTMLAttributes<HTMLHeadingElement>) => ( + <Heading {...props} level={2}> + {children} + </Heading> +); + +const H3 = ({ + children = '', + ...props +}: HTMLAttributes<HTMLHeadingElement>) => ( + <Heading {...props} level={3}> + {children} + </Heading> +); + +const H4 = ({ + children = '', + ...props +}: HTMLAttributes<HTMLHeadingElement>) => ( + <Heading {...props} level={4}> + {children} + </Heading> +); + +const H5 = ({ + children = '', + ...props +}: HTMLAttributes<HTMLHeadingElement>) => ( + <Heading {...props} level={5}> + {children} + </Heading> +); + +const H6 = ({ + children = '', + ...props +}: HTMLAttributes<HTMLHeadingElement>) => ( + <Heading {...props} level={6}> + {children} + </Heading> +); + const components: MDXComponents = { Code, Gallery, + h1: H1, + h2: H2, + h3: H3, + h4: H4, + h5: H5, + h6: H6, Image: BorderedImage, Link, }; diff --git a/src/styles/abstracts/_placeholders.scss b/src/styles/abstracts/_placeholders.scss index 7729e84..76fdbd6 100644 --- a/src/styles/abstracts/_placeholders.scss +++ b/src/styles/abstracts/_placeholders.scss @@ -1,5 +1,6 @@ @forward "./placeholders/animations"; @forward "./placeholders/buttons"; @forward "./placeholders/clearfix"; +@forward "./placeholders/headings"; @forward "./placeholders/layout"; @forward "./placeholders/list"; diff --git a/src/styles/abstracts/placeholders/_headings.scss b/src/styles/abstracts/placeholders/_headings.scss new file mode 100644 index 0000000..a836c74 --- /dev/null +++ b/src/styles/abstracts/placeholders/_headings.scss @@ -0,0 +1,54 @@ +@use "../functions" as fun; + +%heading { + margin: 0; + color: var(--color-primary-dark); + font-family: var(--font-family-secondary); + font-weight: 500; + letter-spacing: 0.01ex; +} + +%h1 { + @extend %heading; + + font-size: var(--font-size-3xl); +} + +%h2 { + @extend %heading; + + padding-bottom: fun.convert-px(3); + background: linear-gradient( + to top, + var(--color-primary-dark) 0.3rem, + transparent 0.3rem + ) + 0 0 / 3rem 100% no-repeat; + font-size: var(--font-size-2xl); + text-shadow: fun.convert-px(1) fun.convert-px(1) 0 var(--color-shadow-light); +} + +%h3 { + @extend %heading; + + font-size: var(--font-size-xl); +} + +%h4 { + @extend %heading; + + font-size: var(--font-size-lg); +} + +%h5 { + @extend %heading; + + font-size: var(--font-size-md); + font-weight: 600; +} + +%h6 { + @extend %heading; + + font-size: var(--font-size-md); +} diff --git a/src/styles/base/_typography.scss b/src/styles/base/_typography.scss index dc958c4..11b506f 100644 --- a/src/styles/base/_typography.scss +++ b/src/styles/base/_typography.scss @@ -1,61 +1,5 @@ @use "../abstracts/functions" as fun; -h1 { - font-size: var(--font-size-3xl); -} - -h2 { - padding-bottom: fun.convert-px(3); - background: linear-gradient( - to top, - var(--color-primary-dark) 0.3rem, - transparent 0.3rem - ) - 0 0 / 3rem 100% no-repeat; - font-size: var(--font-size-2xl); - text-shadow: fun.convert-px(1) fun.convert-px(1) 0 var(--color-shadow-light); -} - -h3 { - font-size: var(--font-size-xl); -} - -h4 { - font-size: var(--font-size-lg); -} - -h5 { - font-size: var(--font-size-md); - font-weight: 600; -} - -h6 { - font-size: var(--font-size-md); -} - -h1, -h2, -h3, -h4, -h5, -h6 { - color: var(--color-primary-dark); - font-family: var(--font-family-secondary); - font-weight: 500; - letter-spacing: 0.01ex; - margin: 0 0 var(--spacing-sm); - - * + { - h2, - h3, - h4, - h5, - h6 { - margin-top: var(--spacing-md); - } - } -} - p { font-size: var(--font-size-md); margin: 0 0 var(--spacing-sm); @@ -132,7 +76,9 @@ a { color: var(--color-primary); text-decoration-thickness: 0.15em; text-underline-offset: 20%; - transition: all 0.3s linear 0s, text-decoration 0.18s ease-in-out 0s; + transition: + all 0.3s linear 0s, + text-decoration 0.18s ease-in-out 0s; &:hover { color: var(--color-primary-light); diff --git a/src/styles/pages/article.module.scss b/src/styles/pages/article.module.scss index 48aed79..088718f 100644 --- a/src/styles/pages/article.module.scss +++ b/src/styles/pages/article.module.scss @@ -1,5 +1,6 @@ @use "../abstracts/functions" as fun; @use "../abstracts/mixins" as mix; +@use "partials/article-headings"; @use "partials/article-links"; @use "partials/article-lists"; @use "partials/article-media"; @@ -18,6 +19,7 @@ .body { :global { + @include article-headings.styles; @include article-links.styles; @include article-lists.styles; @include article-media.styles; diff --git a/src/styles/pages/partials/_article-headings.scss b/src/styles/pages/partials/_article-headings.scss index dfeceb7..7a273e4 100644 --- a/src/styles/pages/partials/_article-headings.scss +++ b/src/styles/pages/partials/_article-headings.scss @@ -1,42 +1,28 @@ -@use "../../abstracts/functions" as fun; +@use "../../abstracts/placeholders"; @mixin styles { h1 { - font-size: var(--font-size-3xl); - font-weight: 500; + @extend %h1; } h2 { - padding-bottom: fun.convert-px(3); - background: linear-gradient( - to top, - var(--color-primary-dark) 0.3rem, - transparent 0.3rem - ) - 0 0 / 3rem 100% no-repeat; - font-size: var(--font-size-2xl); - font-weight: 500; - text-shadow: fun.convert-px(1) fun.convert-px(1) 0 var(--color-shadow-light); + @extend %h2; } h3 { - font-size: var(--font-size-xl); - font-weight: 500; + @extend %h3; } h4 { - font-size: var(--font-size-lg); - font-weight: 500; + @extend %h4; } h5 { - font-size: var(--font-size-md); - font-weight: 600; + @extend %h5; } h6 { - font-size: var(--font-size-md); - font-weight: 500; + @extend %h6; } h1, @@ -45,13 +31,8 @@ h4, h5, h6 { - color: var(--color-primary-dark); - font-family: var(--font-family-secondary); - letter-spacing: 0.01ex; - margin: 0 0 var(--spacing-sm); - - & + & { - margin-top: var(--spacing-md); + &:not(:first-child) { + margin-block: var(--spacing-sm); } } } |
