diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-09-22 19:34:01 +0200 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-10-24 12:23:48 +0200 |
| commit | a6ff5eee45215effb3344cb5d631a27a7c0369aa (patch) | |
| tree | 5051747acf72318b4fc5c18d603e3757fbefdfdb /src/components/organisms/forms | |
| parent | 651ea4fc992e77d2f36b3c68f8e7a70644246067 (diff) | |
refactor(components): rewrite form components
Diffstat (limited to 'src/components/organisms/forms')
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'} + /> + ); +}; |
