From a6ff5eee45215effb3344cb5d631a27a7c0369aa Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Fri, 22 Sep 2023 19:34:01 +0200 Subject: refactor(components): rewrite form components --- .../forms/ackee-toggle/ackee-toggle.fixture.tsx | 1 + .../forms/ackee-toggle/ackee-toggle.stories.tsx | 47 ++++ .../forms/ackee-toggle/ackee-toggle.test.tsx | 15 ++ .../organisms/forms/ackee-toggle/ackee-toggle.tsx | 139 ++++++++++++ .../organisms/forms/ackee-toggle/index.ts | 1 + .../organisms/forms/comment-form.module.scss | 8 - .../organisms/forms/comment-form.stories.tsx | 123 ---------- .../organisms/forms/comment-form.test.tsx | 23 -- src/components/organisms/forms/comment-form.tsx | 193 ---------------- .../forms/comment-form/comment-form.module.scss | 18 ++ .../forms/comment-form/comment-form.stories.tsx | 123 ++++++++++ .../forms/comment-form/comment-form.test.tsx | 23 ++ .../organisms/forms/comment-form/comment-form.tsx | 251 +++++++++++++++++++++ .../organisms/forms/comment-form/index.ts | 1 + .../organisms/forms/contact-form.module.scss | 8 - .../organisms/forms/contact-form.stories.tsx | 65 ------ .../organisms/forms/contact-form.test.tsx | 48 ---- src/components/organisms/forms/contact-form.tsx | 154 ------------- .../forms/contact-form/contact-form.module.scss | 15 ++ .../forms/contact-form/contact-form.stories.tsx | 65 ++++++ .../forms/contact-form/contact-form.test.tsx | 48 ++++ .../organisms/forms/contact-form/contact-form.tsx | 210 +++++++++++++++++ .../organisms/forms/contact-form/index.ts | 1 + src/components/organisms/forms/index.ts | 4 + .../organisms/forms/motion-toggle/index.ts | 1 + .../forms/motion-toggle/motion-toggle.fixture.tsx | 1 + .../forms/motion-toggle/motion-toggle.stories.tsx | 47 ++++ .../forms/motion-toggle/motion-toggle.test.tsx | 15 ++ .../forms/motion-toggle/motion-toggle.tsx | 89 ++++++++ .../organisms/forms/prism-theme-toggle/index.ts | 1 + .../prism-theme-toggle.stories.tsx | 20 ++ .../prism-theme-toggle/prism-theme-toggle.test.tsx | 13 ++ .../prism-theme-toggle/prism-theme-toggle.tsx | 85 +++++++ .../organisms/forms/search-form.module.scss | 58 ----- .../organisms/forms/search-form.stories.tsx | 65 ------ .../organisms/forms/search-form.test.tsx | 16 -- src/components/organisms/forms/search-form.tsx | 72 ------ .../organisms/forms/search-form/index.ts | 1 + .../forms/search-form/search-form.module.scss | 67 ++++++ .../forms/search-form/search-form.stories.tsx | 65 ++++++ .../forms/search-form/search-form.test.tsx | 16 ++ .../organisms/forms/search-form/search-form.tsx | 98 ++++++++ .../organisms/forms/theme-toggle/index.ts | 1 + .../forms/theme-toggle/theme-toggle.stories.tsx | 20 ++ .../forms/theme-toggle/theme-toggle.test.tsx | 13 ++ .../organisms/forms/theme-toggle/theme-toggle.tsx | 76 +++++++ 46 files changed, 1591 insertions(+), 833 deletions(-) create mode 100644 src/components/organisms/forms/ackee-toggle/ackee-toggle.fixture.tsx create mode 100644 src/components/organisms/forms/ackee-toggle/ackee-toggle.stories.tsx create mode 100644 src/components/organisms/forms/ackee-toggle/ackee-toggle.test.tsx create mode 100644 src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx create mode 100644 src/components/organisms/forms/ackee-toggle/index.ts delete mode 100644 src/components/organisms/forms/comment-form.module.scss delete mode 100644 src/components/organisms/forms/comment-form.stories.tsx delete mode 100644 src/components/organisms/forms/comment-form.test.tsx delete mode 100644 src/components/organisms/forms/comment-form.tsx create mode 100644 src/components/organisms/forms/comment-form/comment-form.module.scss create mode 100644 src/components/organisms/forms/comment-form/comment-form.stories.tsx create mode 100644 src/components/organisms/forms/comment-form/comment-form.test.tsx create mode 100644 src/components/organisms/forms/comment-form/comment-form.tsx create mode 100644 src/components/organisms/forms/comment-form/index.ts delete mode 100644 src/components/organisms/forms/contact-form.module.scss delete mode 100644 src/components/organisms/forms/contact-form.stories.tsx delete mode 100644 src/components/organisms/forms/contact-form.test.tsx delete mode 100644 src/components/organisms/forms/contact-form.tsx create mode 100644 src/components/organisms/forms/contact-form/contact-form.module.scss create mode 100644 src/components/organisms/forms/contact-form/contact-form.stories.tsx create mode 100644 src/components/organisms/forms/contact-form/contact-form.test.tsx create mode 100644 src/components/organisms/forms/contact-form/contact-form.tsx create mode 100644 src/components/organisms/forms/contact-form/index.ts create mode 100644 src/components/organisms/forms/motion-toggle/index.ts create mode 100644 src/components/organisms/forms/motion-toggle/motion-toggle.fixture.tsx create mode 100644 src/components/organisms/forms/motion-toggle/motion-toggle.stories.tsx create mode 100644 src/components/organisms/forms/motion-toggle/motion-toggle.test.tsx create mode 100644 src/components/organisms/forms/motion-toggle/motion-toggle.tsx create mode 100644 src/components/organisms/forms/prism-theme-toggle/index.ts create mode 100644 src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.stories.tsx create mode 100644 src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.test.tsx create mode 100644 src/components/organisms/forms/prism-theme-toggle/prism-theme-toggle.tsx delete mode 100644 src/components/organisms/forms/search-form.module.scss delete mode 100644 src/components/organisms/forms/search-form.stories.tsx delete mode 100644 src/components/organisms/forms/search-form.test.tsx delete mode 100644 src/components/organisms/forms/search-form.tsx create mode 100644 src/components/organisms/forms/search-form/index.ts create mode 100644 src/components/organisms/forms/search-form/search-form.module.scss create mode 100644 src/components/organisms/forms/search-form/search-form.stories.tsx create mode 100644 src/components/organisms/forms/search-form/search-form.test.tsx create mode 100644 src/components/organisms/forms/search-form/search-form.tsx create mode 100644 src/components/organisms/forms/theme-toggle/index.ts create mode 100644 src/components/organisms/forms/theme-toggle/theme-toggle.stories.tsx create mode 100644 src/components/organisms/forms/theme-toggle/theme-toggle.test.tsx create mode 100644 src/components/organisms/forms/theme-toggle/theme-toggle.tsx (limited to 'src/components/organisms/forms') 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; + +const Template: ComponentStory = (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(); + 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 & { + /** + * 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 = ({ + defaultValue, + direction, + storageKey, + ...props +}) => { + const intl = useIntl(); + const { value, setValue } = useLocalStorage( + 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) => { + setValue(e.target.value === 'full' ? 'full' : 'partial'); + }; + + const closeTooltip = () => { + setIsTooltipOpened(false); + }; + const toggleTooltip = () => { + setIsTooltipOpened((prev) => !prev); + }; + + return ( + {ackeeLabel}} + name="ackee" + onSwitch={updateSetting} + 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.stories.tsx b/src/components/organisms/forms/comment-form.stories.tsx deleted file mode 100644 index a6069e6..0000000 --- a/src/components/organisms/forms/comment-form.stories.tsx +++ /dev/null @@ -1,123 +0,0 @@ -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; - -const Template: ComponentStory = (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 deleted file mode 100644 index f11c449..0000000 --- a/src/components/organisms/forms/comment-form.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { render, screen } from '../../../../tests/utils'; -import { CommentForm } from './comment-form'; - -const saveComment = async () => { - /** Do nothing. */ -}; -const title = 'Cum voluptas voluptatibus'; - -describe('CommentForm', () => { - it('renders a form', () => { - render(); - expect(screen.getByRole('form')).toBeInTheDocument(); - }); - - it('renders an optional title', () => { - render( - - ); - 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 deleted file mode 100644 index e4140dd..0000000 --- a/src/components/organisms/forms/comment-form.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { FC, ReactNode, useState } from 'react'; -import { useIntl } from 'react-intl'; -import { - Button, - Form, - type FormProps, - Heading, - type HeadingLevel, - type HeadingProps, - Spinner, -} from '../../atoms'; -import { LabelledField } from '../../molecules'; -import styles from './comment-form.module.scss'; - -export type CommentFormData = { - comment: string; - email: string; - name: string; - parentId?: number; - website?: string; -}; - -export type CommentFormProps = Pick & { - /** - * 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; - /** - * The form title. - */ - title?: string; - /** - * The form title alignment. Default: left. - */ - titleAlignment?: HeadingProps['alignment']; - /** - * The title level. Default: 2. - */ - titleLevel?: HeadingLevel; -}; - -export const CommentForm: FC = ({ - Notice, - parentId, - saveComment, - title, - titleAlignment, - titleLevel = 2, - ...props -}) => { - const intl = useIntl(); - const [name, setName] = useState(''); - const [email, setEmail] = useState(''); - const [website, setWebsite] = useState(''); - const [comment, setComment] = useState(''); - const [isSubmitting, setIsSubmitting] = useState(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 ( -
- {title && ( - - {title} - - )} - - - - - - {isSubmitting && ( - - )} - {Notice} - - ); -}; 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/comment-form.stories.tsx b/src/components/organisms/forms/comment-form/comment-form.stories.tsx new file mode 100644 index 0000000..a6069e6 --- /dev/null +++ b/src/components/organisms/forms/comment-form/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; + +const Template: ComponentStory = (args) => ( + +); + +/** + * Forms Stories - Comment + */ +export const Comment = Template.bind({}); diff --git a/src/components/organisms/forms/comment-form/comment-form.test.tsx b/src/components/organisms/forms/comment-form/comment-form.test.tsx new file mode 100644 index 0000000..8aa38af --- /dev/null +++ b/src/components/organisms/forms/comment-form/comment-form.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '../../../../../tests/utils'; +import { CommentForm } from './comment-form'; + +const saveComment = async () => { + /** Do nothing. */ +}; +const title = 'Cum voluptas voluptatibus'; + +describe('CommentForm', () => { + it('renders a form', () => { + render(); + expect(screen.getByRole('form')).toBeInTheDocument(); + }); + + it('renders an optional title', () => { + render( + + ); + expect( + screen.getByRole('heading', { level: 2, name: title }) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/organisms/forms/comment-form/comment-form.tsx b/src/components/organisms/forms/comment-form/comment-form.tsx new file mode 100644 index 0000000..be5d58f --- /dev/null +++ b/src/components/organisms/forms/comment-form/comment-form.tsx @@ -0,0 +1,251 @@ +import { ChangeEvent, FC, FormEvent, ReactNode, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { + Button, + Form, + type FormProps, + Heading, + type HeadingLevel, + type HeadingProps, + Spinner, + 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; + parentId?: number; + website?: string; +}; + +export type CommentFormProps = Pick & { + /** + * 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; + /** + * The form title. + */ + title?: string; + /** + * The form title alignment. Default: left. + */ + titleAlignment?: HeadingProps['alignment']; + /** + * The title level. Default: 2. + */ + titleLevel?: HeadingLevel; +}; + +export const CommentForm: FC = ({ + className = '', + Notice, + parentId, + saveComment, + title, + titleAlignment, + titleLevel = 2, + ...props +}) => { + const formClass = `${styles.form} ${className}`; + const intl = useIntl(); + const emptyForm: CommentFormData = { + author: '', + comment: '', + email: '', + parentId, + website: '', + }; + const [data, setData] = useState(emptyForm); + const [isSubmitting, setIsSubmitting] = useState(false); + + /** + * Reset all the form fields. + */ + const resetForm = () => { + setData(emptyForm); + 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; + + const updateForm = ( + e: ChangeEvent + ) => { + switch (e.target.name) { + case 'author': + setData((prevData) => { + return { ...prevData, author: e.target.value }; + }); + break; + case 'comment': + setData((prevData) => { + return { ...prevData, comment: e.target.value }; + }); + break; + case 'email': + setData((prevData) => { + return { ...prevData, email: e.target.value }; + }); + break; + case 'website': + setData((prevData) => { + return { ...prevData, website: e.target.value }; + }); + break; + default: + break; + } + }; + + const submitHandler = (e: FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + saveComment(data, resetForm).then(() => setIsSubmitting(false)); + }; + + return ( +
+ {title && ( + + {title} + + )} + + } + label={ + + } + /> + + } + label={ + + } + /> + + } + label={} + /> + + } + label={ + + } + /> + + {isSubmitting && ( + + )} + {Notice} + + ); +}; 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.stories.tsx b/src/components/organisms/forms/contact-form.stories.tsx deleted file mode 100644 index 4df3db0..0000000 --- a/src/components/organisms/forms/contact-form.stories.tsx +++ /dev/null @@ -1,65 +0,0 @@ -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; - -const Template: ComponentStory = (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 deleted file mode 100644 index 8e27cd0..0000000 --- a/src/components/organisms/forms/contact-form.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { render, screen } from '../../../../tests/utils'; -import { ContactForm } from './contact-form'; - -const props = { - sendMail: async () => { - /** Do nothing. */ - }, -}; - -describe('ContactForm', () => { - it('renders a contact form', () => { - render(); - expect( - screen.getByRole('form', { name: 'Contact form' }) - ).toBeInTheDocument(); - }); - - it('renders a name field', () => { - render(); - expect(screen.getByRole('textbox', { name: /^Name:/ })).toBeInTheDocument(); - }); - - it('renders an email field', () => { - render(); - expect( - screen.getByRole('textbox', { name: /^Email:/ }) - ).toBeInTheDocument(); - }); - - it('renders an object field', () => { - render(); - expect( - screen.getByRole('textbox', { name: /^Object:/ }) - ).toBeInTheDocument(); - }); - - it('renders a message field', () => { - render(); - expect( - screen.getByRole('textbox', { name: /^Message:/ }) - ).toBeInTheDocument(); - }); - - it('renders a submit button', () => { - render(); - 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 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; -}; - -/** - * ContactForm component - * - * Render a contact form. - */ -export const ContactForm: FC = ({ - className = '', - Notice, - sendMail, -}) => { - const intl = useIntl(); - const [name, setName] = useState(''); - const [email, setEmail] = useState(''); - const [object, setObject] = useState(''); - const [message, setMessage] = useState(''); - const [isSubmitting, setIsSubmitting] = useState(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 ( -
- - - - - - {isSubmitting && ( - - )} - {Notice} - - ); -}; 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/contact-form.stories.tsx b/src/components/organisms/forms/contact-form/contact-form.stories.tsx new file mode 100644 index 0000000..4df3db0 --- /dev/null +++ b/src/components/organisms/forms/contact-form/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; + +const Template: ComponentStory = (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/contact-form.test.tsx b/src/components/organisms/forms/contact-form/contact-form.test.tsx new file mode 100644 index 0000000..59d69fa --- /dev/null +++ b/src/components/organisms/forms/contact-form/contact-form.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '../../../../../tests/utils'; +import { ContactForm } from './contact-form'; + +const props = { + sendMail: async () => { + /** Do nothing. */ + }, +}; + +describe('ContactForm', () => { + it('renders a contact form', () => { + render(); + expect( + screen.getByRole('form', { name: 'Contact form' }) + ).toBeInTheDocument(); + }); + + it('renders a name field', () => { + render(); + expect(screen.getByRole('textbox', { name: /^Name:/ })).toBeInTheDocument(); + }); + + it('renders an email field', () => { + render(); + expect( + screen.getByRole('textbox', { name: /^Email:/ }) + ).toBeInTheDocument(); + }); + + it('renders an object field', () => { + render(); + expect( + screen.getByRole('textbox', { name: /^Object:/ }) + ).toBeInTheDocument(); + }); + + it('renders a message field', () => { + render(); + expect( + screen.getByRole('textbox', { name: /^Message:/ }) + ).toBeInTheDocument(); + }); + + it('renders a submit button', () => { + render(); + expect(screen.getByRole('button', { name: /^Send/ })).toBeInTheDocument(); + }); +}); 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; +}; + +/** + * ContactForm component + * + * Render a contact form. + */ +export const ContactForm: FC = ({ + 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(false); + + /** + * Reset all the form fields. + */ + const resetForm = () => { + setData(emptyForm); + setIsSubmitting(false); + }; + + const updateForm = ( + e: ChangeEvent + ) => { + 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 ( +
+ + } + label={ + + } + /> + + } + label={ + + } + /> + + } + label={} + /> + + } + label={ + + } + /> + + {isSubmitting && ( + + )} + {Notice} + + ); +}; 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; + +const Template: ComponentStory = (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(); + 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 = ({ + defaultValue, + storageKey, + ...props +}) => { + const intl = useIntl(); + const { value: isReduced, setValue: setIsReduced } = useLocalStorage( + 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) => { + setIsReduced((prev) => !prev); + }; + + return ( + {reduceMotionLabel}} + 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; + +const Template: ComponentStory = (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(); + 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 = (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) => { + 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: ( + <> + {lightThemeLabel} + + + ), + value: 'light', + }, + { + id: 'code-blocks-dark', + label: ( + <> + {darkThemeLabel} + + + ), + value: 'dark', + }, + ]; + + return ( + {themeLabel}} + name="code-blocks" + onSwitch={updateTheme} + value={isDarkTheme(theme) ? 'dark' : 'light'} + /> + ); +}; diff --git a/src/components/organisms/forms/search-form.module.scss b/src/components/organisms/forms/search-form.module.scss deleted file mode 100644 index 773a79f..0000000 --- a/src/components/organisms/forms/search-form.module.scss +++ /dev/null @@ -1,58 +0,0 @@ -@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 deleted file mode 100644 index 4a0a15c..0000000 --- a/src/components/organisms/forms/search-form.stories.tsx +++ /dev/null @@ -1,65 +0,0 @@ -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; - -const Template: ComponentStory = (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 deleted file mode 100644 index bc9b7a0..0000000 --- a/src/components/organisms/forms/search-form.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { render, screen } from '../../../../tests/utils'; -import { SearchForm } from './search-form'; - -describe('SearchForm', () => { - it('renders a search input', () => { - render(); - expect( - screen.getByRole('searchbox', { name: 'Search for:' }) - ).toBeInTheDocument(); - }); - - it('renders a submit button', () => { - render(); - 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 deleted file mode 100644 index f80d295..0000000 --- a/src/components/organisms/forms/search-form.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useRouter } from 'next/router'; -import { forwardRef, ForwardRefRenderFunction, useId, useState } from 'react'; -import { useIntl } from 'react-intl'; -import { Button, Form, MagnifyingGlass } from '../../atoms'; -import { LabelledField, type LabelledFieldProps } from '../../molecules'; -import styles from './search-form.module.scss'; - -export type SearchFormProps = Pick & { - /** - * The search page url. - */ - searchPage: string; -}; - -const SearchFormWithRef: 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(''); - - const submitHandler = () => { - router.push({ pathname: searchPage, query: { s: value } }); - setValue(''); - }; - - const id = useId(); - - return ( -
- - - - ); -}; - -/** - * SearchForm component - * - * Render a search form. - */ -export const SearchForm = forwardRef(SearchFormWithRef); 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/search-form.module.scss b/src/components/organisms/forms/search-form/search-form.module.scss new file mode 100644 index 0000000..e485380 --- /dev/null +++ b/src/components/organisms/forms/search-form/search-form.module.scss @@ -0,0 +1,67 @@ +@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 { + 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); + 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 { + &: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; + } + + &: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/search-form.stories.tsx b/src/components/organisms/forms/search-form/search-form.stories.tsx new file mode 100644 index 0000000..c5fbeb9 --- /dev/null +++ b/src/components/organisms/forms/search-form/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: { + isLabelHidden: false, + searchPage: '#', + }, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the form wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + isLabelHidden: { + 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; + +const Template: ComponentStory = (args) => ( + +); + +/** + * Forms Stories - Search + */ +export const Search = Template.bind({}); +Search.args = { + isLabelHidden: true, +}; diff --git a/src/components/organisms/forms/search-form/search-form.test.tsx b/src/components/organisms/forms/search-form/search-form.test.tsx new file mode 100644 index 0000000..b53b9cf --- /dev/null +++ b/src/components/organisms/forms/search-form/search-form.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from '../../../../../tests/utils'; +import { SearchForm } from './search-form'; + +describe('SearchForm', () => { + it('renders a search input', () => { + render(); + expect( + screen.getByRole('searchbox', { name: 'Search for:' }) + ).toBeInTheDocument(); + }); + + it('renders a submit button', () => { + render(); + expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument(); + }); +}); diff --git a/src/components/organisms/forms/search-form/search-form.tsx b/src/components/organisms/forms/search-form/search-form.tsx new file mode 100644 index 0000000..826e6c8 --- /dev/null +++ b/src/components/organisms/forms/search-form/search-form.tsx @@ -0,0 +1,98 @@ +import { useRouter } from 'next/router'; +import { + ChangeEvent, + FormEvent, + forwardRef, + ForwardRefRenderFunction, + useId, + useState, +} from 'react'; +import { useIntl } from 'react-intl'; +import { Button, Form, Input, Label, MagnifyingGlass } from '../../../atoms'; +import { LabelledField } from '../../../molecules'; +import styles from './search-form.module.scss'; + +export type SearchFormProps = { + /** + * Should the label be visually hidden? + * + * @default false + */ + isLabelHidden?: boolean; + /** + * The search page url. + */ + searchPage: string; +}; + +const SearchFormWithRef: ForwardRefRenderFunction< + HTMLInputElement, + SearchFormProps +> = ({ isLabelHidden = false, 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(''); + + const submitHandler = (e: FormEvent) => { + e.preventDefault(); + router.push({ pathname: searchPage, query: { s: value } }); + setValue(''); + }; + + const updateForm = (e: ChangeEvent) => { + setValue(e.target.value); + }; + + const id = useId(); + + return ( +
+ + } + label={ + + } + /> + + + ); +}; + +/** + * SearchForm component + * + * Render a search form. + */ +export const SearchForm = forwardRef(SearchFormWithRef); 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; + +const Template: ComponentStory = (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(); + 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 = (props) => { + const intl = useIntl(); + const { resolvedTheme, setTheme } = useTheme(); + const isDarkTheme = resolvedTheme === 'dark'; + + const updateTheme = (e: ChangeEvent) => { + 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: ( + <> + {lightThemeLabel} + + + ), + value: 'light', + }, + { + id: 'theme-dark', + label: ( + <> + {darkThemeLabel} + + + ), + value: 'dark', + }, + ]; + + return ( + {themeLabel}} + name="theme" + onSwitch={updateTheme} + value={isDarkTheme ? 'dark' : 'light'} + /> + ); +}; -- cgit v1.2.3