aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/organisms/forms
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-09-22 19:34:01 +0200
committerArmand Philippot <git@armandphilippot.com>2023-10-24 12:23:48 +0200
commita6ff5eee45215effb3344cb5d631a27a7c0369aa (patch)
tree5051747acf72318b4fc5c18d603e3757fbefdfdb /src/components/organisms/forms
parent651ea4fc992e77d2f36b3c68f8e7a70644246067 (diff)
refactor(components): rewrite form components
Diffstat (limited to 'src/components/organisms/forms')
-rw-r--r--src/components/organisms/forms/ackee-toggle/ackee-toggle.fixture.tsx1
-rw-r--r--src/components/organisms/forms/ackee-toggle/ackee-toggle.stories.tsx47
-rw-r--r--src/components/organisms/forms/ackee-toggle/ackee-toggle.test.tsx15
-rw-r--r--src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx139
-rw-r--r--src/components/organisms/forms/ackee-toggle/index.ts1
-rw-r--r--src/components/organisms/forms/comment-form.module.scss8
-rw-r--r--src/components/organisms/forms/comment-form/comment-form.module.scss18
-rw-r--r--src/components/organisms/forms/comment-form/comment-form.stories.tsx (renamed from src/components/organisms/forms/comment-form.stories.tsx)0
-rw-r--r--src/components/organisms/forms/comment-form/comment-form.test.tsx (renamed from src/components/organisms/forms/comment-form.test.tsx)2
-rw-r--r--src/components/organisms/forms/comment-form/comment-form.tsx (renamed from src/components/organisms/forms/comment-form.tsx)152
-rw-r--r--src/components/organisms/forms/comment-form/index.ts1
-rw-r--r--src/components/organisms/forms/contact-form.module.scss8
-rw-r--r--src/components/organisms/forms/contact-form.tsx154
-rw-r--r--src/components/organisms/forms/contact-form/contact-form.module.scss15
-rw-r--r--src/components/organisms/forms/contact-form/contact-form.stories.tsx (renamed from src/components/organisms/forms/contact-form.stories.tsx)0
-rw-r--r--src/components/organisms/forms/contact-form/contact-form.test.tsx (renamed from src/components/organisms/forms/contact-form.test.tsx)2
-rw-r--r--src/components/organisms/forms/contact-form/contact-form.tsx210
-rw-r--r--src/components/organisms/forms/contact-form/index.ts1
-rw-r--r--src/components/organisms/forms/index.ts4
-rw-r--r--src/components/organisms/forms/motion-toggle/index.ts1
-rw-r--r--src/components/organisms/forms/motion-toggle/motion-toggle.fixture.tsx1
-rw-r--r--src/components/organisms/forms/motion-toggle/motion-toggle.stories.tsx47
-rw-r--r--src/components/organisms/forms/motion-toggle/motion-toggle.test.tsx15
-rw-r--r--src/components/organisms/forms/motion-toggle/motion-toggle.tsx89
-rw-r--r--src/components/organisms/forms/prism-theme-toggle/index.ts1
-rw-r--r--src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.stories.tsx20
-rw-r--r--src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.test.tsx13
-rw-r--r--src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.tsx85
-rw-r--r--src/components/organisms/forms/search-form/index.ts1
-rw-r--r--src/components/organisms/forms/search-form/search-form.module.scss (renamed from src/components/organisms/forms/search-form.module.scss)31
-rw-r--r--src/components/organisms/forms/search-form/search-form.stories.tsx (renamed from src/components/organisms/forms/search-form.stories.tsx)6
-rw-r--r--src/components/organisms/forms/search-form/search-form.test.tsx (renamed from src/components/organisms/forms/search-form.test.tsx)2
-rw-r--r--src/components/organisms/forms/search-form/search-form.tsx (renamed from src/components/organisms/forms/search-form.tsx)56
-rw-r--r--src/components/organisms/forms/theme-toggle/index.ts1
-rw-r--r--src/components/organisms/forms/theme-toggle/theme-toggle.stories.tsx20
-rw-r--r--src/components/organisms/forms/theme-toggle/theme-toggle.test.tsx13
-rw-r--r--src/components/organisms/forms/theme-toggle/theme-toggle.tsx76
37 files changed, 1007 insertions, 249 deletions
diff --git a/src/components/organisms/forms/ackee-toggle/ackee-toggle.fixture.tsx b/src/components/organisms/forms/ackee-toggle/ackee-toggle.fixture.tsx
new file mode 100644
index 0000000..04602f2
--- /dev/null
+++ b/src/components/organisms/forms/ackee-toggle/ackee-toggle.fixture.tsx
@@ -0,0 +1 @@
+export const storageKey = 'ackee';
diff --git a/src/components/organisms/forms/ackee-toggle/ackee-toggle.stories.tsx b/src/components/organisms/forms/ackee-toggle/ackee-toggle.stories.tsx
new file mode 100644
index 0000000..b5f8ef8
--- /dev/null
+++ b/src/components/organisms/forms/ackee-toggle/ackee-toggle.stories.tsx
@@ -0,0 +1,47 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { AckeeToggle } from './ackee-toggle';
+import { storageKey } from './ackee-toggle.fixture';
+
+/**
+ * AckeeToggle - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Forms/Toggle',
+ component: AckeeToggle,
+ argTypes: {
+ defaultValue: {
+ control: {
+ type: 'select',
+ },
+ description: 'Set the default value.',
+ options: ['full', 'partial'],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ storageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set local storage key.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof AckeeToggle>;
+
+const Template: ComponentStory<typeof AckeeToggle> = (args) => (
+ <AckeeToggle {...args} />
+);
+
+/**
+ * Toggle Stories - Ackee
+ */
+export const Ackee = Template.bind({});
+Ackee.args = {
+ defaultValue: 'full',
+ storageKey,
+};
diff --git a/src/components/organisms/forms/ackee-toggle/ackee-toggle.test.tsx b/src/components/organisms/forms/ackee-toggle/ackee-toggle.test.tsx
new file mode 100644
index 0000000..7784d5f
--- /dev/null
+++ b/src/components/organisms/forms/ackee-toggle/ackee-toggle.test.tsx
@@ -0,0 +1,15 @@
+import { render, screen } from '../../../../../tests/utils';
+import { AckeeToggle } from './ackee-toggle';
+import { storageKey } from './ackee-toggle.fixture';
+
+describe('AckeeToggle', () => {
+ // toHaveValue received undefined. Maybe because of localStorage hook...
+ it('renders a toggle component', () => {
+ render(<AckeeToggle storageKey={storageKey} defaultValue="full" />);
+ expect(
+ screen.getByRole('radiogroup', {
+ name: /Tracking:/i,
+ })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx b/src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx
new file mode 100644
index 0000000..681d384
--- /dev/null
+++ b/src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx
@@ -0,0 +1,139 @@
+import { ChangeEvent, FC, useState } from 'react';
+import { useIntl } from 'react-intl';
+import {
+ type AckeeOptions,
+ useLocalStorage,
+ useUpdateAckeeOptions,
+} from '../../../../utils/hooks';
+import { Legend, List } from '../../../atoms';
+import {
+ Switch,
+ SwitchOption,
+ SwitchProps,
+ Tooltip,
+ TooltipProps,
+} from '../../../molecules';
+
+export type AckeeToggleProps = Omit<
+ SwitchProps,
+ 'isInline' | 'items' | 'name' | 'onSwitch' | 'value'
+> &
+ Pick<TooltipProps, 'direction'> & {
+ /**
+ * Set additional classnames to the toggle wrapper.
+ */
+ className?: string;
+ /**
+ * True if motion should be reduced by default.
+ */
+ defaultValue: AckeeOptions;
+ /**
+ * The local storage key to save preference.
+ */
+ storageKey: string;
+ };
+
+/**
+ * AckeeToggle component
+ *
+ * Render a Toggle component to set reduce motion.
+ */
+export const AckeeToggle: FC<AckeeToggleProps> = ({
+ defaultValue,
+ direction,
+ storageKey,
+ ...props
+}) => {
+ const intl = useIntl();
+ const { value, setValue } = useLocalStorage<AckeeOptions>(
+ storageKey,
+ defaultValue
+ );
+ const [isTooltipOpened, setIsTooltipOpened] = useState(false);
+
+ useUpdateAckeeOptions(value);
+
+ const ackeeLabel = intl.formatMessage({
+ defaultMessage: 'Tracking:',
+ description: 'AckeeToggle: select label',
+ id: '0gVlI3',
+ });
+ const partialLabel = intl.formatMessage({
+ defaultMessage: 'Partial',
+ description: 'AckeeToggle: partial option name',
+ id: 'tIZYpD',
+ });
+ const fullLabel = intl.formatMessage({
+ defaultMessage: 'Full',
+ description: 'AckeeToggle: full option name',
+ id: '5eD6y2',
+ });
+ const tooltipTitle = intl.formatMessage({
+ defaultMessage: 'Ackee tracking (analytics)',
+ description: 'AckeeToggle: tooltip title',
+ id: 'nGss/j',
+ });
+ const tooltipPartial = intl.formatMessage({
+ defaultMessage: 'Partial includes only page url, views and duration.',
+ description: 'AckeeToggle: tooltip message',
+ id: 'ZB/Aw2',
+ });
+ const tooltipFull = intl.formatMessage({
+ defaultMessage:
+ 'Full includes all information from partial as well as information about referrer, operating system, device, browser, screen size and language.',
+ description: 'AckeeToggle: tooltip message',
+ id: '7zDlQo',
+ });
+
+ const options: [SwitchOption, SwitchOption] = [
+ {
+ id: 'ackee-full',
+ label: fullLabel,
+ value: 'full',
+ },
+ {
+ id: 'ackee-partial',
+ label: partialLabel,
+ value: 'partial',
+ },
+ ];
+
+ const updateSetting = (e: ChangeEvent<HTMLInputElement>) => {
+ setValue(e.target.value === 'full' ? 'full' : 'partial');
+ };
+
+ const closeTooltip = () => {
+ setIsTooltipOpened(false);
+ };
+ const toggleTooltip = () => {
+ setIsTooltipOpened((prev) => !prev);
+ };
+
+ return (
+ <Switch
+ {...props}
+ isInline
+ items={options}
+ legend={<Legend>{ackeeLabel}</Legend>}
+ name="ackee"
+ onSwitch={updateSetting}
+ tooltip={
+ <Tooltip
+ direction={direction}
+ heading={tooltipTitle}
+ isOpen={isTooltipOpened}
+ onClickOutside={closeTooltip}
+ onToggle={toggleTooltip}
+ >
+ <List
+ items={[
+ { id: 'partial', value: tooltipPartial },
+ { id: 'full', value: tooltipFull },
+ ]}
+ />
+ </Tooltip>
+ }
+ value={value}
+ />
+ );
+};
diff --git a/src/components/organisms/forms/ackee-toggle/index.ts b/src/components/organisms/forms/ackee-toggle/index.ts
new file mode 100644
index 0000000..7f6313c
--- /dev/null
+++ b/src/components/organisms/forms/ackee-toggle/index.ts
@@ -0,0 +1 @@
+export * from './ackee-toggle';
diff --git a/src/components/organisms/forms/comment-form.module.scss b/src/components/organisms/forms/comment-form.module.scss
deleted file mode 100644
index f3f2646..0000000
--- a/src/components/organisms/forms/comment-form.module.scss
+++ /dev/null
@@ -1,8 +0,0 @@
-.field {
- width: 100%;
-}
-
-.button {
- display: block;
- margin: auto;
-}
diff --git a/src/components/organisms/forms/comment-form/comment-form.module.scss b/src/components/organisms/forms/comment-form/comment-form.module.scss
new file mode 100644
index 0000000..fbf8c96
--- /dev/null
+++ b/src/components/organisms/forms/comment-form/comment-form.module.scss
@@ -0,0 +1,18 @@
+.form {
+ display: flex;
+ flex-flow: column wrap;
+ gap: var(--spacing-xs);
+
+ > * {
+ max-width: 45ch;
+ }
+}
+
+.field {
+ width: 100%;
+}
+
+.button {
+ display: block;
+ margin: var(--spacing-sm) auto 0;
+}
diff --git a/src/components/organisms/forms/comment-form.stories.tsx b/src/components/organisms/forms/comment-form/comment-form.stories.tsx
index a6069e6..a6069e6 100644
--- a/src/components/organisms/forms/comment-form.stories.tsx
+++ b/src/components/organisms/forms/comment-form/comment-form.stories.tsx
diff --git a/src/components/organisms/forms/comment-form.test.tsx b/src/components/organisms/forms/comment-form/comment-form.test.tsx
index f11c449..8aa38af 100644
--- a/src/components/organisms/forms/comment-form.test.tsx
+++ b/src/components/organisms/forms/comment-form/comment-form.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '../../../../tests/utils';
+import { render, screen } from '../../../../../tests/utils';
import { CommentForm } from './comment-form';
const saveComment = async () => {
diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form/comment-form.tsx
index e4140dd..be5d58f 100644
--- a/src/components/organisms/forms/comment-form.tsx
+++ b/src/components/organisms/forms/comment-form/comment-form.tsx
@@ -1,4 +1,4 @@
-import { FC, ReactNode, useState } from 'react';
+import { ChangeEvent, FC, FormEvent, ReactNode, useState } from 'react';
import { useIntl } from 'react-intl';
import {
Button,
@@ -8,14 +8,17 @@ import {
type HeadingLevel,
type HeadingProps,
Spinner,
-} from '../../atoms';
-import { LabelledField } from '../../molecules';
+ Input,
+ TextArea,
+ Label,
+} from '../../../atoms';
+import { LabelledField } from '../../../molecules';
import styles from './comment-form.module.scss';
export type CommentFormData = {
+ author: string;
comment: string;
email: string;
- name: string;
parentId?: number;
website?: string;
};
@@ -49,6 +52,7 @@ export type CommentFormProps = Pick<FormProps, 'className'> & {
};
export const CommentForm: FC<CommentFormProps> = ({
+ className = '',
Notice,
parentId,
saveComment,
@@ -57,21 +61,23 @@ export const CommentForm: FC<CommentFormProps> = ({
titleLevel = 2,
...props
}) => {
+ const formClass = `${styles.form} ${className}`;
const intl = useIntl();
- const [name, setName] = useState<string>('');
- const [email, setEmail] = useState<string>('');
- const [website, setWebsite] = useState<string>('');
- const [comment, setComment] = useState<string>('');
+ const emptyForm: CommentFormData = {
+ author: '',
+ comment: '',
+ email: '',
+ parentId,
+ website: '',
+ };
+ const [data, setData] = useState(emptyForm);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
/**
* Reset all the form fields.
*/
const resetForm = () => {
- setName('');
- setEmail('');
- setWebsite('');
- setComment('');
+ setData(emptyForm);
setIsSubmitting(false);
};
@@ -109,14 +115,39 @@ export const CommentForm: FC<CommentFormProps> = ({
const formId = 'comment-form-title';
const formLabelledBy = title ? formId : undefined;
- /**
- * Handle form submit.
- */
- const submitHandler = () => {
+ 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 submitHandler = (e: FormEvent) => {
+ e.preventDefault();
setIsSubmitting(true);
- saveComment({ comment, email, name, parentId, website }, resetForm).then(
- () => setIsSubmitting(false)
- );
+ saveComment(data, resetForm).then(() => setIsSubmitting(false));
};
return (
@@ -124,6 +155,7 @@ export const CommentForm: FC<CommentFormProps> = ({
{...props}
aria-label={formAriaLabel}
aria-labelledby={formLabelledBy}
+ className={formClass}
onSubmit={submitHandler}
>
{title && (
@@ -133,43 +165,69 @@ export const CommentForm: FC<CommentFormProps> = ({
)}
<LabelledField
className={styles.field}
- id="commenter-name"
- label={nameLabel}
- name="commenter-name"
- required={true}
- setValue={setName}
- type="text"
- value={name}
+ field={
+ <Input
+ id="commenter-name"
+ isRequired
+ name="author"
+ onChange={updateForm}
+ type="text"
+ value={data.author}
+ />
+ }
+ label={
+ <Label htmlFor="commenter-name" isRequired>
+ {nameLabel}
+ </Label>
+ }
/>
<LabelledField
className={styles.field}
- id="commenter-email"
- label={emailLabel}
- name="commenter-email"
- required={true}
- setValue={setEmail}
- type="email"
- value={email}
+ field={
+ <Input
+ id="commenter-email"
+ isRequired
+ name="email"
+ onChange={updateForm}
+ type="email"
+ value={data.email}
+ />
+ }
+ label={
+ <Label htmlFor="commenter-email" isRequired>
+ {emailLabel}
+ </Label>
+ }
/>
<LabelledField
className={styles.field}
- id="commenter-website"
- label={websiteLabel}
- name="commenter-website"
- required={false}
- setValue={setWebsite}
- type="text"
- value={website}
+ field={
+ <Input
+ id="commenter-website"
+ name="website"
+ onChange={updateForm}
+ type="url"
+ value={data.website}
+ />
+ }
+ label={<Label htmlFor="commenter-website">{websiteLabel}</Label>}
/>
<LabelledField
className={styles.field}
- id="commenter-comment"
- label={commentLabel}
- name="commenter-comment"
- required={true}
- setValue={setComment}
- type="textarea"
- value={comment}
+ field={
+ <TextArea
+ id="commenter-comment"
+ isRequired
+ name="comment"
+ onChange={updateForm}
+ value={data.comment}
+ />
+ }
+ label={
+ <Label htmlFor="commenter-comment" isRequired>
+ {commentLabel}
+ </Label>
+ }
/>
<Button type="submit" kind="primary" className={styles.button}>
{intl.formatMessage({
diff --git a/src/components/organisms/forms/comment-form/index.ts b/src/components/organisms/forms/comment-form/index.ts
new file mode 100644
index 0000000..9e22bd9
--- /dev/null
+++ b/src/components/organisms/forms/comment-form/index.ts
@@ -0,0 +1 @@
+export * from './comment-form';
diff --git a/src/components/organisms/forms/contact-form.module.scss b/src/components/organisms/forms/contact-form.module.scss
deleted file mode 100644
index f3f2646..0000000
--- a/src/components/organisms/forms/contact-form.module.scss
+++ /dev/null
@@ -1,8 +0,0 @@
-.field {
- width: 100%;
-}
-
-.button {
- display: block;
- margin: auto;
-}
diff --git a/src/components/organisms/forms/contact-form.tsx b/src/components/organisms/forms/contact-form.tsx
deleted file mode 100644
index ca84c25..0000000
--- a/src/components/organisms/forms/contact-form.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-import { FC, ReactNode, useState } from 'react';
-import { useIntl } from 'react-intl';
-import { Button, Form, Spinner } from '../../atoms';
-import { LabelledField } from '../../molecules';
-import styles from './contact-form.module.scss';
-
-export type ContactFormData = {
- email: string;
- message: string;
- name: string;
- subject: string;
-};
-
-export type ContactFormProps = {
- /**
- * Set additional classnames to the form wrapper.
- */
- className?: string;
- /**
- * Pass a component to print a success/error message.
- */
- Notice?: ReactNode;
- /**
- * A callback function to send mail.
- */
- sendMail: (data: ContactFormData, reset: () => void) => Promise<void>;
-};
-
-/**
- * ContactForm component
- *
- * Render a contact form.
- */
-export const ContactForm: FC<ContactFormProps> = ({
- className = '',
- Notice,
- sendMail,
-}) => {
- const intl = useIntl();
- const [name, setName] = useState<string>('');
- const [email, setEmail] = useState<string>('');
- const [object, setObject] = useState<string>('');
- const [message, setMessage] = useState<string>('');
- const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
-
- /**
- * Reset all the form fields.
- */
- const resetForm = () => {
- setName('');
- setEmail('');
- setObject('');
- setMessage('');
- setIsSubmitting(false);
- };
-
- const formName = intl.formatMessage({
- defaultMessage: 'Contact form',
- description: 'ContactForm: form accessible name',
- id: 'HFdzae',
- });
-
- const nameLabel = intl.formatMessage({
- defaultMessage: 'Name:',
- description: 'ContactForm: name label',
- id: '1dCuCx',
- });
-
- const emailLabel = intl.formatMessage({
- defaultMessage: 'Email:',
- description: 'ContactForm: email label',
- id: 'w4B5PA',
- });
-
- const objectLabel = intl.formatMessage({
- defaultMessage: 'Object:',
- description: 'ContactForm: object label',
- id: 's8/tyz',
- });
-
- const messageLabel = intl.formatMessage({
- defaultMessage: 'Message:',
- description: 'ContactForm: message label',
- id: 'yN5P+m',
- });
-
- const submitHandler = async () => {
- setIsSubmitting(true);
- sendMail({ email, message, name, subject: object }, resetForm).then(() =>
- setIsSubmitting(false)
- );
- };
-
- return (
- <Form aria-label={formName} className={className} onSubmit={submitHandler}>
- <LabelledField
- className={styles.field}
- id="contact-name"
- label={nameLabel}
- name="contact-name"
- required={true}
- setValue={setName}
- type="text"
- value={name}
- />
- <LabelledField
- className={styles.field}
- id="contact-email"
- label={emailLabel}
- name="contact-email"
- required={true}
- setValue={setEmail}
- type="email"
- value={email}
- />
- <LabelledField
- className={styles.field}
- id="contact-object"
- label={objectLabel}
- name="contact-object"
- setValue={setObject}
- type="text"
- value={object}
- />
- <LabelledField
- className={styles.field}
- id="contact-message"
- label={messageLabel}
- name="contact-message"
- required={true}
- setValue={setMessage}
- type="textarea"
- value={message}
- />
- <Button type="submit" kind="primary" className={styles.button}>
- {intl.formatMessage({
- defaultMessage: 'Send',
- description: 'ContactForm: send button',
- id: 'VkAnvv',
- })}
- </Button>
- {isSubmitting && (
- <Spinner
- message={intl.formatMessage({
- defaultMessage: 'Sending mail...',
- description: 'ContactForm: spinner message on submit',
- id: 'xaqaYQ',
- })}
- />
- )}
- {Notice}
- </Form>
- );
-};
diff --git a/src/components/organisms/forms/contact-form/contact-form.module.scss b/src/components/organisms/forms/contact-form/contact-form.module.scss
new file mode 100644
index 0000000..c106fb1
--- /dev/null
+++ b/src/components/organisms/forms/contact-form/contact-form.module.scss
@@ -0,0 +1,15 @@
+.form {
+ display: flex;
+ flex-flow: column wrap;
+ gap: var(--spacing-xs);
+ max-width: 45ch;
+}
+
+.field {
+ width: 100%;
+}
+
+.button {
+ display: block;
+ margin: var(--spacing-sm) auto 0;
+}
diff --git a/src/components/organisms/forms/contact-form.stories.tsx b/src/components/organisms/forms/contact-form/contact-form.stories.tsx
index 4df3db0..4df3db0 100644
--- a/src/components/organisms/forms/contact-form.stories.tsx
+++ b/src/components/organisms/forms/contact-form/contact-form.stories.tsx
diff --git a/src/components/organisms/forms/contact-form.test.tsx b/src/components/organisms/forms/contact-form/contact-form.test.tsx
index 8e27cd0..59d69fa 100644
--- a/src/components/organisms/forms/contact-form.test.tsx
+++ b/src/components/organisms/forms/contact-form/contact-form.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '../../../../tests/utils';
+import { render, screen } from '../../../../../tests/utils';
import { ContactForm } from './contact-form';
const props = {
diff --git a/src/components/organisms/forms/contact-form/contact-form.tsx b/src/components/organisms/forms/contact-form/contact-form.tsx
new file mode 100644
index 0000000..6208b94
--- /dev/null
+++ b/src/components/organisms/forms/contact-form/contact-form.tsx
@@ -0,0 +1,210 @@
+import { ChangeEvent, FC, FormEvent, ReactNode, useState } from 'react';
+import { useIntl } from 'react-intl';
+import { Button, Form, Input, Label, Spinner, TextArea } from '../../../atoms';
+import { LabelledField } from '../../../molecules';
+import styles from './contact-form.module.scss';
+
+export type ContactFormData = {
+ email: string;
+ message: string;
+ name: string;
+ object: string;
+};
+
+export type ContactFormProps = {
+ /**
+ * Set additional classnames to the form wrapper.
+ */
+ className?: string;
+ /**
+ * Pass a component to print a success/error message.
+ */
+ Notice?: ReactNode;
+ /**
+ * A callback function to send mail.
+ */
+ sendMail: (data: ContactFormData, reset: () => void) => Promise<void>;
+};
+
+/**
+ * ContactForm component
+ *
+ * Render a contact form.
+ */
+export const ContactForm: FC<ContactFormProps> = ({
+ className = '',
+ Notice,
+ sendMail,
+}) => {
+ const formClass = `${styles.form} ${className}`;
+ const intl = useIntl();
+ const emptyForm: ContactFormData = {
+ email: '',
+ message: '',
+ name: '',
+ object: '',
+ };
+ const [data, setData] = useState(emptyForm);
+ const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
+
+ /**
+ * Reset all the form fields.
+ */
+ const resetForm = () => {
+ setData(emptyForm);
+ setIsSubmitting(false);
+ };
+
+ 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 formName = intl.formatMessage({
+ defaultMessage: 'Contact form',
+ description: 'ContactForm: form accessible name',
+ id: 'HFdzae',
+ });
+
+ const nameLabel = intl.formatMessage({
+ defaultMessage: 'Name:',
+ description: 'ContactForm: name label',
+ id: '1dCuCx',
+ });
+
+ const emailLabel = intl.formatMessage({
+ defaultMessage: 'Email:',
+ description: 'ContactForm: email label',
+ id: 'w4B5PA',
+ });
+
+ const objectLabel = intl.formatMessage({
+ defaultMessage: 'Object:',
+ description: 'ContactForm: object label',
+ id: 's8/tyz',
+ });
+
+ const messageLabel = intl.formatMessage({
+ defaultMessage: 'Message:',
+ description: 'ContactForm: message label',
+ id: 'yN5P+m',
+ });
+
+ const submitHandler = async (e: FormEvent) => {
+ e.preventDefault();
+ setIsSubmitting(true);
+ sendMail(data, resetForm).then(() => setIsSubmitting(false));
+ };
+
+ return (
+ <Form aria-label={formName} className={formClass} onSubmit={submitHandler}>
+ <LabelledField
+ className={styles.field}
+ field={
+ <Input
+ id="contact-name"
+ isRequired
+ name="name"
+ onChange={updateForm}
+ type="text"
+ value={data.name}
+ />
+ }
+ label={
+ <Label htmlFor="contact-name" isRequired>
+ {nameLabel}
+ </Label>
+ }
+ />
+ <LabelledField
+ className={styles.field}
+ field={
+ <Input
+ id="contact-email"
+ isRequired
+ name="email"
+ onChange={updateForm}
+ type="email"
+ value={data.email}
+ />
+ }
+ label={
+ <Label htmlFor="contact-email" isRequired>
+ {emailLabel}
+ </Label>
+ }
+ />
+ <LabelledField
+ className={styles.field}
+ field={
+ <Input
+ id="contact-object"
+ name="object"
+ onChange={updateForm}
+ type="text"
+ value={data.object}
+ />
+ }
+ label={<Label htmlFor="contact-object">{objectLabel}</Label>}
+ />
+ <LabelledField
+ className={styles.field}
+ field={
+ <TextArea
+ id="contact-message"
+ isRequired
+ name="message"
+ onChange={updateForm}
+ value={data.message}
+ />
+ }
+ label={
+ <Label htmlFor="contact-message" isRequired>
+ {messageLabel}
+ </Label>
+ }
+ />
+ <Button type="submit" kind="primary" className={styles.button}>
+ {intl.formatMessage({
+ defaultMessage: 'Send',
+ description: 'ContactForm: send button',
+ id: 'VkAnvv',
+ })}
+ </Button>
+ {isSubmitting && (
+ <Spinner
+ message={intl.formatMessage({
+ defaultMessage: 'Sending mail...',
+ description: 'ContactForm: spinner message on submit',
+ id: 'xaqaYQ',
+ })}
+ />
+ )}
+ {Notice}
+ </Form>
+ );
+};
diff --git a/src/components/organisms/forms/contact-form/index.ts b/src/components/organisms/forms/contact-form/index.ts
new file mode 100644
index 0000000..c72af3d
--- /dev/null
+++ b/src/components/organisms/forms/contact-form/index.ts
@@ -0,0 +1 @@
+export * from './contact-form';
diff --git a/src/components/organisms/forms/index.ts b/src/components/organisms/forms/index.ts
index 10eaf20..e507895 100644
--- a/src/components/organisms/forms/index.ts
+++ b/src/components/organisms/forms/index.ts
@@ -1,3 +1,7 @@
+export * from './ackee-toggle';
export * from './comment-form';
export * from './contact-form';
+export * from './motion-toggle';
+export * from './prism-theme-toggle';
export * from './search-form';
+export * from './theme-toggle';
diff --git a/src/components/organisms/forms/motion-toggle/index.ts b/src/components/organisms/forms/motion-toggle/index.ts
new file mode 100644
index 0000000..0e35578
--- /dev/null
+++ b/src/components/organisms/forms/motion-toggle/index.ts
@@ -0,0 +1 @@
+export * from './motion-toggle';
diff --git a/src/components/organisms/forms/motion-toggle/motion-toggle.fixture.tsx b/src/components/organisms/forms/motion-toggle/motion-toggle.fixture.tsx
new file mode 100644
index 0000000..f13658a
--- /dev/null
+++ b/src/components/organisms/forms/motion-toggle/motion-toggle.fixture.tsx
@@ -0,0 +1 @@
+export const storageKey = 'reduced-motion';
diff --git a/src/components/organisms/forms/motion-toggle/motion-toggle.stories.tsx b/src/components/organisms/forms/motion-toggle/motion-toggle.stories.tsx
new file mode 100644
index 0000000..7e541db
--- /dev/null
+++ b/src/components/organisms/forms/motion-toggle/motion-toggle.stories.tsx
@@ -0,0 +1,47 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { MotionToggle } from './motion-toggle';
+import { storageKey } from './motion-toggle.fixture';
+
+/**
+ * MotionToggle - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Forms/Toggle',
+ component: MotionToggle,
+ argTypes: {
+ defaultValue: {
+ control: {
+ type: 'select',
+ },
+ description: 'Set the default value.',
+ options: ['on', 'off'],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ storageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set local storage key.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof MotionToggle>;
+
+const Template: ComponentStory<typeof MotionToggle> = (args) => (
+ <MotionToggle {...args} />
+);
+
+/**
+ * Toggle Stories - Motion
+ */
+export const Motion = Template.bind({});
+Motion.args = {
+ defaultValue: 'on',
+ storageKey,
+};
diff --git a/src/components/organisms/forms/motion-toggle/motion-toggle.test.tsx b/src/components/organisms/forms/motion-toggle/motion-toggle.test.tsx
new file mode 100644
index 0000000..614c038
--- /dev/null
+++ b/src/components/organisms/forms/motion-toggle/motion-toggle.test.tsx
@@ -0,0 +1,15 @@
+import { render, screen } from '../../../../../tests/utils';
+import { MotionToggle } from './motion-toggle';
+import { storageKey } from './motion-toggle.fixture';
+
+describe('MotionToggle', () => {
+ // toHaveValue received undefined. Maybe because of localStorage hook...
+ it('renders a toggle component', () => {
+ render(<MotionToggle storageKey={storageKey} defaultValue="on" />);
+ expect(
+ screen.getByRole('radiogroup', {
+ name: /Animations:/i,
+ })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/motion-toggle/motion-toggle.tsx b/src/components/organisms/forms/motion-toggle/motion-toggle.tsx
new file mode 100644
index 0000000..a8ca7ce
--- /dev/null
+++ b/src/components/organisms/forms/motion-toggle/motion-toggle.tsx
@@ -0,0 +1,89 @@
+import { ChangeEvent, FC } from 'react';
+import { useIntl } from 'react-intl';
+import { useAttributes, useLocalStorage } from '../../../../utils/hooks';
+import { Legend } from '../../../atoms';
+import { Switch, SwitchOption, SwitchProps } from '../../../molecules';
+
+export type MotionToggleValue = 'on' | 'off';
+
+export type MotionToggleProps = Omit<
+ SwitchProps,
+ 'isInline' | 'items' | 'name' | 'onSwitch' | 'value'
+> & {
+ /**
+ * True if motion should be reduced by default.
+ */
+ defaultValue: 'on' | 'off';
+ /**
+ * The local storage key to save preference.
+ */
+ storageKey: string;
+};
+
+/**
+ * MotionToggle component
+ *
+ * Render a Toggle component to set reduce motion.
+ */
+export const MotionToggle: FC<MotionToggleProps> = ({
+ defaultValue,
+ storageKey,
+ ...props
+}) => {
+ const intl = useIntl();
+ const { value: isReduced, setValue: setIsReduced } = useLocalStorage<boolean>(
+ storageKey,
+ defaultValue !== 'on'
+ );
+ useAttributes({
+ element:
+ typeof window !== 'undefined' ? document.documentElement : undefined,
+ attribute: 'reduced-motion',
+ value: `${isReduced}`,
+ });
+
+ const reduceMotionLabel = intl.formatMessage({
+ defaultMessage: 'Animations:',
+ description: 'MotionToggle: reduce motion label',
+ id: '/q5csZ',
+ });
+ const onLabel = intl.formatMessage({
+ defaultMessage: 'On',
+ description: 'MotionToggle: activate reduce motion label',
+ id: 'va65iw',
+ });
+ const offLabel = intl.formatMessage({
+ defaultMessage: 'Off',
+ description: 'MotionToggle: deactivate reduce motion label',
+ id: 'pWKyyR',
+ });
+
+ const options: [SwitchOption, SwitchOption] = [
+ {
+ id: 'reduced-motion-on',
+ label: onLabel,
+ value: 'on',
+ },
+ {
+ id: 'reduced-motion-off',
+ label: offLabel,
+ value: 'off',
+ },
+ ];
+
+ const updateSetting = (e: ChangeEvent<HTMLInputElement>) => {
+ setIsReduced((prev) => !prev);
+ };
+
+ return (
+ <Switch
+ {...props}
+ isInline
+ items={options}
+ legend={<Legend>{reduceMotionLabel}</Legend>}
+ name="reduced-motion"
+ onSwitch={updateSetting}
+ value={isReduced ? 'off' : 'on'}
+ />
+ );
+};
diff --git a/src/components/organisms/forms/prism-theme-toggle/index.ts b/src/components/organisms/forms/prism-theme-toggle/index.ts
new file mode 100644
index 0000000..f4e490f
--- /dev/null
+++ b/src/components/organisms/forms/prism-theme-toggle/index.ts
@@ -0,0 +1 @@
+export * from './prism-theme-toggle';
diff --git a/src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.stories.tsx b/src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.stories.tsx
new file mode 100644
index 0000000..3c8eaba
--- /dev/null
+++ b/src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.stories.tsx
@@ -0,0 +1,20 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { PrismThemeToggle } from './prism-theme-toggle';
+
+/**
+ * PrismThemeToggle - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Forms/Toggle',
+ component: PrismThemeToggle,
+ argTypes: {},
+} as ComponentMeta<typeof PrismThemeToggle>;
+
+const Template: ComponentStory<typeof PrismThemeToggle> = (args) => (
+ <PrismThemeToggle {...args} />
+);
+
+/**
+ * Toggle Stories - Prism theme
+ */
+export const PrismTheme = Template.bind({});
diff --git a/src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.test.tsx b/src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.test.tsx
new file mode 100644
index 0000000..f29418e
--- /dev/null
+++ b/src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.test.tsx
@@ -0,0 +1,13 @@
+import { render, screen } from '../../../../../tests/utils';
+import { PrismThemeToggle } from './prism-theme-toggle';
+
+describe('PrismThemeToggle', () => {
+ it('renders a toggle component', () => {
+ render(<PrismThemeToggle />);
+ expect(
+ screen.getByRole('radiogroup', {
+ name: /Code blocks:/i,
+ })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.tsx b/src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.tsx
new file mode 100644
index 0000000..0e1649b
--- /dev/null
+++ b/src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.tsx
@@ -0,0 +1,85 @@
+import { ChangeEvent, FC } from 'react';
+import { useIntl } from 'react-intl';
+import { type PrismTheme, usePrismTheme } from '../../../../utils/providers';
+import { Legend, Moon, Sun } from '../../../atoms';
+import { Switch, SwitchOption, SwitchProps } from '../../../molecules';
+
+export type PrismThemeToggleProps = Omit<
+ SwitchProps,
+ 'isInline' | 'items' | 'name' | 'onSwitch' | 'value'
+>;
+
+/**
+ * PrismThemeToggle component
+ *
+ * Render a Toggle component to set code blocks theme.
+ */
+export const PrismThemeToggle: FC<PrismThemeToggleProps> = (props) => {
+ const intl = useIntl();
+ const { theme, setTheme, resolvedTheme } = usePrismTheme();
+
+ /**
+ * Check if the resolved or chosen theme is dark theme.
+ *
+ * @returns {boolean} True if it is dark theme.
+ */
+ const isDarkTheme = (prismTheme?: PrismTheme): boolean => {
+ if (prismTheme === 'system') return resolvedTheme === 'dark';
+ return prismTheme === 'dark';
+ };
+
+ const updateTheme = (e: ChangeEvent<HTMLInputElement>) => {
+ setTheme(e.target.value === 'light' ? 'light' : 'dark');
+ };
+
+ const themeLabel = intl.formatMessage({
+ defaultMessage: 'Code blocks:',
+ description: 'PrismThemeToggle: theme label',
+ id: 'ftXN+0',
+ });
+ const lightThemeLabel = intl.formatMessage({
+ defaultMessage: 'Light theme',
+ description: 'PrismThemeToggle: light theme label',
+ id: 'tsWh8x',
+ });
+ const darkThemeLabel = intl.formatMessage({
+ defaultMessage: 'Dark theme',
+ description: 'PrismThemeToggle: dark theme label',
+ id: 'og/zWL',
+ });
+
+ const options: [SwitchOption, SwitchOption] = [
+ {
+ id: 'code-blocks-light',
+ label: (
+ <>
+ <span className="screen-reader-text">{lightThemeLabel}</span>
+ <Sun />
+ </>
+ ),
+ value: 'light',
+ },
+ {
+ id: 'code-blocks-dark',
+ label: (
+ <>
+ <span className="screen-reader-text">{darkThemeLabel}</span>
+ <Moon />
+ </>
+ ),
+ value: 'dark',
+ },
+ ];
+
+ return (
+ <Switch
+ {...props}
+ isInline
+ items={options}
+ legend={<Legend>{themeLabel}</Legend>}
+ name="code-blocks"
+ onSwitch={updateTheme}
+ value={isDarkTheme(theme) ? 'dark' : 'light'}
+ />
+ );
+};
diff --git a/src/components/organisms/forms/search-form/index.ts b/src/components/organisms/forms/search-form/index.ts
new file mode 100644
index 0000000..e7d3f3d
--- /dev/null
+++ b/src/components/organisms/forms/search-form/index.ts
@@ -0,0 +1 @@
+export * from './search-form';
diff --git a/src/components/organisms/forms/search-form.module.scss b/src/components/organisms/forms/search-form/search-form.module.scss
index 773a79f..e485380 100644
--- a/src/components/organisms/forms/search-form.module.scss
+++ b/src/components/organisms/forms/search-form/search-form.module.scss
@@ -1,5 +1,5 @@
-@use "../../../styles/abstracts/functions" as fun;
-@use "../../../styles/abstracts/mixins" as mix;
+@use "../../../../styles/abstracts/functions" as fun;
+@use "../../../../styles/abstracts/mixins" as mix;
.wrapper {
display: flex;
@@ -14,8 +14,12 @@
}
.btn {
- position: absolute;
- right: 0;
+ align-self: stretch;
+ background: var(--color-bg-tertiary);
+ border: fun.convert-px(2) solid var(--color-border);
+ border-left: none;
+ box-shadow: fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-shadow);
+ transition: all 0.25s linear 0s;
&__icon {
transform: scale(0.85);
@@ -45,14 +49,19 @@
}
.field {
- width: 100%;
- padding-right: var(--spacing-lg);
-
- &:hover ~ .btn {
- transform: translate(fun.convert-px(-3), fun.convert-px(-3));
+ &:focus-within ~ .btn {
+ background: var(--color-bg);
+ border-color: var(--color-primary);
+ box-shadow: none;
+ transform: translate(fun.convert-px(3), fun.convert-px(3));
+ transition:
+ all 0.2s ease-in-out 0s,
+ transform 0.3s ease-out 0s;
}
- &:focus ~ .btn {
- transform: translate(fun.convert-px(3), fun.convert-px(3));
+ &:hover:not(:focus-within) ~ .btn {
+ box-shadow: fun.convert-px(5) fun.convert-px(5) 0 fun.convert-px(1)
+ var(--color-shadow);
+ transform: translate(fun.convert-px(-3), fun.convert-px(-3));
}
}
diff --git a/src/components/organisms/forms/search-form.stories.tsx b/src/components/organisms/forms/search-form/search-form.stories.tsx
index 4a0a15c..c5fbeb9 100644
--- a/src/components/organisms/forms/search-form.stories.tsx
+++ b/src/components/organisms/forms/search-form/search-form.stories.tsx
@@ -8,7 +8,7 @@ export default {
title: 'Organisms/Forms',
component: SearchForm,
args: {
- hideLabel: false,
+ isLabelHidden: false,
searchPage: '#',
},
argTypes: {
@@ -25,7 +25,7 @@ export default {
required: false,
},
},
- hideLabel: {
+ isLabelHidden: {
control: {
type: 'boolean',
},
@@ -61,5 +61,5 @@ const Template: ComponentStory<typeof SearchForm> = (args) => (
*/
export const Search = Template.bind({});
Search.args = {
- hideLabel: true,
+ isLabelHidden: true,
};
diff --git a/src/components/organisms/forms/search-form.test.tsx b/src/components/organisms/forms/search-form/search-form.test.tsx
index bc9b7a0..b53b9cf 100644
--- a/src/components/organisms/forms/search-form.test.tsx
+++ b/src/components/organisms/forms/search-form/search-form.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '../../../../tests/utils';
+import { render, screen } from '../../../../../tests/utils';
import { SearchForm } from './search-form';
describe('SearchForm', () => {
diff --git a/src/components/organisms/forms/search-form.tsx b/src/components/organisms/forms/search-form/search-form.tsx
index f80d295..826e6c8 100644
--- a/src/components/organisms/forms/search-form.tsx
+++ b/src/components/organisms/forms/search-form/search-form.tsx
@@ -1,11 +1,24 @@
import { useRouter } from 'next/router';
-import { forwardRef, ForwardRefRenderFunction, useId, useState } from 'react';
+import {
+ ChangeEvent,
+ FormEvent,
+ forwardRef,
+ ForwardRefRenderFunction,
+ useId,
+ useState,
+} from 'react';
import { useIntl } from 'react-intl';
-import { Button, Form, MagnifyingGlass } from '../../atoms';
-import { LabelledField, type LabelledFieldProps } from '../../molecules';
+import { Button, Form, Input, Label, MagnifyingGlass } from '../../../atoms';
+import { LabelledField } from '../../../molecules';
import styles from './search-form.module.scss';
-export type SearchFormProps = Pick<LabelledFieldProps, 'hideLabel'> & {
+export type SearchFormProps = {
+ /**
+ * Should the label be visually hidden?
+ *
+ * @default false
+ */
+ isLabelHidden?: boolean;
/**
* The search page url.
*/
@@ -15,7 +28,7 @@ export type SearchFormProps = Pick<LabelledFieldProps, 'hideLabel'> & {
const SearchFormWithRef: ForwardRefRenderFunction<
HTMLInputElement,
SearchFormProps
-> = ({ hideLabel, searchPage }, ref) => {
+> = ({ isLabelHidden = false, searchPage }, ref) => {
const intl = useIntl();
const fieldLabel = intl.formatMessage({
defaultMessage: 'Search for:',
@@ -31,25 +44,38 @@ const SearchFormWithRef: ForwardRefRenderFunction<
const router = useRouter();
const [value, setValue] = useState<string>('');
- const submitHandler = () => {
+ const submitHandler = (e: FormEvent) => {
+ e.preventDefault();
router.push({ pathname: searchPage, query: { s: value } });
setValue('');
};
+ const updateForm = (e: ChangeEvent<HTMLInputElement>) => {
+ setValue(e.target.value);
+ };
+
const id = useId();
return (
- <Form className={styles.wrapper} grouped={false} onSubmit={submitHandler}>
+ <Form className={styles.wrapper} onSubmit={submitHandler}>
<LabelledField
className={styles.field}
- hideLabel={hideLabel}
- id={`search-form-${id}`}
- label={fieldLabel}
- name="search-form"
- ref={ref}
- setValue={setValue}
- type="search"
- value={value}
+ field={
+ <Input
+ className={styles.field}
+ id={`search-form-${id}`}
+ name="search-form"
+ onChange={updateForm}
+ ref={ref}
+ type="search"
+ value={value}
+ />
+ }
+ label={
+ <Label htmlFor={`search-form-${id}`} isHidden={isLabelHidden}>
+ {fieldLabel}
+ </Label>
+ }
/>
<Button
aria-label={buttonLabel}
diff --git a/src/components/organisms/forms/theme-toggle/index.ts b/src/components/organisms/forms/theme-toggle/index.ts
new file mode 100644
index 0000000..0dbf668
--- /dev/null
+++ b/src/components/organisms/forms/theme-toggle/index.ts
@@ -0,0 +1 @@
+export * from './theme-toggle';
diff --git a/src/components/organisms/forms/theme-toggle/theme-toggle.stories.tsx b/src/components/organisms/forms/theme-toggle/theme-toggle.stories.tsx
new file mode 100644
index 0000000..ac228b4
--- /dev/null
+++ b/src/components/organisms/forms/theme-toggle/theme-toggle.stories.tsx
@@ -0,0 +1,20 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { ThemeToggle } from './theme-toggle';
+
+/**
+ * ThemeToggle - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Forms/Toggle',
+ component: ThemeToggle,
+ argTypes: {},
+} as ComponentMeta<typeof ThemeToggle>;
+
+const Template: ComponentStory<typeof ThemeToggle> = (args) => (
+ <ThemeToggle {...args} />
+);
+
+/**
+ * Toggle Stories - Theme
+ */
+export const Theme = Template.bind({});
diff --git a/src/components/organisms/forms/theme-toggle/theme-toggle.test.tsx b/src/components/organisms/forms/theme-toggle/theme-toggle.test.tsx
new file mode 100644
index 0000000..9f37a26
--- /dev/null
+++ b/src/components/organisms/forms/theme-toggle/theme-toggle.test.tsx
@@ -0,0 +1,13 @@
+import { render, screen } from '../../../../../tests/utils';
+import { ThemeToggle } from './theme-toggle';
+
+describe('ThemeToggle', () => {
+ it('renders a toggle component', () => {
+ render(<ThemeToggle />);
+ expect(
+ screen.getByRole('radiogroup', {
+ name: /Theme:/i,
+ })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/theme-toggle/theme-toggle.tsx b/src/components/organisms/forms/theme-toggle/theme-toggle.tsx
new file mode 100644
index 0000000..da303d3
--- /dev/null
+++ b/src/components/organisms/forms/theme-toggle/theme-toggle.tsx
@@ -0,0 +1,76 @@
+import { useTheme } from 'next-themes';
+import { ChangeEvent, FC } from 'react';
+import { useIntl } from 'react-intl';
+import { Legend, Moon, Sun } from '../../../atoms';
+import { Switch, SwitchOption, SwitchProps } from '../../../molecules';
+
+export type ThemeToggleProps = Omit<
+ SwitchProps,
+ 'isInline' | 'items' | 'name' | 'onSwitch' | 'value'
+>;
+
+/**
+ * ThemeToggle component
+ *
+ * Render a Toggle component to set theme.
+ */
+export const ThemeToggle: FC<ThemeToggleProps> = (props) => {
+ const intl = useIntl();
+ const { resolvedTheme, setTheme } = useTheme();
+ const isDarkTheme = resolvedTheme === 'dark';
+
+ const updateTheme = (e: ChangeEvent<HTMLInputElement>) => {
+ setTheme(e.target.value === 'light' ? 'light' : 'dark');
+ };
+
+ const themeLabel = intl.formatMessage({
+ defaultMessage: 'Theme:',
+ description: 'ThemeToggle: theme label',
+ id: 'suXOBu',
+ });
+ const lightThemeLabel = intl.formatMessage({
+ defaultMessage: 'Light theme',
+ description: 'ThemeToggle: light theme label',
+ id: 'Ygea7s',
+ });
+ const darkThemeLabel = intl.formatMessage({
+ defaultMessage: 'Dark theme',
+ description: 'ThemeToggle: dark theme label',
+ id: '2QwvtS',
+ });
+
+ const options: [SwitchOption, SwitchOption] = [
+ {
+ id: 'theme-light',
+ label: (
+ <>
+ <span className="screen-reader-text">{lightThemeLabel}</span>
+ <Sun />
+ </>
+ ),
+ value: 'light',
+ },
+ {
+ id: 'theme-dark',
+ label: (
+ <>
+ <span className="screen-reader-text">{darkThemeLabel}</span>
+ <Moon />
+ </>
+ ),
+ value: 'dark',
+ },
+ ];
+
+ return (
+ <Switch
+ {...props}
+ isInline
+ items={options}
+ legend={<Legend>{themeLabel}</Legend>}
+ name="theme"
+ onSwitch={updateTheme}
+ value={isDarkTheme ? 'dark' : 'light'}
+ />
+ );
+};