aboutsummaryrefslogtreecommitdiffstats
path: root/src/components
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-09-29 11:47:06 +0200
committerArmand Philippot <git@armandphilippot.com>2023-10-24 12:25:00 +0200
commit81b1e0e05919eb368a66aef47adcf7738af76f29 (patch)
tree758577d3f58d7025f84bca5bac9bc0da8432deb7 /src/components
parent3272ac336da52364ace5ed76d8f609d4088ffc06 (diff)
refactor(components): rewrite Spinner component
* Message should be set as children * Default message is no longer available (depending on use case, the consumer might prefer aria-label instead) * It is now possible to define the message position
Diffstat (limited to 'src/components')
-rw-r--r--src/components/atoms/images/icons/svg-paths/icons-paths/arrow-icon-paths.tsx3
-rw-r--r--src/components/atoms/loaders/spinner.module.scss48
-rw-r--r--src/components/atoms/loaders/spinner.test.tsx15
-rw-r--r--src/components/atoms/loaders/spinner.tsx35
-rw-r--r--src/components/atoms/loaders/spinner/index.ts1
-rw-r--r--src/components/atoms/loaders/spinner/spinner.module.scss69
-rw-r--r--src/components/atoms/loaders/spinner/spinner.stories.tsx (renamed from src/components/atoms/loaders/spinner.stories.tsx)17
-rw-r--r--src/components/atoms/loaders/spinner/spinner.test.tsx53
-rw-r--r--src/components/atoms/loaders/spinner/spinner.tsx42
-rw-r--r--src/components/organisms/forms/comment-form/comment-form.tsx16
-rw-r--r--src/components/organisms/forms/contact-form/contact-form.tsx115
-rw-r--r--src/components/organisms/layout/posts-list.tsx10
12 files changed, 252 insertions, 172 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()}
</>
);