diff options
18 files changed, 312 insertions, 190 deletions
diff --git a/src/components/atoms/images/icons/svg-paths/icons-paths/arrow-icon-paths.tsx b/src/components/atoms/images/icons/svg-paths/icons-paths/arrow-icon-paths.tsx index ee29d5d..0dc701a 100644 --- a/src/components/atoms/images/icons/svg-paths/icons-paths/arrow-icon-paths.tsx +++ b/src/components/atoms/images/icons/svg-paths/icons-paths/arrow-icon-paths.tsx @@ -1,7 +1,8 @@ /* eslint-disable react/jsx-no-literals */ import type { FC } from 'react'; +import type { Position } from '../../../../../types'; -export type ArrowOrientation = 'top' | 'right' | 'bottom' | 'left'; +export type ArrowOrientation = Exclude<Position, 'center'>; const getArrowBarPathFrom = (orientation: ArrowOrientation) => { switch (orientation) { diff --git a/src/components/atoms/loaders/spinner.module.scss b/src/components/atoms/loaders/spinner.module.scss deleted file mode 100644 index 3e05cb3..0000000 --- a/src/components/atoms/loaders/spinner.module.scss +++ /dev/null @@ -1,48 +0,0 @@ -@use "../../../styles/abstracts/functions" as fun; - -.wrapper { - display: flex; - flex-flow: row wrap; - align-items: center; - justify-content: center; - gap: var(--spacing-2xs); - margin: var(--spacing-md) 0; -} - -.ball { - width: fun.convert-px(8); - height: fun.convert-px(8); - background: linear-gradient( - to right, - var(--color-primary-light) 0%, - var(--color-primary-lighter) 100% - ); - border-radius: 50%; - animation: spinner 1.4s infinite ease-in-out both; - - &:first-child { - animation-delay: -0.32s; - } - - &:nth-child(2) { - animation-delay: -0.16s; - } -} - -.text { - margin-left: var(--spacing-xs); - color: var(--color-primary-darker); - text-align: center; -} - -@keyframes spinner { - 0%, - 80%, - 100% { - transform: scale(0); - } - - 40% { - transform: scale(1); - } -} diff --git a/src/components/atoms/loaders/spinner.test.tsx b/src/components/atoms/loaders/spinner.test.tsx deleted file mode 100644 index 553c3ef..0000000 --- a/src/components/atoms/loaders/spinner.test.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; -import { Spinner } from './spinner'; - -describe('Spinner', () => { - it('renders a spinner loader', () => { - render(<Spinner />); - expect(screen.getByText('Loading...')).toBeInTheDocument(); - }); - - it('renders a spinner loader with a custom message', () => { - render(<Spinner message="Submitting" />); - expect(screen.getByText('Submitting')).toBeInTheDocument(); - }); -}); diff --git a/src/components/atoms/loaders/spinner.tsx b/src/components/atoms/loaders/spinner.tsx deleted file mode 100644 index 968290b..0000000 --- a/src/components/atoms/loaders/spinner.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { FC } from 'react'; -import { useIntl } from 'react-intl'; -import styles from './spinner.module.scss'; - -export type SpinnerProps = { - /** - * The loading message. Default: "Loading...". - */ - message?: string; -}; - -/** - * Spinner component - * - * Render a loading message with animation. - */ -export const Spinner: FC<SpinnerProps> = ({ message }) => { - const intl = useIntl(); - - return ( - <div className={styles.wrapper}> - <div className={styles.ball}></div> - <div className={styles.ball}></div> - <div className={styles.ball}></div> - <div className={styles.text}> - {message ?? - intl.formatMessage({ - defaultMessage: 'Loading...', - description: 'Spinner: loading text', - id: 'q9cJQe', - })} - </div> - </div> - ); -}; diff --git a/src/components/atoms/loaders/spinner/index.ts b/src/components/atoms/loaders/spinner/index.ts new file mode 100644 index 0000000..cd17217 --- /dev/null +++ b/src/components/atoms/loaders/spinner/index.ts @@ -0,0 +1 @@ +export * from './spinner'; diff --git a/src/components/atoms/loaders/spinner/spinner.module.scss b/src/components/atoms/loaders/spinner/spinner.module.scss new file mode 100644 index 0000000..97882a4 --- /dev/null +++ b/src/components/atoms/loaders/spinner/spinner.module.scss @@ -0,0 +1,69 @@ +@use "../../../../styles/abstracts/functions" as fun; + +.wrapper { + display: flex; + align-items: center; + gap: var(--spacing-xs); + width: fit-content; + + &--left { + flex-flow: row-reverse wrap; + } + + &--right { + flex-flow: row wrap; + } + + &--bottom { + flex-flow: column nowrap; + } + + &--top { + flex-flow: column-reverse nowrap; + } +} + +.icon { + --ball-size: #{fun.convert-px(8)}; + + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + width: calc((var(--ball-size) * 3) + var(--spacing-xs)); + + &__ball { + width: var(--ball-size); + height: var(--ball-size); + background: linear-gradient( + to right, + var(--color-primary-light) 0%, + var(--color-primary-lighter) 100% + ); + border-radius: 50%; + animation: spinner 1.4s infinite ease-in-out both; + + &:first-child { + animation-delay: -0.32s; + } + + &:nth-child(2) { + animation-delay: -0.16s; + } + } +} + +.body { + color: var(--color-primary-darker); +} + +@keyframes spinner { + 0%, + 80%, + 100% { + transform: scale(0); + } + + 40% { + transform: scale(1); + } +} diff --git a/src/components/atoms/loaders/spinner.stories.tsx b/src/components/atoms/loaders/spinner/spinner.stories.tsx index 197d06c..e9dfae4 100644 --- a/src/components/atoms/loaders/spinner.stories.tsx +++ b/src/components/atoms/loaders/spinner/spinner.stories.tsx @@ -1,14 +1,14 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; import { Spinner as SpinnerComponent } from './spinner'; /** * Spinner - Storybook Meta */ export default { - title: 'Atoms/Loaders/Spinner', + title: 'Atoms/Loaders', component: SpinnerComponent, argTypes: { - message: { + children: { control: { type: 'text', }, @@ -29,14 +29,9 @@ const Template: ComponentStory<typeof SpinnerComponent> = (args) => ( ); /** - * Loaders Stories - Default Spinner + * Loaders Stories - Spinner */ export const Spinner = Template.bind({}); - -/** - * Loaders Stories - Spinner with custom message - */ -export const SpinnerCustomMessage = Template.bind({}); -SpinnerCustomMessage.args = { - message: 'Submitting...', +Spinner.args = { + children: 'Submitting...', }; diff --git a/src/components/atoms/loaders/spinner/spinner.test.tsx b/src/components/atoms/loaders/spinner/spinner.test.tsx new file mode 100644 index 0000000..733648b --- /dev/null +++ b/src/components/atoms/loaders/spinner/spinner.test.tsx @@ -0,0 +1,53 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { Spinner } from './spinner'; + +describe('Spinner', () => { + it('renders a spinner', () => { + const { container } = render(<Spinner />); + expect(container).toBeInTheDocument(); + }); + + it('can render a spinner with a custom message', () => { + const customMsg = 'Submitting'; + + render(<Spinner>{customMsg}</Spinner>); + expect(rtlScreen.getByText(customMsg)).toBeInTheDocument(); + }); + + it('can render a spinner with a custom message at the bottom', () => { + const customMsg = 'necessitatibus'; + + render(<Spinner position="bottom">{customMsg}</Spinner>); + expect(rtlScreen.getByText(customMsg).parentElement).toHaveClass( + 'wrapper--bottom' + ); + }); + + it('can render a spinner with a custom message on the left', () => { + const customMsg = 'eos'; + + render(<Spinner position="left">{customMsg}</Spinner>); + expect(rtlScreen.getByText(customMsg).parentElement).toHaveClass( + 'wrapper--left' + ); + }); + + it('can render a spinner with a custom message on the right', () => { + const customMsg = 'neque'; + + render(<Spinner position="right">{customMsg}</Spinner>); + expect(rtlScreen.getByText(customMsg).parentElement).toHaveClass( + 'wrapper--right' + ); + }); + + it('can render a spinner with a custom message on the top', () => { + const customMsg = 'vero'; + + render(<Spinner position="top">{customMsg}</Spinner>); + expect(rtlScreen.getByText(customMsg).parentElement).toHaveClass( + 'wrapper--top' + ); + }); +}); diff --git a/src/components/atoms/loaders/spinner/spinner.tsx b/src/components/atoms/loaders/spinner/spinner.tsx new file mode 100644 index 0000000..6c6c23c --- /dev/null +++ b/src/components/atoms/loaders/spinner/spinner.tsx @@ -0,0 +1,42 @@ +import type { FC, HTMLAttributes, ReactNode } from 'react'; +import type { Position } from '../../../../types'; +import styles from './spinner.module.scss'; + +export type SpinnerProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & { + /** + * The loading message. + */ + children?: ReactNode; + /** + * Define the position of the loading message if any. + * + * @default 'right' + */ + position?: Exclude<Position, 'center'>; +}; + +/** + * Spinner component + * + * Render a loading message with animation. + */ +export const Spinner: FC<SpinnerProps> = ({ + children, + className = '', + position = 'right', + ...props +}) => { + const positionClass = styles[`wrapper--${position}`]; + const wrapperClass = `${styles.wrapper} ${positionClass} ${className}`; + + return ( + <div {...props} className={wrapperClass}> + <div aria-hidden className={styles.icon}> + <div className={styles.icon__ball} /> + <div className={styles.icon__ball} /> + <div className={styles.icon__ball} /> + </div> + <div className={styles.body}>{children}</div> + </div> + ); +}; diff --git a/src/components/organisms/forms/comment-form/comment-form.tsx b/src/components/organisms/forms/comment-form/comment-form.tsx index e645ede..b5f2d64 100644 --- a/src/components/organisms/forms/comment-form/comment-form.tsx +++ b/src/components/organisms/forms/comment-form/comment-form.tsx @@ -117,6 +117,12 @@ export const CommentForm: FC<CommentFormProps> = ({ id: 'dz2kDV', }); + const loadingMsg = intl.formatMessage({ + defaultMessage: 'Submitting...', + description: 'CommentForm: spinner message on submit', + id: 'IY5ew6', + }); + const formAriaLabel = title ? undefined : formTitle; const formId = useId(); const formLabelledBy = title ? formId : undefined; @@ -246,15 +252,7 @@ export const CommentForm: FC<CommentFormProps> = ({ id: 'OL0Yzx', })} </Button> - {isSubmitting ? ( - <Spinner - message={intl.formatMessage({ - defaultMessage: 'Submitting...', - description: 'CommentForm: spinner message on submit', - id: 'IY5ew6', - })} - /> - ) : null} + {isSubmitting ? <Spinner>{loadingMsg}</Spinner> : null} {Notice} </Form> ); diff --git a/src/components/organisms/forms/contact-form/contact-form.tsx b/src/components/organisms/forms/contact-form/contact-form.tsx index 6208b94..89fd331 100644 --- a/src/components/organisms/forms/contact-form/contact-form.tsx +++ b/src/components/organisms/forms/contact-form/contact-form.tsx @@ -1,4 +1,13 @@ -import { ChangeEvent, FC, FormEvent, ReactNode, useState } from 'react'; +/* eslint-disable max-statements */ +import { + type ChangeEvent, + type FC, + type FormEvent, + type ReactNode, + useState, + useCallback, + useMemo, +} from 'react'; import { useIntl } from 'react-intl'; import { Button, Form, Input, Label, Spinner, TextArea } from '../../../atoms'; import { LabelledField } from '../../../molecules'; @@ -38,51 +47,54 @@ export const ContactForm: FC<ContactFormProps> = ({ }) => { const formClass = `${styles.form} ${className}`; const intl = useIntl(); - const emptyForm: ContactFormData = { - email: '', - message: '', - name: '', - object: '', - }; + const emptyForm: ContactFormData = useMemo(() => { + return { + email: '', + message: '', + name: '', + object: '', + }; + }, []); 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 updateForm = ( - e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> - ) => { - switch (e.target.name) { - case 'email': - setData((prevData) => { - return { ...prevData, email: e.target.value }; - }); - break; - case 'message': - setData((prevData) => { - return { ...prevData, message: e.target.value }; - }); - break; - case 'name': - setData((prevData) => { - return { ...prevData, name: e.target.value }; - }); - break; - case 'object': - setData((prevData) => { - return { ...prevData, object: e.target.value }; - }); - break; - default: - break; - } - }; + const updateForm = useCallback( + (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { + switch (e.target.name) { + case 'email': + setData((prevData) => { + return { ...prevData, email: e.target.value }; + }); + break; + case 'message': + setData((prevData) => { + return { ...prevData, message: e.target.value }; + }); + break; + case 'name': + setData((prevData) => { + return { ...prevData, name: e.target.value }; + }); + break; + case 'object': + setData((prevData) => { + return { ...prevData, object: e.target.value }; + }); + break; + default: + break; + } + }, + [] + ); const formName = intl.formatMessage({ defaultMessage: 'Contact form', @@ -114,11 +126,20 @@ export const ContactForm: FC<ContactFormProps> = ({ id: 'yN5P+m', }); - const submitHandler = async (e: FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - sendMail(data, resetForm).then(() => setIsSubmitting(false)); - }; + const loadingMsg = intl.formatMessage({ + defaultMessage: 'Sending mail...', + description: 'ContactForm: spinner message on submit', + id: 'xaqaYQ', + }); + + const submitHandler = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + await sendMail(data, resetForm).then(() => setIsSubmitting(false)); + }, + [data, resetForm, sendMail] + ); return ( <Form aria-label={formName} className={formClass} onSubmit={submitHandler}> @@ -195,15 +216,7 @@ export const ContactForm: FC<ContactFormProps> = ({ id: 'VkAnvv', })} </Button> - {isSubmitting && ( - <Spinner - message={intl.formatMessage({ - defaultMessage: 'Sending mail...', - description: 'ContactForm: spinner message on submit', - id: 'xaqaYQ', - })} - /> - )} + {isSubmitting ? <Spinner>{loadingMsg}</Spinner> : null} {Notice} </Form> ); diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx index f04ba74..5401ed1 100644 --- a/src/components/organisms/layout/posts-list.tsx +++ b/src/components/organisms/layout/posts-list.tsx @@ -165,8 +165,14 @@ export const PostsList: FC<PostsListProps> = ({ const loadMoreBody = intl.formatMessage({ defaultMessage: 'Load more articles?', - id: 'uaqd5F', description: 'PostsList: load more button', + id: 'uaqd5F', + }); + + const loadingMoreArticles = intl.formatMessage({ + defaultMessage: 'Loading more articles...', + description: 'PostsList: loading more articles message', + id: 'xYemkP', }); /** @@ -224,7 +230,7 @@ export const PostsList: FC<PostsListProps> = ({ return ( <> {getPosts()} - {isLoading ? <Spinner /> : null} + {isLoading ? <Spinner>{loadingMoreArticles}</Spinner> : null} {isMounted ? getProgressBar() : getPagination()} </> ); diff --git a/src/i18n/en.json b/src/i18n/en.json index d8a982d..2faedfa 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -83,6 +83,10 @@ "defaultMessage": "Page not found.", "description": "404Page: SEO - Meta description" }, + "4iYISO": { + "defaultMessage": "Loading the requested article...", + "description": "ArticlePage: loading article message" + }, "50xc4o": { "defaultMessage": "Read more articles about:", "description": "ArticlePage: footer topics list label" @@ -187,6 +191,10 @@ "defaultMessage": "Reading time:", "description": "Meta: reading time label" }, + "EeCqAE": { + "defaultMessage": "Loading the search results...", + "description": "SearchPage: loading search results message" + }, "Es52wh": { "defaultMessage": "Blog", "description": "Breadcrumb: blog label" @@ -307,6 +315,10 @@ "defaultMessage": "CV", "description": "Layout: main nav - cv link" }, + "RwI3B9": { + "defaultMessage": "Loading the repository popularity...", + "description": "ProjectsPage: loading repository popularity" + }, "T4YA64": { "defaultMessage": "Subscribe", "description": "HomePage: RSS feed subscription text" @@ -535,10 +547,6 @@ "defaultMessage": "Share", "description": "Sharing: widget title" }, - "q9cJQe": { - "defaultMessage": "Loading...", - "description": "Spinner: loading text" - }, "qnwsWV": { "defaultMessage": "Projects", "description": "Layout: main nav - projects link" @@ -619,6 +627,10 @@ "defaultMessage": "Settings form", "description": "SettingsModal: an accessible form name" }, + "xYemkP": { + "defaultMessage": "Loading more articles...", + "description": "PostsList: loading more articles message" + }, "xaqaYQ": { "defaultMessage": "Sending mail...", "description": "ContactForm: spinner message on submit" diff --git a/src/i18n/fr.json b/src/i18n/fr.json index d64c930..0f79416 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -83,6 +83,10 @@ "defaultMessage": "Page non trouvée.", "description": "404Page: SEO - Meta description" }, + "4iYISO": { + "defaultMessage": "Chargement de l'article demandé…", + "description": "ArticlePage: loading article message" + }, "50xc4o": { "defaultMessage": "Lire plus d’articles à propos de :", "description": "ArticlePage: footer topics list label" @@ -187,6 +191,10 @@ "defaultMessage": "Temps de lecture :", "description": "Meta: reading time label" }, + "EeCqAE": { + "defaultMessage": "Chargement des résultats…", + "description": "SearchPage: loading search results message" + }, "Es52wh": { "defaultMessage": "Blog", "description": "Breadcrumb: blog label" @@ -307,6 +315,10 @@ "defaultMessage": "CV", "description": "Layout: main nav - cv link" }, + "RwI3B9": { + "defaultMessage": "Chargement de la popularité du dépôt…", + "description": "ProjectsPage: loading repository popularity" + }, "T4YA64": { "defaultMessage": "Vous abonner", "description": "HomePage: RSS feed subscription text" @@ -535,10 +547,6 @@ "defaultMessage": "Partager", "description": "Sharing: widget title" }, - "q9cJQe": { - "defaultMessage": "Chargement…", - "description": "Spinner: loading text" - }, "qnwsWV": { "defaultMessage": "Projets", "description": "Layout: main nav - projects link" @@ -619,6 +627,10 @@ "defaultMessage": "Formulaire des réglages", "description": "SettingsModal: an accessible form name" }, + "xYemkP": { + "defaultMessage": "Chargement des articles précédents…", + "description": "PostsList: loading more articles message" + }, "xaqaYQ": { "defaultMessage": "Mail en cours d’envoi…", "description": "ContactForm: spinner message on submit" diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx index 3e4c38f..523e21d 100644 --- a/src/pages/article/[slug].tsx +++ b/src/pages/article/[slug].tsx @@ -71,8 +71,13 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ const { website } = useSettings(); const prismPlugins: OptionalPrismPlugin[] = ['command-line', 'line-numbers']; const { attributes, className } = usePrism({ plugins: prismPlugins }); + const loadingArticle = intl.formatMessage({ + defaultMessage: 'Loading the requested article...', + description: 'ArticlePage: loading article message', + id: '4iYISO', + }); - if (isFallback || !article) return <Spinner />; + if (isFallback || !article) return <Spinner>{loadingArticle}</Spinner>; const { content, id, intro, meta, title } = article; const { author, commentsCount, cover, dates, seo, thematics, topics } = meta; diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx index afcf060..717ae13 100644 --- a/src/pages/projets/[slug].tsx +++ b/src/pages/projets/[slug].tsx @@ -170,6 +170,12 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => { return links; }; + const loadingRepoPopularity = intl.formatMessage({ + defaultMessage: 'Loading the repository popularity...', + description: 'ProjectsPage: loading repository popularity', + id: 'RwI3B9', + }); + const { isError, isLoading, data } = useGithubApi( /* * Repo should be defined for each project so for now it is safe for my @@ -182,7 +188,7 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => { ); if (isError) return 'Error'; - if (isLoading || !data) return <Spinner />; + if (isLoading || !data) return <Spinner aria-label={loadingRepoPopularity} />; const getRepoPopularity = (repo: string) => { const stars = intl.formatMessage( diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx index 971d04a..5acf352 100644 --- a/src/pages/recherche/index.tsx +++ b/src/pages/recherche/index.tsx @@ -151,6 +151,11 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ id: 'N804XO', }); const postsListBaseUrl = `${ROUTES.SEARCH}/page/`; + const loadingResults = intl.formatMessage({ + defaultMessage: 'Loading the search results...', + description: 'SearchPage: loading search results message', + id: 'EeCqAE', + }); return ( <> @@ -211,7 +216,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ total={totalArticles ?? 0} /> ) : ( - <Spinner /> + <Spinner>{loadingResults}</Spinner> )} {error ? ( <Notice diff --git a/src/types/app.ts b/src/types/app.ts index 64bb3af..e237560 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -1,7 +1,7 @@ -import { NextPage } from 'next'; -import { AppProps as NextAppProps } from 'next/app'; -import { ReactElement, ReactNode } from 'react'; -import { MessageFormatElement } from 'react-intl'; +import type { NextPage } from 'next'; +import type { AppProps as NextAppProps } from 'next/app'; +import type { ReactElement, ReactNode } from 'react'; +import type { MessageFormatElement } from 'react-intl'; export type NextPageWithLayoutOptions = { withExtraPadding?: boolean; @@ -9,15 +9,15 @@ export type NextPageWithLayoutOptions = { useGrid?: boolean; }; -export type NextPageWithLayout<T = {}> = NextPage<T> & { +export type NextPageWithLayout<T = object> = NextPage<T> & { getLayout?: ( page: ReactElement, options: NextPageWithLayoutOptions ) => ReactNode; }; -// modified version - allows for custom pageProps type, falling back to 'any' -type AppProps<P = any> = { +// modified version - allows custom pageProps type, falling back to 'unknown' +type AppProps<P = unknown> = { pageProps: P; } & Omit<NextAppProps<P>, 'pageProps'>; @@ -130,3 +130,5 @@ export type Topic = Page<'topic'>; export type Slug = { slug: string; }; + +export type Position = 'bottom' | 'center' | 'left' | 'right' | 'top'; |
