summaryrefslogtreecommitdiffstats
path: root/src/components/organisms/forms
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2022-05-24 19:35:12 +0200
committerGitHub <noreply@github.com>2022-05-24 19:35:12 +0200
commitc85ab5ad43ccf52881ee224672c41ec30021cf48 (patch)
tree8058808d9bfca19383f120c46b34d99ff2f89f63 /src/components/organisms/forms
parent52404177c07a2aab7fc894362fb3060dff2431a0 (diff)
parent11b9de44a4b2f305a6a484187805e429b2767118 (diff)
refactor: use storybook and atomic design (#16)
BREAKING CHANGE: rewrite most of the Typescript types, so the content format (the meta in particular) needs to be updated.
Diffstat (limited to 'src/components/organisms/forms')
-rw-r--r--src/components/organisms/forms/comment-form.module.scss8
-rw-r--r--src/components/organisms/forms/comment-form.stories.tsx123
-rw-r--r--src/components/organisms/forms/comment-form.test.tsx23
-rw-r--r--src/components/organisms/forms/comment-form.tsx193
-rw-r--r--src/components/organisms/forms/contact-form.module.scss8
-rw-r--r--src/components/organisms/forms/contact-form.stories.tsx65
-rw-r--r--src/components/organisms/forms/contact-form.test.tsx48
-rw-r--r--src/components/organisms/forms/contact-form.tsx158
-rw-r--r--src/components/organisms/forms/search-form.module.scss58
-rw-r--r--src/components/organisms/forms/search-form.stories.tsx65
-rw-r--r--src/components/organisms/forms/search-form.test.tsx16
-rw-r--r--src/components/organisms/forms/search-form.tsx76
-rw-r--r--src/components/organisms/forms/settings-form.module.scss11
-rw-r--r--src/components/organisms/forms/settings-form.stories.tsx67
-rw-r--r--src/components/organisms/forms/settings-form.test.tsx67
-rw-r--r--src/components/organisms/forms/settings-form.tsx56
16 files changed, 1042 insertions, 0 deletions
diff --git a/src/components/organisms/forms/comment-form.module.scss b/src/components/organisms/forms/comment-form.module.scss
new file mode 100644
index 0000000..f3f2646
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.module.scss
@@ -0,0 +1,8 @@
+.field {
+ width: 100%;
+}
+
+.button {
+ display: block;
+ margin: auto;
+}
diff --git a/src/components/organisms/forms/comment-form.stories.tsx b/src/components/organisms/forms/comment-form.stories.tsx
new file mode 100644
index 0000000..1a9e7b7
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.stories.tsx
@@ -0,0 +1,123 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CommentForm from './comment-form';
+
+const saveComment = async () => {
+ /** Do nothing. */
+};
+
+/**
+ * CommentForm - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Forms',
+ component: CommentForm,
+ args: {
+ saveComment,
+ titleAlignment: 'left',
+ titleLevel: 2,
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the form wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ Notice: {
+ control: {
+ type: null,
+ },
+ description: 'A component to display a success or error message.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'function',
+ required: false,
+ },
+ },
+ parentId: {
+ control: {
+ type: null,
+ },
+ description: 'The parent id if it is a reply.',
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ saveComment: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to process the comment form data.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The form title.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ titleAlignment: {
+ control: {
+ type: 'select',
+ },
+ description: 'The heading alignment.',
+ options: ['center', 'left'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'left' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ titleLevel: {
+ control: {
+ type: 'number',
+ min: 1,
+ max: 6,
+ },
+ description: 'The title level (hn).',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 2 },
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof CommentForm>;
+
+const Template: ComponentStory<typeof CommentForm> = (args) => (
+ <CommentForm {...args} />
+);
+
+/**
+ * Forms Stories - Comment
+ */
+export const Comment = Template.bind({});
diff --git a/src/components/organisms/forms/comment-form.test.tsx b/src/components/organisms/forms/comment-form.test.tsx
new file mode 100644
index 0000000..c67ad6b
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@test-utils';
+import CommentForm from './comment-form';
+
+const saveComment = async () => {
+ /** Do nothing. */
+};
+const title = 'Cum voluptas voluptatibus';
+
+describe('CommentForm', () => {
+ it('renders a form', () => {
+ render(<CommentForm saveComment={saveComment} />);
+ expect(screen.getByRole('form')).toBeInTheDocument();
+ });
+
+ it('renders an optional title', () => {
+ render(
+ <CommentForm saveComment={saveComment} title={title} titleLevel={2} />
+ );
+ expect(
+ screen.getByRole('heading', { level: 2, name: title })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form.tsx
new file mode 100644
index 0000000..b2c725f
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.tsx
@@ -0,0 +1,193 @@
+import Button from '@components/atoms/buttons/button';
+import Form, { type FormProps } from '@components/atoms/forms/form';
+import Heading, {
+ type HeadingProps,
+ type HeadingLevel,
+} from '@components/atoms/headings/heading';
+import Spinner from '@components/atoms/loaders/spinner';
+import LabelledField from '@components/molecules/forms/labelled-field';
+import { FC, ReactNode, useState } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './comment-form.module.scss';
+
+export type CommentFormData = {
+ comment: string;
+ email: string;
+ name: string;
+ parentId?: number;
+ website?: string;
+};
+
+export type CommentFormProps = Pick<FormProps, 'className'> & {
+ /**
+ * Pass a component to print a success/error message.
+ */
+ Notice?: ReactNode;
+ /**
+ * The comment parent id.
+ */
+ parentId?: number;
+ /**
+ * A callback function to save comment. It takes a function as parameter to
+ * reset the form.
+ */
+ saveComment: (data: CommentFormData, reset: () => void) => Promise<void>;
+ /**
+ * The form title.
+ */
+ title?: string;
+ /**
+ * The form title alignment. Default: left.
+ */
+ titleAlignment?: HeadingProps['alignment'];
+ /**
+ * The title level. Default: 2.
+ */
+ titleLevel?: HeadingLevel;
+};
+
+const CommentForm: FC<CommentFormProps> = ({
+ Notice,
+ parentId,
+ saveComment,
+ title,
+ titleAlignment,
+ titleLevel = 2,
+ ...props
+}) => {
+ const intl = useIntl();
+ const [name, setName] = useState<string>('');
+ const [email, setEmail] = useState<string>('');
+ const [website, setWebsite] = useState<string>('');
+ const [comment, setComment] = useState<string>('');
+ const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
+
+ /**
+ * Reset all the form fields.
+ */
+ const resetForm = () => {
+ setName('');
+ setEmail('');
+ setWebsite('');
+ setComment('');
+ setIsSubmitting(false);
+ };
+
+ const nameLabel = intl.formatMessage({
+ defaultMessage: 'Name:',
+ description: 'CommentForm: name label',
+ id: 'ZIrTee',
+ });
+
+ const emailLabel = intl.formatMessage({
+ defaultMessage: 'Email:',
+ description: 'CommentForm: email label',
+ id: 'Bh7z5v',
+ });
+
+ const websiteLabel = intl.formatMessage({
+ defaultMessage: 'Website:',
+ description: 'CommentForm: website label',
+ id: 'u41qSk',
+ });
+
+ const commentLabel = intl.formatMessage({
+ defaultMessage: 'Comment:',
+ description: 'CommentForm: comment label',
+ id: 'A8hGaK',
+ });
+
+ const formTitle = intl.formatMessage({
+ defaultMessage: 'Comment form',
+ description: 'CommentForm: aria label',
+ id: 'dz2kDV',
+ });
+
+ const formAriaLabel = title ? undefined : formTitle;
+ const formId = 'comment-form-title';
+ const formLabelledBy = title ? formId : undefined;
+
+ /**
+ * Handle form submit.
+ */
+ const submitHandler = () => {
+ setIsSubmitting(true);
+ saveComment({ comment, email, name, parentId, website }, resetForm).then(
+ () => setIsSubmitting(false)
+ );
+ };
+
+ return (
+ <Form
+ onSubmit={submitHandler}
+ aria-label={formAriaLabel}
+ aria-labelledby={formLabelledBy}
+ {...props}
+ >
+ {title && (
+ <Heading id={formId} level={titleLevel} alignment={titleAlignment}>
+ {title}
+ </Heading>
+ )}
+ <LabelledField
+ type="text"
+ id="commenter-name"
+ name="commenter-name"
+ label={nameLabel}
+ required={true}
+ value={name}
+ setValue={setName}
+ className={styles.field}
+ />
+ <LabelledField
+ type="email"
+ id="commenter-email"
+ name="commenter-email"
+ label={emailLabel}
+ required={true}
+ value={email}
+ setValue={setEmail}
+ className={styles.field}
+ />
+ <LabelledField
+ type="text"
+ id="commenter-website"
+ name="commenter-website"
+ label={websiteLabel}
+ required={false}
+ value={website}
+ setValue={setWebsite}
+ className={styles.field}
+ />
+ <LabelledField
+ type="textarea"
+ id="commenter-comment"
+ name="commenter-comment"
+ label={commentLabel}
+ required={true}
+ value={comment}
+ setValue={setComment}
+ className={styles.field}
+ />
+ <Button type="submit" kind="primary" className={styles.button}>
+ {intl.formatMessage({
+ defaultMessage: 'Publish',
+ description: 'CommentForm: submit button',
+ id: 'OL0Yzx',
+ })}
+ </Button>
+ {isSubmitting && (
+ <Spinner
+ message={intl.formatMessage({
+ defaultMessage: 'Submitting...',
+ description: 'CommentForm: spinner message on submit',
+ id: 'IY5ew6',
+ })}
+ />
+ )}
+ {Notice}
+ </Form>
+ );
+};
+
+export default CommentForm;
diff --git a/src/components/organisms/forms/contact-form.module.scss b/src/components/organisms/forms/contact-form.module.scss
new file mode 100644
index 0000000..f3f2646
--- /dev/null
+++ b/src/components/organisms/forms/contact-form.module.scss
@@ -0,0 +1,8 @@
+.field {
+ width: 100%;
+}
+
+.button {
+ display: block;
+ margin: auto;
+}
diff --git a/src/components/organisms/forms/contact-form.stories.tsx b/src/components/organisms/forms/contact-form.stories.tsx
new file mode 100644
index 0000000..191d448
--- /dev/null
+++ b/src/components/organisms/forms/contact-form.stories.tsx
@@ -0,0 +1,65 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ContactForm from './contact-form';
+
+/**
+ * ContactForm - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Forms',
+ component: ContactForm,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the form wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ Notice: {
+ control: {
+ type: null,
+ },
+ description: 'A component to display a success or error message.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'function',
+ required: false,
+ },
+ },
+ sendMail: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to process the contact form data.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof ContactForm>;
+
+const Template: ComponentStory<typeof ContactForm> = (args) => (
+ <ContactForm {...args} />
+);
+
+/**
+ * Forms Stories - Contact
+ */
+export const Contact = Template.bind({});
+Contact.args = {
+ sendMail: async (_data, reset: () => void) => {
+ reset();
+ },
+};
diff --git a/src/components/organisms/forms/contact-form.test.tsx b/src/components/organisms/forms/contact-form.test.tsx
new file mode 100644
index 0000000..6225fa9
--- /dev/null
+++ b/src/components/organisms/forms/contact-form.test.tsx
@@ -0,0 +1,48 @@
+import { render, screen } from '@test-utils';
+import ContactForm from './contact-form';
+
+const props = {
+ sendMail: async () => {
+ /** Do nothing. */
+ },
+};
+
+describe('ContactForm', () => {
+ it('renders a contact form', () => {
+ render(<ContactForm {...props} />);
+ expect(
+ screen.getByRole('form', { name: 'Contact form' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a name field', () => {
+ render(<ContactForm {...props} />);
+ expect(screen.getByRole('textbox', { name: /^Name:/ })).toBeInTheDocument();
+ });
+
+ it('renders an email field', () => {
+ render(<ContactForm {...props} />);
+ expect(
+ screen.getByRole('textbox', { name: /^Email:/ })
+ ).toBeInTheDocument();
+ });
+
+ it('renders an object field', () => {
+ render(<ContactForm {...props} />);
+ expect(
+ screen.getByRole('textbox', { name: /^Object:/ })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a message field', () => {
+ render(<ContactForm {...props} />);
+ expect(
+ screen.getByRole('textbox', { name: /^Message:/ })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a submit button', () => {
+ render(<ContactForm {...props} />);
+ expect(screen.getByRole('button', { name: /^Send/ })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/contact-form.tsx b/src/components/organisms/forms/contact-form.tsx
new file mode 100644
index 0000000..912402c
--- /dev/null
+++ b/src/components/organisms/forms/contact-form.tsx
@@ -0,0 +1,158 @@
+import Button from '@components/atoms/buttons/button';
+import Form from '@components/atoms/forms/form';
+import Spinner from '@components/atoms/loaders/spinner';
+import LabelledField from '@components/molecules/forms/labelled-field';
+import { FC, ReactNode, useState } from 'react';
+import { useIntl } from 'react-intl';
+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.
+ */
+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} onSubmit={submitHandler} className={className}>
+ <LabelledField
+ type="text"
+ id="contact-name"
+ name="contact-name"
+ label={nameLabel}
+ required={true}
+ value={name}
+ setValue={setName}
+ className={styles.field}
+ />
+ <LabelledField
+ type="email"
+ id="contact-email"
+ name="contact-email"
+ label={emailLabel}
+ required={true}
+ value={email}
+ setValue={setEmail}
+ className={styles.field}
+ />
+ <LabelledField
+ type="text"
+ id="contact-object"
+ name="contact-object"
+ label={objectLabel}
+ value={object}
+ setValue={setObject}
+ className={styles.field}
+ />
+ <LabelledField
+ type="textarea"
+ id="contact-message"
+ name="contact-message"
+ label={messageLabel}
+ required={true}
+ value={message}
+ setValue={setMessage}
+ className={styles.field}
+ />
+ <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>
+ );
+};
+
+export default ContactForm;
diff --git a/src/components/organisms/forms/search-form.module.scss b/src/components/organisms/forms/search-form.module.scss
new file mode 100644
index 0000000..1d388a4
--- /dev/null
+++ b/src/components/organisms/forms/search-form.module.scss
@@ -0,0 +1,58 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ display: flex;
+ align-items: center;
+ position: relative;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ max-width: 35ch;
+ }
+ }
+}
+
+.btn {
+ position: absolute;
+ right: 0;
+
+ &__icon {
+ transform: scale(0.85);
+ transition: all 0.3s ease-in-out 0s;
+ }
+
+ &:focus {
+ outline: var(--color-primary-light) solid fun.convert-px(3);
+ }
+
+ &:active {
+ outline: none;
+ }
+
+ &:hover &,
+ &:focus & {
+ &__icon {
+ transform: scale(0.85) rotate(20deg) translateY(#{fun.convert-px(3)});
+ }
+ }
+
+ &:active & {
+ &__icon {
+ transform: scale(0.7);
+ }
+ }
+}
+
+.field {
+ width: 100%;
+ padding-right: var(--spacing-lg);
+
+ &:hover ~ .btn {
+ transform: translate(fun.convert-px(-3), fun.convert-px(-3));
+ }
+
+ &:focus ~ .btn {
+ 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.stories.tsx
new file mode 100644
index 0000000..d8c8e1e
--- /dev/null
+++ b/src/components/organisms/forms/search-form.stories.tsx
@@ -0,0 +1,65 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SearchForm from './search-form';
+
+/**
+ * SearchForm - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Forms',
+ component: SearchForm,
+ args: {
+ hideLabel: false,
+ searchPage: '#',
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the form wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ hideLabel: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the input label should be visually hidden.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ searchPage: {
+ control: {
+ type: 'text',
+ },
+ description: 'The search results page url.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SearchForm>;
+
+const Template: ComponentStory<typeof SearchForm> = (args) => (
+ <SearchForm {...args} />
+);
+
+/**
+ * Forms Stories - Search
+ */
+export const Search = Template.bind({});
+Search.args = {
+ hideLabel: true,
+};
diff --git a/src/components/organisms/forms/search-form.test.tsx b/src/components/organisms/forms/search-form.test.tsx
new file mode 100644
index 0000000..59a2f68
--- /dev/null
+++ b/src/components/organisms/forms/search-form.test.tsx
@@ -0,0 +1,16 @@
+import { render, screen } from '@test-utils';
+import SearchForm from './search-form';
+
+describe('SearchForm', () => {
+ it('renders a search input', () => {
+ render(<SearchForm searchPage="#" />);
+ expect(
+ screen.getByRole('searchbox', { name: 'Search for:' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a submit button', () => {
+ render(<SearchForm searchPage="#" />);
+ expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/search-form.tsx b/src/components/organisms/forms/search-form.tsx
new file mode 100644
index 0000000..1b5f662
--- /dev/null
+++ b/src/components/organisms/forms/search-form.tsx
@@ -0,0 +1,76 @@
+import Button from '@components/atoms/buttons/button';
+import Form from '@components/atoms/forms/form';
+import MagnifyingGlass from '@components/atoms/icons/magnifying-glass';
+import LabelledField, {
+ type LabelledFieldProps,
+} from '@components/molecules/forms/labelled-field';
+import { useRouter } from 'next/router';
+import { forwardRef, ForwardRefRenderFunction, useId, useState } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './search-form.module.scss';
+
+export type SearchFormProps = Pick<LabelledFieldProps, 'hideLabel'> & {
+ /**
+ * The search page url.
+ */
+ searchPage: string;
+};
+
+/**
+ * SearchForm component
+ *
+ * Render a search form.
+ */
+const SearchForm: ForwardRefRenderFunction<
+ HTMLInputElement,
+ SearchFormProps
+> = ({ hideLabel, searchPage }, ref) => {
+ const intl = useIntl();
+ const fieldLabel = intl.formatMessage({
+ defaultMessage: 'Search for:',
+ description: 'SearchForm: field accessible label',
+ id: 'X8oujO',
+ });
+ const buttonLabel = intl.formatMessage({
+ defaultMessage: 'Search',
+ description: 'SearchForm: button accessible name',
+ id: 'WMqQrv',
+ });
+
+ const router = useRouter();
+ const [value, setValue] = useState<string>('');
+
+ const submitHandler = () => {
+ router.push({ pathname: searchPage, query: { s: value } });
+ setValue('');
+ };
+
+ const id = useId();
+
+ return (
+ <Form grouped={false} onSubmit={submitHandler} className={styles.wrapper}>
+ <LabelledField
+ className={styles.field}
+ hideLabel={hideLabel}
+ id={`search-form-${id}`}
+ label={fieldLabel}
+ name="search-form"
+ ref={ref}
+ setValue={setValue}
+ type="search"
+ value={value}
+ />
+ <Button
+ type="submit"
+ kind="neutral"
+ shape="initial"
+ className={styles.btn}
+ aria-label={buttonLabel}
+ >
+ <MagnifyingGlass className={styles.btn__icon} />
+ </Button>
+ </Form>
+ );
+};
+
+export default forwardRef(SearchForm);
diff --git a/src/components/organisms/forms/settings-form.module.scss b/src/components/organisms/forms/settings-form.module.scss
new file mode 100644
index 0000000..a6a2077
--- /dev/null
+++ b/src/components/organisms/forms/settings-form.module.scss
@@ -0,0 +1,11 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.label {
+ margin-right: auto;
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "2xs", "height") {
+ font-size: var(--font-size-sm);
+ }
+ }
+}
diff --git a/src/components/organisms/forms/settings-form.stories.tsx b/src/components/organisms/forms/settings-form.stories.tsx
new file mode 100644
index 0000000..70e1844
--- /dev/null
+++ b/src/components/organisms/forms/settings-form.stories.tsx
@@ -0,0 +1,67 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SettingsForm from './settings-form';
+
+/**
+ * SettingsModal - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Forms',
+ component: SettingsForm,
+ argTypes: {
+ ackeeStorageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'The local storage key for Ackee setting.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the modal wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ motionStorageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'The local storage key for reduced motion setting.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ tooltipClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the tooltip wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof SettingsForm>;
+
+const Template: ComponentStory<typeof SettingsForm> = (args) => (
+ <SettingsForm {...args} />
+);
+
+/**
+ * Form Stories - Settings
+ */
+export const Settings = Template.bind({});
diff --git a/src/components/organisms/forms/settings-form.test.tsx b/src/components/organisms/forms/settings-form.test.tsx
new file mode 100644
index 0000000..43d546e
--- /dev/null
+++ b/src/components/organisms/forms/settings-form.test.tsx
@@ -0,0 +1,67 @@
+import { render, screen } from '@test-utils';
+import SettingsForm from './settings-form';
+
+const ackeeStorageKey = 'ackee-tracking';
+const motionStorageKey = 'reduce-motion';
+
+describe('SettingsForm', () => {
+ it('renders a form', () => {
+ render(
+ <SettingsForm
+ ackeeStorageKey={ackeeStorageKey}
+ motionStorageKey={motionStorageKey}
+ />
+ );
+ expect(
+ screen.getByRole('form', { name: /^Settings form/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a theme toggle setting', () => {
+ render(
+ <SettingsForm
+ ackeeStorageKey={ackeeStorageKey}
+ motionStorageKey={motionStorageKey}
+ />
+ );
+ expect(
+ screen.getByRole('checkbox', { name: /^Theme:/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a code blocks toggle setting', () => {
+ render(
+ <SettingsForm
+ ackeeStorageKey={ackeeStorageKey}
+ motionStorageKey={motionStorageKey}
+ />
+ );
+ expect(
+ screen.getByRole('checkbox', { name: /^Code blocks:/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a motion setting', () => {
+ render(
+ <SettingsForm
+ ackeeStorageKey={ackeeStorageKey}
+ motionStorageKey={motionStorageKey}
+ />
+ );
+ expect(
+ screen.getByRole('checkbox', { name: /^Animations:/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a Ackee setting', () => {
+ render(
+ <SettingsForm
+ ackeeStorageKey={ackeeStorageKey}
+ motionStorageKey={motionStorageKey}
+ />
+ );
+ expect(
+ screen.getByRole('combobox', { name: /^Tracking:/i })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/settings-form.tsx b/src/components/organisms/forms/settings-form.tsx
new file mode 100644
index 0000000..c897fa5
--- /dev/null
+++ b/src/components/organisms/forms/settings-form.tsx
@@ -0,0 +1,56 @@
+import Form from '@components/atoms/forms/form';
+import AckeeSelect, {
+ type AckeeSelectProps,
+} from '@components/molecules/forms/ackee-select';
+import MotionToggle, {
+ MotionToggleProps,
+} from '@components/molecules/forms/motion-toggle';
+import PrismThemeToggle from '@components/molecules/forms/prism-theme-toggle';
+import ThemeToggle from '@components/molecules/forms/theme-toggle';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './settings-form.module.scss';
+
+export type SettingsFormProps = Pick<AckeeSelectProps, 'tooltipClassName'> & {
+ /**
+ * The local storage key for Ackee settings.
+ */
+ ackeeStorageKey: AckeeSelectProps['storageKey'];
+ /**
+ * The local storage key for Reduce motion settings.
+ */
+ motionStorageKey: MotionToggleProps['storageKey'];
+};
+
+const SettingsForm: FC<SettingsFormProps> = ({
+ ackeeStorageKey,
+ motionStorageKey,
+ tooltipClassName,
+}) => {
+ const intl = useIntl();
+ const ariaLabel = intl.formatMessage({
+ defaultMessage: 'Settings form',
+ id: 'gX+YVy',
+ description: 'SettingsForm: an accessible form name',
+ });
+
+ return (
+ <Form aria-label={ariaLabel} onSubmit={() => null}>
+ <ThemeToggle labelClassName={styles.label} />
+ <PrismThemeToggle labelClassName={styles.label} />
+ <MotionToggle
+ labelClassName={styles.label}
+ storageKey={motionStorageKey}
+ value={false}
+ />
+ <AckeeSelect
+ initialValue="full"
+ labelClassName={styles.label}
+ tooltipClassName={tooltipClassName}
+ storageKey={ackeeStorageKey}
+ />
+ </Form>
+ );
+};
+
+export default SettingsForm;