aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2022-05-18 14:27:11 +0200
committerArmand Philippot <git@armandphilippot.com>2022-05-18 14:27:11 +0200
commitb214baab3e17d92f784b4f782863deafc5558ee4 (patch)
treecdc20c7e77ba6926285917eead8bb088bdc843f8 /src
parent54883bb5c36cf21462a421605a709fdd6f04b150 (diff)
chore: close toolbar modals on click/focus outside
Diffstat (limited to 'src')
-rw-r--r--src/components/molecules/forms/select-with-tooltip.tsx10
-rw-r--r--src/components/molecules/modals/tooltip.tsx16
-rw-r--r--src/components/organisms/forms/settings-form.module.scss11
-rw-r--r--src/components/organisms/forms/settings-form.stories.tsx47
-rw-r--r--src/components/organisms/forms/settings-form.test.tsx39
-rw-r--r--src/components/organisms/forms/settings-form.tsx36
-rw-r--r--src/components/organisms/modals/search-modal.tsx13
-rw-r--r--src/components/organisms/modals/settings-modal.module.scss14
-rw-r--r--src/components/organisms/modals/settings-modal.stories.tsx8
-rw-r--r--src/components/organisms/modals/settings-modal.test.tsx27
-rw-r--r--src/components/organisms/modals/settings-modal.tsx42
-rw-r--r--src/components/organisms/toolbar/main-nav.tsx16
-rw-r--r--src/components/organisms/toolbar/search.stories.tsx1
-rw-r--r--src/components/organisms/toolbar/search.test.tsx5
-rw-r--r--src/components/organisms/toolbar/search.tsx16
-rw-r--r--src/components/organisms/toolbar/settings.stories.tsx8
-rw-r--r--src/components/organisms/toolbar/settings.tsx16
-rw-r--r--src/components/organisms/toolbar/toolbar.tsx18
-rw-r--r--src/utils/hooks/use-click-outside.tsx43
19 files changed, 258 insertions, 128 deletions
diff --git a/src/components/molecules/forms/select-with-tooltip.tsx b/src/components/molecules/forms/select-with-tooltip.tsx
index cf7b041..f576a15 100644
--- a/src/components/molecules/forms/select-with-tooltip.tsx
+++ b/src/components/molecules/forms/select-with-tooltip.tsx
@@ -1,4 +1,5 @@
-import { FC, useState } from 'react';
+import useClickOutside from '@utils/hooks/use-click-outside';
+import { FC, useRef, useState } from 'react';
import HelpButton from '../buttons/help-button';
import Tooltip, { type TooltipProps } from '../modals/tooltip';
import LabelledSelect, { type LabelledSelectProps } from './labelled-select';
@@ -28,11 +29,17 @@ const SelectWithTooltip: FC<SelectWithTooltipProps> = ({
...props
}) => {
const [isTooltipOpened, setIsTooltipOpened] = useState<boolean>(false);
+ const tooltipRef = useRef<HTMLDivElement>(null);
const buttonModifier = isTooltipOpened ? styles['btn--activated'] : '';
const tooltipModifier = isTooltipOpened
? styles['tooltip--visible']
: styles['tooltip--hidden'];
+ useClickOutside(
+ tooltipRef,
+ () => isTooltipOpened && setIsTooltipOpened(false)
+ );
+
return (
<div className={styles.wrapper}>
<LabelledSelect
@@ -50,6 +57,7 @@ const SelectWithTooltip: FC<SelectWithTooltipProps> = ({
content={content}
icon="?"
className={`${styles.tooltip} ${tooltipModifier} ${tooltipClassName}`}
+ ref={tooltipRef}
/>
</div>
);
diff --git a/src/components/molecules/modals/tooltip.tsx b/src/components/molecules/modals/tooltip.tsx
index 80721f3..efb3009 100644
--- a/src/components/molecules/modals/tooltip.tsx
+++ b/src/components/molecules/modals/tooltip.tsx
@@ -1,5 +1,5 @@
import List, { type ListItem } from '@components/atoms/lists/list';
-import { FC, ReactNode } from 'react';
+import { forwardRef, ForwardRefRenderFunction, ReactNode } from 'react';
import styles from './tooltip.module.scss';
export type TooltipProps = {
@@ -26,12 +26,10 @@ export type TooltipProps = {
*
* Render a tooltip modal.
*/
-const Tooltip: FC<TooltipProps> = ({
- className = '',
- content,
- icon,
- title,
-}) => {
+const Tooltip: ForwardRefRenderFunction<HTMLDivElement, TooltipProps> = (
+ { className = '', content, icon, title },
+ ref
+) => {
/**
* Format an array of strings to an array of object with id and value.
*
@@ -45,7 +43,7 @@ const Tooltip: FC<TooltipProps> = ({
};
return (
- <div className={`${styles.wrapper} ${className}`}>
+ <div className={`${styles.wrapper} ${className}`} ref={ref}>
<div className={styles.title}>
<span className={styles.icon}>{icon}</span>
{title}
@@ -59,4 +57,4 @@ const Tooltip: FC<TooltipProps> = ({
);
};
-export default Tooltip;
+export default forwardRef(Tooltip);
diff --git a/src/components/organisms/forms/settings-form.module.scss b/src/components/organisms/forms/settings-form.module.scss
new file mode 100644
index 0000000..a6a2077
--- /dev/null
+++ b/src/components/organisms/forms/settings-form.module.scss
@@ -0,0 +1,11 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.label {
+ margin-right: auto;
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "2xs", "height") {
+ font-size: var(--font-size-sm);
+ }
+ }
+}
diff --git a/src/components/organisms/forms/settings-form.stories.tsx b/src/components/organisms/forms/settings-form.stories.tsx
new file mode 100644
index 0000000..46305e7
--- /dev/null
+++ b/src/components/organisms/forms/settings-form.stories.tsx
@@ -0,0 +1,47 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SettingsForm from './settings-form';
+
+/**
+ * SettingsModal - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Forms',
+ component: SettingsForm,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the modal wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ tooltipClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the tooltip wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof SettingsForm>;
+
+const Template: ComponentStory<typeof SettingsForm> = (args) => (
+ <SettingsForm {...args} />
+);
+
+/**
+ * Form Stories - Settings
+ */
+export const Settings = Template.bind({});
diff --git a/src/components/organisms/forms/settings-form.test.tsx b/src/components/organisms/forms/settings-form.test.tsx
new file mode 100644
index 0000000..beb65ec
--- /dev/null
+++ b/src/components/organisms/forms/settings-form.test.tsx
@@ -0,0 +1,39 @@
+import { render, screen } from '@test-utils';
+import SettingsForm from './settings-form';
+
+describe('SettingsForm', () => {
+ it('renders a form', () => {
+ render(<SettingsForm />);
+ expect(
+ screen.getByRole('form', { name: /^Settings form/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a theme toggle setting', () => {
+ render(<SettingsForm />);
+ expect(
+ screen.getByRole('checkbox', { name: /^Theme:/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a code blocks toggle setting', () => {
+ render(<SettingsForm />);
+ expect(
+ screen.getByRole('checkbox', { name: /^Code blocks:/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a motion setting', () => {
+ render(<SettingsForm />);
+ expect(
+ screen.getByRole('checkbox', { name: /^Animations:/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a Ackee setting', () => {
+ render(<SettingsForm />);
+ expect(
+ screen.getByRole('combobox', { name: /^Tracking:/i })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/settings-form.tsx b/src/components/organisms/forms/settings-form.tsx
new file mode 100644
index 0000000..0a34601
--- /dev/null
+++ b/src/components/organisms/forms/settings-form.tsx
@@ -0,0 +1,36 @@
+import Form from '@components/atoms/forms/form';
+import AckeeSelect, {
+ type AckeeSelectProps,
+} from '@components/molecules/forms/ackee-select';
+import MotionToggle from '@components/molecules/forms/motion-toggle';
+import PrismThemeToggle from '@components/molecules/forms/prism-theme-toggle';
+import ThemeToggle from '@components/molecules/forms/theme-toggle';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './settings-form.module.scss';
+
+export type SettingsFormProps = Pick<AckeeSelectProps, 'tooltipClassName'>;
+
+const SettingsForm: FC<SettingsFormProps> = ({ tooltipClassName }) => {
+ const intl = useIntl();
+ const ariaLabel = intl.formatMessage({
+ defaultMessage: 'Settings form',
+ id: 'gX+YVy',
+ description: 'SettingsForm: an accessible form name',
+ });
+
+ return (
+ <Form aria-label={ariaLabel} onSubmit={() => null}>
+ <ThemeToggle labelClassName={styles.label} value={false} />
+ <PrismThemeToggle labelClassName={styles.label} value={false} />
+ <MotionToggle labelClassName={styles.label} value={false} />
+ <AckeeSelect
+ initialValue="full"
+ labelClassName={styles.label}
+ tooltipClassName={tooltipClassName}
+ />
+ </Form>
+ );
+};
+
+export default SettingsForm;
diff --git a/src/components/organisms/modals/search-modal.tsx b/src/components/organisms/modals/search-modal.tsx
index 866bc25..e92bf1b 100644
--- a/src/components/organisms/modals/search-modal.tsx
+++ b/src/components/organisms/modals/search-modal.tsx
@@ -1,9 +1,18 @@
+import Spinner from '@components/atoms/loaders/spinner';
import Modal, { type ModalProps } from '@components/molecules/modals/modal';
+import dynamic from 'next/dynamic';
import { FC } from 'react';
import { useIntl } from 'react-intl';
-import SearchForm, { SearchFormProps } from '../forms/search-form';
+import { type SearchFormProps } from '../forms/search-form';
import styles from './search-modal.module.scss';
+const DynamicSearchForm = dynamic(
+ () => import('@components/organisms/forms/search-form'),
+ {
+ loading: () => <Spinner />,
+ }
+);
+
export type SearchModalProps = Pick<SearchFormProps, 'searchPage'> & {
/**
* Set additional classnames to modal wrapper.
@@ -26,7 +35,7 @@ const SearchModal: FC<SearchModalProps> = ({ className, searchPage }) => {
return (
<Modal title={modalTitle} className={`${styles.wrapper} ${className}`}>
- <SearchForm hideLabel={true} searchPage={searchPage} />
+ <DynamicSearchForm hideLabel={true} searchPage={searchPage} />
</Modal>
);
};
diff --git a/src/components/organisms/modals/settings-modal.module.scss b/src/components/organisms/modals/settings-modal.module.scss
index ebae3da..a6a2077 100644
--- a/src/components/organisms/modals/settings-modal.module.scss
+++ b/src/components/organisms/modals/settings-modal.module.scss
@@ -1,21 +1,11 @@
@use "@styles/abstracts/mixins" as mix;
-.wrapper {
- .label {
- margin-right: auto;
- }
+.label {
+ margin-right: auto;
@include mix.media("screen") {
@include mix.dimensions(null, "2xs", "height") {
font-size: var(--font-size-sm);
-
- .heading {
- font-size: var(--font-size-lg);
- }
-
- .label {
- font-size: var(--font-size-sm);
- }
}
}
}
diff --git a/src/components/organisms/modals/settings-modal.stories.tsx b/src/components/organisms/modals/settings-modal.stories.tsx
index 0abe004..0fe8c18 100644
--- a/src/components/organisms/modals/settings-modal.stories.tsx
+++ b/src/components/organisms/modals/settings-modal.stories.tsx
@@ -1,5 +1,4 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
import SettingsModal from './settings-modal';
/**
@@ -36,13 +35,6 @@ export default {
},
},
},
- decorators: [
- (Story) => (
- <IntlProvider locale="en">
- <Story />
- </IntlProvider>
- ),
- ],
} as ComponentMeta<typeof SettingsModal>;
const Template: ComponentStory<typeof SettingsModal> = (args) => (
diff --git a/src/components/organisms/modals/settings-modal.test.tsx b/src/components/organisms/modals/settings-modal.test.tsx
index 6291e54..acbf7d1 100644
--- a/src/components/organisms/modals/settings-modal.test.tsx
+++ b/src/components/organisms/modals/settings-modal.test.tsx
@@ -2,31 +2,8 @@ import { render, screen } from '@test-utils';
import SettingsModal from './settings-modal';
describe('SettingsModal', () => {
- it('renders a theme toggle setting', () => {
+ it('renders a fake heading', () => {
render(<SettingsModal />);
- expect(
- screen.getByRole('checkbox', { name: /^Theme:/i })
- ).toBeInTheDocument();
- });
-
- it('renders a code blocks toggle setting', () => {
- render(<SettingsModal />);
- expect(
- screen.getByRole('checkbox', { name: /^Code blocks:/i })
- ).toBeInTheDocument();
- });
-
- it('renders a motion setting', () => {
- render(<SettingsModal />);
- expect(
- screen.getByRole('checkbox', { name: /^Animations:/i })
- ).toBeInTheDocument();
- });
-
- it('renders a Ackee setting', () => {
- render(<SettingsModal />);
- expect(
- screen.getByRole('combobox', { name: /^Tracking:/i })
- ).toBeInTheDocument();
+ expect(screen.getByText(/Settings/i)).toBeInTheDocument();
});
});
diff --git a/src/components/organisms/modals/settings-modal.tsx b/src/components/organisms/modals/settings-modal.tsx
index 20d2605..e724076 100644
--- a/src/components/organisms/modals/settings-modal.tsx
+++ b/src/components/organisms/modals/settings-modal.tsx
@@ -1,25 +1,20 @@
-import Form from '@components/atoms/forms/form';
-import AckeeSelect, {
- type AckeeSelectProps,
-} from '@components/molecules/forms/ackee-select';
-import MotionToggle from '@components/molecules/forms/motion-toggle';
-import PrismThemeToggle from '@components/molecules/forms/prism-theme-toggle';
-import ThemeToggle from '@components/molecules/forms/theme-toggle';
+import Spinner from '@components/atoms/loaders/spinner';
import Modal, { type ModalProps } from '@components/molecules/modals/modal';
+import dynamic from 'next/dynamic';
import { FC } from 'react';
import { useIntl } from 'react-intl';
+import { type SettingsFormProps } from '../forms/settings-form';
import styles from './settings-modal.module.scss';
-export type SettingsModalProps = {
- /**
- * Set additional classnames to the modal wrapper.
- */
- className?: ModalProps['className'];
- /**
- * Set additional classnames to the tooltip wrapper.
- */
- tooltipClassName?: AckeeSelectProps['tooltipClassName'];
-};
+const DynamicSettingsForm = dynamic(
+ () => import('@components/organisms/forms/settings-form'),
+ {
+ loading: () => <Spinner />,
+ }
+);
+
+export type SettingsModalProps = Pick<ModalProps, 'className'> &
+ Pick<SettingsFormProps, 'tooltipClassName'>;
/**
* SettingsModal component
@@ -28,7 +23,7 @@ export type SettingsModalProps = {
*/
const SettingsModal: FC<SettingsModalProps> = ({
className = '',
- tooltipClassName = '',
+ ...props
}) => {
const intl = useIntl();
const title = intl.formatMessage({
@@ -44,16 +39,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
className={`${styles.wrapper} ${className}`}
headingClassName={styles.heading}
>
- <Form onSubmit={() => null}>
- <ThemeToggle labelClassName={styles.label} value={false} />
- <PrismThemeToggle labelClassName={styles.label} value={false} />
- <MotionToggle labelClassName={styles.label} value={false} />
- <AckeeSelect
- initialValue="full"
- labelClassName={styles.label}
- tooltipClassName={tooltipClassName}
- />
- </Form>
+ <DynamicSettingsForm {...props} />
</Modal>
);
};
diff --git a/src/components/organisms/toolbar/main-nav.tsx b/src/components/organisms/toolbar/main-nav.tsx
index 35e3fd6..d205112 100644
--- a/src/components/organisms/toolbar/main-nav.tsx
+++ b/src/components/organisms/toolbar/main-nav.tsx
@@ -5,7 +5,7 @@ import Nav, {
type NavProps,
type NavItem,
} from '@components/molecules/nav/nav';
-import { FC } from 'react';
+import { forwardRef, ForwardRefRenderFunction } from 'react';
import { useIntl } from 'react-intl';
import mainNavStyles from './main-nav.module.scss';
import sharedStyles from './toolbar-items.module.scss';
@@ -34,12 +34,10 @@ export type MainNavProps = {
*
* Render the main navigation.
*/
-const MainNav: FC<MainNavProps> = ({
- className = '',
- isActive,
- items,
- setIsActive,
-}) => {
+const MainNav: ForwardRefRenderFunction<HTMLDivElement, MainNavProps> = (
+ { className = '', isActive, items, setIsActive },
+ ref
+) => {
const intl = useIntl();
const label = isActive
? intl.formatMessage({
@@ -54,7 +52,7 @@ const MainNav: FC<MainNavProps> = ({
});
return (
- <div className={`${sharedStyles.item} ${mainNavStyles.item}`}>
+ <div className={`${sharedStyles.item} ${mainNavStyles.item}`} ref={ref}>
<Checkbox
id="main-nav-button"
name="main-nav-button"
@@ -79,4 +77,4 @@ const MainNav: FC<MainNavProps> = ({
);
};
-export default MainNav;
+export default forwardRef(MainNav);
diff --git a/src/components/organisms/toolbar/search.stories.tsx b/src/components/organisms/toolbar/search.stories.tsx
index c6063a0..6aaffde 100644
--- a/src/components/organisms/toolbar/search.stories.tsx
+++ b/src/components/organisms/toolbar/search.stories.tsx
@@ -1,6 +1,5 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { useState } from 'react';
-import { IntlProvider } from 'react-intl';
import Search from './search';
/**
diff --git a/src/components/organisms/toolbar/search.test.tsx b/src/components/organisms/toolbar/search.test.tsx
index a18b679..7c77eac 100644
--- a/src/components/organisms/toolbar/search.test.tsx
+++ b/src/components/organisms/toolbar/search.test.tsx
@@ -11,9 +11,4 @@ describe('Search', () => {
render(<Search searchPage="#" isActive={true} setIsActive={() => null} />);
expect(screen.getByRole('checkbox')).toHaveAccessibleName('Close search');
});
-
- it('renders a search form', () => {
- render(<Search searchPage="#" isActive={true} setIsActive={() => null} />);
- expect(screen.getByRole('searchbox')).toBeInTheDocument();
- });
});
diff --git a/src/components/organisms/toolbar/search.tsx b/src/components/organisms/toolbar/search.tsx
index a1471ef..5695348 100644
--- a/src/components/organisms/toolbar/search.tsx
+++ b/src/components/organisms/toolbar/search.tsx
@@ -1,7 +1,7 @@
import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
import Label from '@components/atoms/forms/label';
import MagnifyingGlass from '@components/atoms/icons/magnifying-glass';
-import { FC } from 'react';
+import { forwardRef, ForwardRefRenderFunction } from 'react';
import { useIntl } from 'react-intl';
import SearchModal, { type SearchModalProps } from '../modals/search-modal';
import searchStyles from './search.module.scss';
@@ -26,12 +26,10 @@ export type SearchProps = {
setIsActive: CheckboxProps['setValue'];
};
-const Search: FC<SearchProps> = ({
- className = '',
- isActive,
- searchPage,
- setIsActive,
-}) => {
+const Search: ForwardRefRenderFunction<HTMLDivElement, SearchProps> = (
+ { className = '', isActive, searchPage, setIsActive },
+ ref
+) => {
const intl = useIntl();
const label = isActive
? intl.formatMessage({
@@ -46,7 +44,7 @@ const Search: FC<SearchProps> = ({
});
return (
- <div className={`${sharedStyles.item} ${searchStyles.item}`}>
+ <div className={`${sharedStyles.item} ${searchStyles.item}`} ref={ref}>
<Checkbox
id="search-button"
name="search-button"
@@ -69,4 +67,4 @@ const Search: FC<SearchProps> = ({
);
};
-export default Search;
+export default forwardRef(Search);
diff --git a/src/components/organisms/toolbar/settings.stories.tsx b/src/components/organisms/toolbar/settings.stories.tsx
index 1ec0897..aab4b9e 100644
--- a/src/components/organisms/toolbar/settings.stories.tsx
+++ b/src/components/organisms/toolbar/settings.stories.tsx
@@ -1,6 +1,5 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { useState } from 'react';
-import { IntlProvider } from 'react-intl';
import Settings from './settings';
/**
@@ -57,13 +56,6 @@ export default {
},
},
},
- decorators: [
- (Story) => (
- <IntlProvider locale="en">
- <Story />
- </IntlProvider>
- ),
- ],
} as ComponentMeta<typeof Settings>;
const Template: ComponentStory<typeof Settings> = ({
diff --git a/src/components/organisms/toolbar/settings.tsx b/src/components/organisms/toolbar/settings.tsx
index 3b10226..43d3190 100644
--- a/src/components/organisms/toolbar/settings.tsx
+++ b/src/components/organisms/toolbar/settings.tsx
@@ -1,7 +1,7 @@
import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
import Label from '@components/atoms/forms/label';
import Cog from '@components/atoms/icons/cog';
-import { FC } from 'react';
+import { FC, forwardRef, ForwardRefRenderFunction } from 'react';
import { useIntl } from 'react-intl';
import SettingsModal, {
type SettingsModalProps,
@@ -28,12 +28,10 @@ export type SettingsProps = {
tooltipClassName?: SettingsModalProps['tooltipClassName'];
};
-const Settings: FC<SettingsProps> = ({
- className = '',
- isActive,
- setIsActive,
- tooltipClassName = '',
-}) => {
+const Settings: ForwardRefRenderFunction<HTMLDivElement, SettingsProps> = (
+ { className = '', isActive, setIsActive, tooltipClassName = '' },
+ ref
+) => {
const intl = useIntl();
const label = isActive
? intl.formatMessage({
@@ -48,7 +46,7 @@ const Settings: FC<SettingsProps> = ({
});
return (
- <div className={`${sharedStyles.item} ${settingsStyles.item}`}>
+ <div className={`${sharedStyles.item} ${settingsStyles.item}`} ref={ref}>
<Checkbox
id="settings-button"
name="settings-button"
@@ -71,4 +69,4 @@ const Settings: FC<SettingsProps> = ({
);
};
-export default Settings;
+export default forwardRef(Settings);
diff --git a/src/components/organisms/toolbar/toolbar.tsx b/src/components/organisms/toolbar/toolbar.tsx
index 6593055..e4188fe 100644
--- a/src/components/organisms/toolbar/toolbar.tsx
+++ b/src/components/organisms/toolbar/toolbar.tsx
@@ -1,4 +1,5 @@
-import { FC, useState } from 'react';
+import useClickOutside from '@utils/hooks/use-click-outside';
+import { FC, useRef, useState } from 'react';
import MainNav, { type MainNavProps } from '../toolbar/main-nav';
import Search, { type SearchProps } from '../toolbar/search';
import Settings from '../toolbar/settings';
@@ -22,8 +23,18 @@ export type ToolbarProps = Pick<SearchProps, 'searchPage'> & {
*/
const Toolbar: FC<ToolbarProps> = ({ className = '', nav, searchPage }) => {
const [isNavOpened, setIsNavOpened] = useState<boolean>(false);
- const [isSettingsOpened, setIsSettingsOpened] = useState<boolean>(false);
const [isSearchOpened, setIsSearchOpened] = useState<boolean>(false);
+ const [isSettingsOpened, setIsSettingsOpened] = useState<boolean>(false);
+ const mainNavRef = useRef<HTMLDivElement>(null);
+ const searchRef = useRef<HTMLDivElement>(null);
+ const settingsRef = useRef<HTMLDivElement>(null);
+
+ useClickOutside(mainNavRef, () => isNavOpened && setIsNavOpened(false));
+ useClickOutside(searchRef, () => isSearchOpened && setIsSearchOpened(false));
+ useClickOutside(
+ settingsRef,
+ () => isSettingsOpened && setIsSettingsOpened(false)
+ );
return (
<div className={`${styles.wrapper} ${className}`}>
@@ -32,18 +43,21 @@ const Toolbar: FC<ToolbarProps> = ({ className = '', nav, searchPage }) => {
isActive={isNavOpened}
setIsActive={setIsNavOpened}
className={styles.modal}
+ ref={mainNavRef}
/>
<Search
searchPage={searchPage}
isActive={isSearchOpened}
setIsActive={setIsSearchOpened}
className={`${styles.modal} ${styles['modal--search']}`}
+ ref={searchRef}
/>
<Settings
isActive={isSettingsOpened}
setIsActive={setIsSettingsOpened}
className={`${styles.modal} ${styles['modal--settings']}`}
tooltipClassName={styles.tooltip}
+ ref={settingsRef}
/>
</div>
);
diff --git a/src/utils/hooks/use-click-outside.tsx b/src/utils/hooks/use-click-outside.tsx
new file mode 100644
index 0000000..066c1c2
--- /dev/null
+++ b/src/utils/hooks/use-click-outside.tsx
@@ -0,0 +1,43 @@
+import { RefObject, useCallback, useEffect } from 'react';
+
+/**
+ * Listen for click/focus outside an element and execute the given callback.
+ *
+ * @param el - A React reference to an element.
+ * @param callback - A callback function to execute on click outside.
+ */
+const useClickOutside = (el: RefObject<HTMLElement>, callback: () => void) => {
+ /**
+ * Check if an event target is outside an element.
+ *
+ * @param {RefObject<HTMLElement>} ref - A React reference object.
+ * @param {EventTarget} target - An event target.
+ * @returns {boolean} True if the event target is outside the ref object.
+ */
+ const isTargetOutside = (
+ ref: RefObject<HTMLElement>,
+ target: EventTarget
+ ): boolean => {
+ if (!ref.current) return false;
+ return !ref.current.contains(target as Node);
+ };
+
+ const handleEvent = useCallback(
+ (e: MouseEvent | FocusEvent) => {
+ if (e.target && isTargetOutside(el, e.target)) callback();
+ },
+ [el, callback]
+ );
+
+ useEffect(() => {
+ document.addEventListener('mousedown', handleEvent);
+ document.addEventListener('focusin', handleEvent);
+
+ return () => {
+ document.removeEventListener('mousedown', handleEvent);
+ document.removeEventListener('focusin', handleEvent);
+ };
+ }, [handleEvent]);
+};
+
+export default useClickOutside;