diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-10-30 11:18:11 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-11 18:15:27 +0100 |
| commit | 84a679b0e48ed76eee2fa44d3caac83591aa3c8c (patch) | |
| tree | 1d418a6c514ff8a04b84ba35c98736e8450f968c /src | |
| parent | 60c49f18389ff625177a57277ef8f292a31097bf (diff) | |
feat(hooks): add useBoolean and useToggle hooks
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/molecules/forms/flipping-label/flipping-label.stories.tsx | 7 | ||||
| -rw-r--r-- | src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx | 17 | ||||
| -rw-r--r-- | src/components/organisms/forms/comment-form/comment-form.tsx | 17 | ||||
| -rw-r--r-- | src/components/organisms/forms/contact-form/contact-form.tsx | 17 | ||||
| -rw-r--r-- | src/components/organisms/layout/comment.tsx | 13 | ||||
| -rw-r--r-- | src/components/organisms/toolbar/main-nav.stories.tsx | 8 | ||||
| -rw-r--r-- | src/components/organisms/toolbar/search.stories.tsx | 16 | ||||
| -rw-r--r-- | src/components/organisms/toolbar/settings.stories.tsx | 8 | ||||
| -rw-r--r-- | src/components/organisms/toolbar/toolbar.tsx | 53 | ||||
| -rw-r--r-- | src/utils/hooks/index.ts | 2 | ||||
| -rw-r--r-- | src/utils/hooks/use-boolean/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/hooks/use-boolean/use-boolean.test.ts | 45 | ||||
| -rw-r--r-- | src/utils/hooks/use-boolean/use-boolean.ts | 44 | ||||
| -rw-r--r-- | src/utils/hooks/use-toggle/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/hooks/use-toggle/use-toggle.test.ts | 24 | ||||
| -rw-r--r-- | src/utils/hooks/use-toggle/use-toggle.ts | 15 |
16 files changed, 204 insertions, 84 deletions
diff --git a/src/components/molecules/forms/flipping-label/flipping-label.stories.tsx b/src/components/molecules/forms/flipping-label/flipping-label.stories.tsx index c3c4f9a..906a488 100644 --- a/src/components/molecules/forms/flipping-label/flipping-label.stories.tsx +++ b/src/components/molecules/forms/flipping-label/flipping-label.stories.tsx @@ -1,5 +1,5 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { useCallback, useState } from 'react'; +import { useToggle } from '../../../../utils/hooks'; import { Button, Icon } from '../../../atoms'; import { FlippingLabel } from './flipping-label'; @@ -74,11 +74,10 @@ const Template: ComponentStory<typeof FlippingLabel> = ({ isActive, ...args }) => { - const [active, setActive] = useState<boolean>(isActive); - const updateState = useCallback(() => setActive((prev) => !prev), []); + const [active, toggle] = useToggle(isActive); return ( - <Button kind="neutral" onClick={updateState} shape="initial" type="button"> + <Button kind="neutral" onClick={toggle} shape="initial" type="button"> <FlippingLabel {...args} isActive={active} /> </Button> ); diff --git a/src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx b/src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx index 9493095..2fea0a7 100644 --- a/src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx +++ b/src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx @@ -1,7 +1,7 @@ /* eslint-disable max-statements */ -import { type FC, useState, useCallback } from 'react'; +import type { FC } from 'react'; import { useIntl } from 'react-intl'; -import { useAckee } from '../../../../utils/hooks'; +import { useAckee, useBoolean } from '../../../../utils/hooks'; import { Legend, List, ListItem } from '../../../atoms'; import { Switch, @@ -25,7 +25,11 @@ export type AckeeToggleProps = Omit< export const AckeeToggle: FC<AckeeToggleProps> = ({ direction, ...props }) => { const intl = useIntl(); const [tracking, toggleTracking] = useAckee(); - const [isTooltipOpened, setIsTooltipOpened] = useState(false); + const { + deactivate: closeTooltip, + state: isTooltipOpened, + toggle: toggleTooltip, + } = useBoolean(false); const ackeeLabel = intl.formatMessage({ defaultMessage: 'Tracking:', @@ -64,13 +68,6 @@ export const AckeeToggle: FC<AckeeToggleProps> = ({ direction, ...props }) => { { id: 'ackee-partial' as const, label: partialLabel, value: 'partial' }, ] satisfies [SwitchOption, SwitchOption]; - const closeTooltip = useCallback(() => { - setIsTooltipOpened(false); - }, []); - const toggleTooltip = useCallback(() => { - setIsTooltipOpened((prev) => !prev); - }, []); - return ( <Switch {...props} diff --git a/src/components/organisms/forms/comment-form/comment-form.tsx b/src/components/organisms/forms/comment-form/comment-form.tsx index b5f2d64..9059cbc 100644 --- a/src/components/organisms/forms/comment-form/comment-form.tsx +++ b/src/components/organisms/forms/comment-form/comment-form.tsx @@ -10,6 +10,7 @@ import { useId, } from 'react'; import { useIntl } from 'react-intl'; +import { useBoolean } from '../../../../utils/hooks'; import { Button, Form, @@ -77,15 +78,19 @@ export const CommentForm: FC<CommentFormProps> = ({ }; }, [parentId]); const [data, setData] = useState(emptyForm); - const [isSubmitting, setIsSubmitting] = useState<boolean>(false); + const { + activate: activateNotice, + deactivate: deactivateNotice, + state: isSubmitting, + } = useBoolean(false); /** * Reset all the form fields. */ const resetForm = useCallback(() => { setData(emptyForm); - setIsSubmitting(false); - }, [emptyForm]); + deactivateNotice(); + }, [deactivateNotice, emptyForm]); const nameLabel = intl.formatMessage({ defaultMessage: 'Name:', @@ -160,10 +165,10 @@ export const CommentForm: FC<CommentFormProps> = ({ const sendForm = useCallback( (e: FormEvent) => { e.preventDefault(); - setIsSubmitting(true); - saveComment(data, resetForm).then(() => setIsSubmitting(false)); + activateNotice(); + saveComment(data, resetForm).then(() => deactivateNotice()); }, - [data, resetForm, saveComment] + [activateNotice, data, deactivateNotice, resetForm, saveComment] ); return ( diff --git a/src/components/organisms/forms/contact-form/contact-form.tsx b/src/components/organisms/forms/contact-form/contact-form.tsx index 89fd331..ed23aad 100644 --- a/src/components/organisms/forms/contact-form/contact-form.tsx +++ b/src/components/organisms/forms/contact-form/contact-form.tsx @@ -9,6 +9,7 @@ import { useMemo, } from 'react'; import { useIntl } from 'react-intl'; +import { useBoolean } from '../../../../utils/hooks'; import { Button, Form, Input, Label, Spinner, TextArea } from '../../../atoms'; import { LabelledField } from '../../../molecules'; import styles from './contact-form.module.scss'; @@ -56,15 +57,19 @@ export const ContactForm: FC<ContactFormProps> = ({ }; }, []); const [data, setData] = useState(emptyForm); - const [isSubmitting, setIsSubmitting] = useState<boolean>(false); + const { + activate: activateNotice, + deactivate: deactivateNotice, + state: isSubmitting, + } = useBoolean(false); /** * Reset all the form fields. */ const resetForm = useCallback(() => { setData(emptyForm); - setIsSubmitting(false); - }, [emptyForm]); + deactivateNotice(); + }, [deactivateNotice, emptyForm]); const updateForm = useCallback( (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { @@ -135,10 +140,10 @@ export const ContactForm: FC<ContactFormProps> = ({ const submitHandler = useCallback( async (e: FormEvent) => { e.preventDefault(); - setIsSubmitting(true); - await sendMail(data, resetForm).then(() => setIsSubmitting(false)); + activateNotice(); + await sendMail(data, resetForm).then(() => deactivateNotice()); }, - [data, resetForm, sendMail] + [activateNotice, data, deactivateNotice, resetForm, sendMail] ); return ( diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx index db7cb3a..adbb2cc 100644 --- a/src/components/organisms/layout/comment.tsx +++ b/src/components/organisms/layout/comment.tsx @@ -1,11 +1,11 @@ /* eslint-disable max-statements */ import NextImage from 'next/image'; import Script from 'next/script'; -import { type FC, useCallback, useState } from 'react'; +import type { FC } from 'react'; import { useIntl } from 'react-intl'; import type { Comment as CommentSchema, WithContext } from 'schema-dts'; import type { SingleComment } from '../../../types'; -import { useSettings } from '../../../utils/hooks'; +import { useSettings, useToggle } from '../../../utils/hooks'; import { Button, Link, Time } from '../../atoms'; import { Card, @@ -49,12 +49,7 @@ export const UserComment: FC<UserCommentProps> = ({ }) => { const intl = useIntl(); const { website } = useSettings(); - const [isReplying, setIsReplying] = useState<boolean>(false); - - const handleReply = useCallback( - () => setIsReplying((prevState) => !prevState), - [] - ); + const [isReplying, toggleIsReplying] = useToggle(false); if (!approved) { return ( @@ -170,7 +165,7 @@ export const UserComment: FC<UserCommentProps> = ({ {canReply ? ( <CardFooter> <CardActions> - <Button kind="tertiary" onClick={handleReply}> + <Button kind="tertiary" onClick={toggleIsReplying}> {buttonLabel} </Button> </CardActions> diff --git a/src/components/organisms/toolbar/main-nav.stories.tsx b/src/components/organisms/toolbar/main-nav.stories.tsx index d79addf..31e2b65 100644 --- a/src/components/organisms/toolbar/main-nav.stories.tsx +++ b/src/components/organisms/toolbar/main-nav.stories.tsx @@ -1,5 +1,5 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { useCallback, useState } from 'react'; +import { useToggle } from '../../../utils/hooks'; import { MainNavItem } from './main-nav'; /** @@ -61,11 +61,7 @@ const Template: ComponentStory<typeof MainNavItem> = ({ setIsActive: _setIsActive, ...args }) => { - const [isOpen, setIsOpen] = useState<boolean>(isActive); - - const toggle = useCallback(() => { - setIsOpen((prevState) => !prevState); - }, []); + const [isOpen, toggle] = useToggle(isActive); return <MainNavItem isActive={isOpen} setIsActive={toggle} {...args} />; }; diff --git a/src/components/organisms/toolbar/search.stories.tsx b/src/components/organisms/toolbar/search.stories.tsx index 2c8dd10..0f211bd 100644 --- a/src/components/organisms/toolbar/search.stories.tsx +++ b/src/components/organisms/toolbar/search.stories.tsx @@ -1,5 +1,5 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { useState } from 'react'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useToggle } from '../../../utils/hooks'; import { Search } from './search'; /** @@ -66,17 +66,9 @@ const Template: ComponentStory<typeof Search> = ({ setIsActive: _setIsActive, ...args }) => { - const [isOpen, setIsOpen] = useState<boolean>(isActive); + const [isOpen, toggle] = useToggle(isActive); - return ( - <Search - isActive={isOpen} - setIsActive={() => { - setIsOpen(!isOpen); - }} - {...args} - /> - ); + return <Search isActive={isOpen} setIsActive={toggle} {...args} />; }; /** diff --git a/src/components/organisms/toolbar/settings.stories.tsx b/src/components/organisms/toolbar/settings.stories.tsx index 793c521..c1fe37d 100644 --- a/src/components/organisms/toolbar/settings.stories.tsx +++ b/src/components/organisms/toolbar/settings.stories.tsx @@ -1,5 +1,5 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { useCallback, useState } from 'react'; +import { useToggle } from '../../../utils/hooks'; import { Settings } from './settings'; /** @@ -66,11 +66,7 @@ const Template: ComponentStory<typeof Settings> = ({ setIsActive: _setIsActive, ...args }) => { - const [isOpen, setIsOpen] = useState<boolean>(isActive); - - const toggle = useCallback(() => { - setIsOpen((prevState) => !prevState); - }, []); + const [isOpen, toggle] = useToggle(isActive); return <Settings isActive={isOpen} setIsActive={toggle} {...args} />; }; diff --git a/src/components/organisms/toolbar/toolbar.tsx b/src/components/organisms/toolbar/toolbar.tsx index c400285..c0be464 100644 --- a/src/components/organisms/toolbar/toolbar.tsx +++ b/src/components/organisms/toolbar/toolbar.tsx @@ -1,6 +1,10 @@ /* eslint-disable max-statements */ -import { type FC, useState, useCallback } from 'react'; -import { useOnClickOutside, useRouteChange } from '../../../utils/hooks'; +import type { FC } from 'react'; +import { + useBoolean, + useOnClickOutside, + useRouteChange, +} from '../../../utils/hooks'; import { MainNavItem, type MainNavItemProps } from './main-nav'; import { Search, type SearchProps } from './search'; import { Settings } from './settings'; @@ -27,54 +31,53 @@ export const Toolbar: FC<ToolbarProps> = ({ nav, searchPage, }) => { - const [isNavOpened, setIsNavOpened] = useState<boolean>(false); - const [isSearchOpened, setIsSearchOpened] = useState<boolean>(false); - const [isSettingsOpened, setIsSettingsOpened] = useState<boolean>(false); + const { + deactivate: deactivateMainNav, + state: isMainNavOpen, + toggle: toggleMainNav, + } = useBoolean(false); + const { + deactivate: deactivateSearch, + state: isSearchOpen, + toggle: toggleSearch, + } = useBoolean(false); + const { + deactivate: deactivateSettings, + state: isSettingsOpen, + toggle: toggleSettings, + } = useBoolean(false); const mainNavRef = useOnClickOutside<HTMLDivElement>( - () => isNavOpened && setIsNavOpened(false) + () => isMainNavOpen && deactivateMainNav() ); const searchRef = useOnClickOutside<HTMLDivElement>( - () => isSearchOpened && setIsSearchOpened(false) + () => isSearchOpen && deactivateSearch() ); const settingsRef = useOnClickOutside<HTMLDivElement>( - () => isSettingsOpened && setIsSettingsOpened(false) + () => isSettingsOpen && deactivateSettings() ); - const toggleMainNav = useCallback( - () => setIsNavOpened((prevState) => !prevState), - [] - ); - const toggleSearch = useCallback( - () => setIsSearchOpened((prevState) => !prevState), - [] - ); - const toggleSettings = useCallback( - () => setIsSettingsOpened((prevState) => !prevState), - [] - ); - - useRouteChange(() => setIsSearchOpened(false)); + useRouteChange(deactivateSearch); return ( <div className={`${styles.wrapper} ${className}`}> <MainNavItem className={styles.modal} - isActive={isNavOpened} + isActive={isMainNavOpen} items={nav} ref={mainNavRef} setIsActive={toggleMainNav} /> <Search className={`${styles.modal} ${styles['modal--search']}`} - isActive={isSearchOpened} + isActive={isSearchOpen} ref={searchRef} searchPage={searchPage} setIsActive={toggleSearch} /> <Settings className={`${styles.modal} ${styles['modal--settings']}`} - isActive={isSettingsOpened} + isActive={isSettingsOpen} ref={settingsRef} setIsActive={toggleSettings} /> diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index f1bb31e..b98be04 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -1,5 +1,6 @@ export * from './use-ackee'; export * from './use-article'; +export * from './use-boolean'; export * from './use-breadcrumb'; export * from './use-comments'; export * from './use-data-from-api'; @@ -23,3 +24,4 @@ export * from './use-settings'; export * from './use-state-change'; export * from './use-system-color-scheme'; export * from './use-theme'; +export * from './use-toggle'; diff --git a/src/utils/hooks/use-boolean/index.ts b/src/utils/hooks/use-boolean/index.ts new file mode 100644 index 0000000..a210294 --- /dev/null +++ b/src/utils/hooks/use-boolean/index.ts @@ -0,0 +1 @@ +export * from './use-boolean'; diff --git a/src/utils/hooks/use-boolean/use-boolean.test.ts b/src/utils/hooks/use-boolean/use-boolean.test.ts new file mode 100644 index 0000000..22d3cdc --- /dev/null +++ b/src/utils/hooks/use-boolean/use-boolean.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from '@jest/globals'; +import { act, renderHook } from '@testing-library/react'; +import { useBoolean } from './use-boolean'; + +describe('use-boolean', () => { + it('returns the initial state', () => { + const initialState = true; + const { result } = renderHook(() => useBoolean(initialState)); + + expect(result.current.state).toBe(initialState); + }); + + it('can set the state to false', () => { + const { result } = renderHook(() => useBoolean()); + + act(() => { + result.current.deactivate(); + }); + + expect(result.current.state).toBe(false); + }); + + it('can set the state to true', () => { + const { result } = renderHook(() => useBoolean()); + + act(() => { + result.current.activate(); + }); + + expect(result.current.state).toBe(true); + }); + + it('can switch the state', () => { + const initialState = true; + const { result } = renderHook(() => useBoolean(initialState)); + + expect(result.current.state).toBe(initialState); + + act(() => { + result.current.toggle(); + }); + + expect(result.current.state).toBe(!initialState); + }); +}); diff --git a/src/utils/hooks/use-boolean/use-boolean.ts b/src/utils/hooks/use-boolean/use-boolean.ts new file mode 100644 index 0000000..35cb00c --- /dev/null +++ b/src/utils/hooks/use-boolean/use-boolean.ts @@ -0,0 +1,44 @@ +import { useCallback, useState } from 'react'; + +export type UseBooleanReturn = { + /** + * Set state as true. + */ + activate: () => void; + /** + * Set state as false. + */ + deactivate: () => void; + /** + * Current state. + */ + state: boolean; + /** + * Switch state. + */ + toggle: () => void; +}; + +/** + * React hook to deal with boolean states. + * + * @param {boolean} [initialState] - The initial state. + * @returns {UseBooleanReturn} The state and utility functions to update it. + */ +export const useBoolean = (initialState = false): UseBooleanReturn => { + const [state, setState] = useState(initialState); + + const activate = useCallback(() => { + setState(true); + }, []); + + const deactivate = useCallback(() => { + setState(false); + }, []); + + const toggle = useCallback(() => { + setState((prevState) => !prevState); + }, []); + + return { activate, deactivate, state, toggle }; +}; diff --git a/src/utils/hooks/use-toggle/index.ts b/src/utils/hooks/use-toggle/index.ts new file mode 100644 index 0000000..01a0e57 --- /dev/null +++ b/src/utils/hooks/use-toggle/index.ts @@ -0,0 +1 @@ +export * from './use-toggle'; diff --git a/src/utils/hooks/use-toggle/use-toggle.test.ts b/src/utils/hooks/use-toggle/use-toggle.test.ts new file mode 100644 index 0000000..b2feeab --- /dev/null +++ b/src/utils/hooks/use-toggle/use-toggle.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from '@jest/globals'; +import { act, renderHook } from '@testing-library/react'; +import { useToggle } from './use-toggle'; + +describe('use-toggle', () => { + it('returns the default state', () => { + const { result } = renderHook(() => useToggle()); + + expect(result.current[0]).toBe(false); + }); + + it('can switch the state', () => { + const initialState = true; + const { result } = renderHook(() => useToggle(initialState)); + + expect(result.current[0]).toBe(initialState); + + act(() => { + result.current[1](); + }); + + expect(result.current[0]).toBe(!initialState); + }); +}); diff --git a/src/utils/hooks/use-toggle/use-toggle.ts b/src/utils/hooks/use-toggle/use-toggle.ts new file mode 100644 index 0000000..c07c1e2 --- /dev/null +++ b/src/utils/hooks/use-toggle/use-toggle.ts @@ -0,0 +1,15 @@ +import { useBoolean } from '../use-boolean'; + +export type UseToggleReturn = readonly [boolean, () => void]; + +/** + * React hook to toggle boolean states. + * + * @param {boolean} [initialState] - The initial state. + * @returns {UseToggleReturn} The state and a function to switch state. + */ +export const useToggle = (initialState = false): UseToggleReturn => { + const { state, toggle } = useBoolean(initialState); + + return [state, toggle] as const; +}; |
