diff options
| -rw-r--r-- | src/components/organisms/forms/search-form/search-form.test.tsx | 17 | ||||
| -rw-r--r-- | src/components/organisms/forms/search-form/search-form.tsx | 32 | ||||
| -rw-r--r-- | src/components/organisms/navbar/index.ts | 1 | ||||
| -rw-r--r-- | src/components/organisms/navbar/navbar-item/navbar-item.stories.tsx | 23 | ||||
| -rw-r--r-- | src/components/organisms/navbar/navbar-item/navbar-item.test.tsx | 63 | ||||
| -rw-r--r-- | src/components/organisms/navbar/navbar-item/navbar-item.tsx | 41 | ||||
| -rw-r--r-- | src/components/organisms/navbar/navbar.module.scss | 2 | ||||
| -rw-r--r-- | src/components/organisms/navbar/navbar.stories.tsx | 117 | ||||
| -rw-r--r-- | src/components/organisms/navbar/navbar.test.tsx | 47 | ||||
| -rw-r--r-- | src/components/organisms/navbar/navbar.tsx | 31 | ||||
| -rw-r--r-- | src/components/templates/layout/layout.tsx | 125 | ||||
| -rw-r--r-- | src/utils/hooks/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/hooks/use-autofocus/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/hooks/use-autofocus/use-autofocus.test.ts | 79 | ||||
| -rw-r--r-- | src/utils/hooks/use-autofocus/use-autofocus.ts | 40 |
15 files changed, 216 insertions, 404 deletions
diff --git a/src/components/organisms/forms/search-form/search-form.test.tsx b/src/components/organisms/forms/search-form/search-form.test.tsx index d1fdfa9..8b4379b 100644 --- a/src/components/organisms/forms/search-form/search-form.test.tsx +++ b/src/components/organisms/forms/search-form/search-form.test.tsx @@ -1,7 +1,8 @@ import { describe, expect, it, jest } from '@jest/globals'; import { userEvent } from '@testing-library/user-event'; -import { render, screen as rtlScreen } from '../../../../../tests/utils'; -import { SearchForm } from './search-form'; +import type { Ref } from 'react'; +import { act, render, screen as rtlScreen } from '../../../../../tests/utils'; +import { SearchForm, type SearchFormRef } from './search-form'; describe('SearchForm', () => { it('renders a search input with a submit button', () => { @@ -36,4 +37,16 @@ describe('SearchForm', () => { expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledWith({ query }); }); + + it('can give focus to the search input', () => { + const ref: Ref<SearchFormRef> = { current: null }; + + render(<SearchForm ref={ref} />); + + act(() => { + ref.current?.focus(); + }); + + expect(rtlScreen.getByRole('searchbox')).toHaveFocus(); + }); }); diff --git a/src/components/organisms/forms/search-form/search-form.tsx b/src/components/organisms/forms/search-form/search-form.tsx index 3f16ad0..3d0efa2 100644 --- a/src/components/organisms/forms/search-form/search-form.tsx +++ b/src/components/organisms/forms/search-form/search-form.tsx @@ -1,4 +1,10 @@ -import { forwardRef, type ForwardRefRenderFunction, useId } from 'react'; +import { + forwardRef, + type ForwardRefRenderFunction, + useId, + useImperativeHandle, + useRef, +} from 'react'; import { useIntl } from 'react-intl'; import { type FormSubmitHandler, useForm } from '../../../../utils/hooks'; import { @@ -29,8 +35,15 @@ export type SearchFormProps = Omit<FormProps, 'children' | 'onSubmit'> & { onSubmit?: SearchFormSubmit; }; +export type SearchFormRef = { + /** + * A method to focus the search input. + */ + focus: () => void; +}; + const SearchFormWithRef: ForwardRefRenderFunction< - HTMLInputElement, + SearchFormRef, SearchFormProps > = ({ className = '', isLabelHidden = false, onSubmit, ...props }, ref) => { const intl = useIntl(); @@ -39,6 +52,7 @@ const SearchFormWithRef: ForwardRefRenderFunction< submitHandler: onSubmit, }); const id = useId(); + const inputRef = useRef<HTMLInputElement>(null); const formClass = [ styles.wrapper, styles[isLabelHidden ? 'wrapper--no-label' : 'wrapper--has-label'], @@ -57,6 +71,18 @@ const SearchFormWithRef: ForwardRefRenderFunction< }), }; + useImperativeHandle( + ref, + () => { + return { + focus() { + inputRef.current?.focus(); + }, + }; + }, + [] + ); + return ( <Form {...props} className={formClass} onSubmit={submit}> <LabelledField @@ -68,7 +94,7 @@ const SearchFormWithRef: ForwardRefRenderFunction< // eslint-disable-next-line react/jsx-no-literals name="query" onChange={update} - ref={ref} + ref={inputRef} // eslint-disable-next-line react/jsx-no-literals type="search" value={values.query} diff --git a/src/components/organisms/navbar/index.ts b/src/components/organisms/navbar/index.ts index f5899d0..afa9cb6 100644 --- a/src/components/organisms/navbar/index.ts +++ b/src/components/organisms/navbar/index.ts @@ -1 +1,2 @@ export * from './navbar'; +export * from './navbar-item'; diff --git a/src/components/organisms/navbar/navbar-item/navbar-item.stories.tsx b/src/components/organisms/navbar/navbar-item/navbar-item.stories.tsx index 1c56768..93b7281 100644 --- a/src/components/organisms/navbar/navbar-item/navbar-item.stories.tsx +++ b/src/components/organisms/navbar/navbar-item/navbar-item.stories.tsx @@ -1,5 +1,4 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { useBoolean } from '../../../../utils/hooks'; import { NavbarItem } from './navbar-item'; /** @@ -11,23 +10,9 @@ export default { argTypes: {}, } as ComponentMeta<typeof NavbarItem>; -const Template: ComponentStory<typeof NavbarItem> = ({ - isActive, - onDeactivate, - onToggle, - ...args -}) => { - const { deactivate, state, toggle } = useBoolean(isActive); - - return ( - <NavbarItem - {...args} - isActive={state} - onDeactivate={deactivate} - onToggle={toggle} - /> - ); -}; +const Template: ComponentStory<typeof NavbarItem> = (args) => ( + <NavbarItem {...args} /> +); /** * NavbarItem Stories - Default @@ -37,7 +22,6 @@ Default.args = { children: 'The modal contents.', icon: 'cog', id: 'default', - isActive: false, label: 'Open example', }; @@ -49,7 +33,6 @@ ModalVisibleAfterBreakpoint.args = { children: 'The modal contents.', icon: 'cog', id: 'modal-visible', - isActive: false, label: 'Open example', modalVisibleFrom: 'md', }; diff --git a/src/components/organisms/navbar/navbar-item/navbar-item.test.tsx b/src/components/organisms/navbar/navbar-item/navbar-item.test.tsx index 2e7edea..e531ff6 100644 --- a/src/components/organisms/navbar/navbar-item/navbar-item.test.tsx +++ b/src/components/organisms/navbar/navbar-item/navbar-item.test.tsx @@ -1,24 +1,7 @@ -import { describe, expect, it } from '@jest/globals'; +import { describe, expect, it, jest } from '@jest/globals'; import { render, screen as rtlScreen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; -import { useBoolean } from '../../../../utils/hooks'; -import { NavbarItem, type NavbarItemProps } from './navbar-item'; - -const ControlledNavbarItem = ({ - isActive, - ...props -}: Omit<NavbarItemProps, 'onDeactivate' | 'onToggle'>) => { - const { deactivate, state, toggle } = useBoolean(isActive); - - return ( - <NavbarItem - {...props} - isActive={state} - onDeactivate={deactivate} - onToggle={toggle} - /> - ); -}; +import { NavbarItem } from './navbar-item'; describe('NavbarItem', () => { it('renders a labelled checkbox to open/close a modal', async () => { @@ -27,14 +10,9 @@ describe('NavbarItem', () => { const user = userEvent.setup(); render( - <ControlledNavbarItem - icon="arrow" - id="vel" - isActive={false} - label={label} - > + <NavbarItem icon="arrow" id="vel" label={label}> {modal} - </ControlledNavbarItem> + </NavbarItem> ); // eslint-disable-next-line @typescript-eslint/no-magic-numbers @@ -58,9 +36,9 @@ describe('NavbarItem', () => { const user = userEvent.setup(); render( - <ControlledNavbarItem icon="arrow" id="et" isActive label={label}> + <NavbarItem icon="arrow" id="et" label={label}> {modal} - </ControlledNavbarItem> + </NavbarItem> ); // eslint-disable-next-line @typescript-eslint/no-magic-numbers @@ -68,6 +46,8 @@ describe('NavbarItem', () => { const controller = rtlScreen.getByRole('checkbox', { name: label }); + await user.click(controller); + expect(controller).toBeChecked(); if (controller.parentElement) await user.click(controller.parentElement); @@ -76,4 +56,31 @@ describe('NavbarItem', () => { // Since the visibility is declared in CSS we cannot use this assertion. //expect(rtlScreen.getByText(modal)).not.toBeVisible(); }); + + /* eslint-disable max-statements */ + it('accepts an activation handler', async () => { + const handler = jest.fn(); + const user = userEvent.setup(); + const label = 'qui'; + + render( + <NavbarItem icon="arrow" id="aut" label={label} onActivation={handler}> + Some contents + </NavbarItem> + ); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(3); + + expect(handler).not.toHaveBeenCalled(); + + await user.click(rtlScreen.getByLabelText(label)); + + /* For some reasons (probably setTimeout) it is called twice but if I use + jest fake timers the test throws `Exceeded timeout`... So I leave it with 2 + for now. */ + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenCalledWith(true); + }); + /* eslint-enable max-statements */ }); diff --git a/src/components/organisms/navbar/navbar-item/navbar-item.tsx b/src/components/organisms/navbar/navbar-item/navbar-item.tsx index 8ef6ce3..993b613 100644 --- a/src/components/organisms/navbar/navbar-item/navbar-item.tsx +++ b/src/components/organisms/navbar/navbar-item/navbar-item.tsx @@ -6,8 +6,11 @@ import { useRef, } from 'react'; import { + useBoolean, useOnClickOutside, + useOnRouteChange, type useOnClickOutsideHandler, + useTimeout, } from '../../../../utils/hooks'; import { Checkbox, @@ -24,11 +27,17 @@ import { import { Modal } from '../../../molecules'; import styles from './navbar-item.module.scss'; +export type NavbarItemActivationHandler = (isActive: boolean) => void; + export type NavbarItemProps = Omit< ListItemProps, 'children' | 'hideMarker' | 'id' > & { /** + * Add a delay (in ms) before triggering the `onActivation` handler. + */ + activationHandlerDelay?: number; + /** * The modal contents. */ children: ReactNode; @@ -41,10 +50,6 @@ export type NavbarItemProps = Omit< */ id: string; /** - * Should the modal be visible? - */ - isActive: boolean; - /** * An accessible name for the nav item. */ label: string; @@ -57,13 +62,9 @@ export type NavbarItemProps = Omit< */ modalVisibleFrom?: 'sm' | 'md'; /** - * A callback function to handle modal deactivation. + * A callback function to handle item activation. */ - onDeactivate?: () => void; - /** - * A callback function to handle modal toggle. - */ - onToggle: () => void; + onActivation?: NavbarItemActivationHandler; /** * Should we add the icon on the modal? * @@ -77,16 +78,15 @@ const NavbarItemWithRef: ForwardRefRenderFunction< NavbarItemProps > = ( { + activationHandlerDelay, children, className = '', icon, id, - isActive, label, modalHeading, modalVisibleFrom, - onDeactivate, - onToggle, + onActivation, showIconOnModal = false, ...props }, @@ -99,6 +99,7 @@ const NavbarItemWithRef: ForwardRefRenderFunction< : '', className, ].join(' '); + const { deactivate, state: isActive, toggle } = useBoolean(false); const labelRef = useRef<HTMLLabelElement>(null); const checkboxRef = useRef<HTMLInputElement>(null); const deactivateItem: useOnClickOutsideHandler = useCallback( @@ -107,12 +108,20 @@ const NavbarItemWithRef: ForwardRefRenderFunction< e.target && checkboxRef.current?.contains(e.target as Node); const isLabel = e.target && labelRef.current?.contains(e.target as Node); - if (onDeactivate && !isCheckbox && !isLabel) onDeactivate(); + if (!isCheckbox && !isLabel) deactivate(); }, - [onDeactivate] + [deactivate] ); const modalRef = useOnClickOutside<HTMLDivElement>(deactivateItem); + useOnRouteChange(deactivate, 'end'); + + const handleActivation = useCallback(() => { + if (onActivation) onActivation(isActive); + }, [isActive, onActivation]); + + useTimeout(handleActivation, activationHandlerDelay); + return ( <ListItem {...props} className={itemClass} hideMarker ref={ref}> <Checkbox @@ -120,7 +129,7 @@ const NavbarItemWithRef: ForwardRefRenderFunction< id={id} isChecked={isActive} name={id} - onChange={onToggle} + onChange={toggle} ref={checkboxRef} value={id} /> diff --git a/src/components/organisms/navbar/navbar.module.scss b/src/components/organisms/navbar/navbar.module.scss index 4041825..5af884e 100644 --- a/src/components/organisms/navbar/navbar.module.scss +++ b/src/components/organisms/navbar/navbar.module.scss @@ -47,7 +47,7 @@ } } -.item { +:where(.wrapper) > * { display: flex; justify-content: flex-end; } diff --git a/src/components/organisms/navbar/navbar.stories.tsx b/src/components/organisms/navbar/navbar.stories.tsx index fef995e..95b71ef 100644 --- a/src/components/organisms/navbar/navbar.stories.tsx +++ b/src/components/organisms/navbar/navbar.stories.tsx @@ -1,5 +1,6 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react'; import { Navbar as NavbarComponent } from './navbar'; +import { NavbarItem } from './navbar-item'; /** * Navbar - Storybook Meta @@ -7,28 +8,15 @@ import { Navbar as NavbarComponent } from './navbar'; export default { title: 'Organisms/Navbar', component: NavbarComponent, - args: { - searchPage: '#', - }, argTypes: { - nav: { - description: 'The main nav items.', + children: { + description: 'The navbar items.', type: { name: 'object', required: true, value: {}, }, }, - searchPage: { - control: { - type: 'text', - }, - description: 'The search results page url.', - type: { - name: 'string', - required: true, - }, - }, }, parameters: { layout: 'fullscreen', @@ -39,72 +27,51 @@ const Template: ComponentStory<typeof NavbarComponent> = (args) => ( <NavbarComponent {...args} /> ); -const doNothing = () => { - // do nothing; +/** + * Navbar Stories - 1 item + */ +export const OneItem = Template.bind({}); +OneItem.args = { + children: ( + <NavbarItem icon="hamburger" id="main-nav" label="Nav"> + The main nav contents + </NavbarItem> + ), }; /** - * Navbar Stories - With all items inactive + * Navbar Stories - 2 items */ -export const NavbarInactiveItems = Template.bind({}); -NavbarInactiveItems.args = { - items: [ - { - icon: 'hamburger', - id: 'main-nav', - isActive: false, - label: 'Nav', - contents: 'Main nav contents', - onToggle: doNothing, - }, - { - icon: 'magnifying-glass', - id: 'search', - isActive: false, - label: 'Search', - contents: 'Search contents', - onToggle: doNothing, - }, - { - icon: 'cog', - id: 'settings', - isActive: false, - label: 'Settings', - contents: 'Settings contents', - onToggle: doNothing, - }, - ], +export const TwoItems = Template.bind({}); +TwoItems.args = { + children: ( + <> + <NavbarItem icon="hamburger" id="main-nav" label="Nav"> + The main nav contents + </NavbarItem> + <NavbarItem icon="magnifying-glass" id="search" label="Search"> + A search form + </NavbarItem> + </> + ), }; /** - * Navbar Stories - With one item active + * Navbar Stories - 3 items */ -export const NavbarActiveItem = Template.bind({}); -NavbarActiveItem.args = { - items: [ - { - icon: 'hamburger', - id: 'main-nav', - isActive: true, - label: 'Nav', - contents: 'Main nav contents', - onToggle: doNothing, - }, - { - icon: 'magnifying-glass', - id: 'search', - isActive: false, - label: 'Search', - contents: 'Search contents', - onToggle: doNothing, - }, - { - icon: 'cog', - id: 'settings', - isActive: false, - label: 'Settings', - contents: 'Settings contents', - onToggle: doNothing, - }, - ], +export const ThreeItems = Template.bind({}); +ThreeItems.args = { + children: ( + <> + <NavbarItem icon="hamburger" id="main-nav" label="Nav"> + The main nav contents + </NavbarItem> + <NavbarItem icon="magnifying-glass" id="search" label="Search"> + A search form + </NavbarItem> + <NavbarItem icon="cog" id="settings" label="Settings"> + A settings form + </NavbarItem> + </> + ), }; diff --git a/src/components/organisms/navbar/navbar.test.tsx b/src/components/organisms/navbar/navbar.test.tsx index 35b33f2..6578672 100644 --- a/src/components/organisms/navbar/navbar.test.tsx +++ b/src/components/organisms/navbar/navbar.test.tsx @@ -1,42 +1,21 @@ import { describe, expect, it } from '@jest/globals'; import { render, screen as rtlScreen } from '@testing-library/react'; -import { Navbar, type NavbarItems } from './navbar'; - -const doNothing = () => { - // do nothing; -}; - -const items: NavbarItems = [ - { - icon: 'hamburger', - id: 'main-nav', - isActive: false, - label: 'Nav', - contents: 'Main nav contents', - onToggle: doNothing, - }, - { - icon: 'magnifying-glass', - id: 'search', - isActive: false, - label: 'Search', - contents: 'Search contents', - onToggle: doNothing, - }, - { - icon: 'cog', - id: 'settings', - isActive: false, - label: 'Settings', - contents: 'Settings contents', - onToggle: doNothing, - }, -]; +import { Navbar } from './navbar'; +import { NavbarItem } from './navbar-item'; describe('Navbar', () => { it('renders the given items', () => { - render(<Navbar items={items} />); + render( + <Navbar> + <NavbarItem icon="hamburger" id="main-nav" label="Main nav"> + Main nav + </NavbarItem> + <NavbarItem icon="magnifying-glass" id="search" label="Search"> + Search form + </NavbarItem> + </Navbar> + ); - expect(rtlScreen.getAllByRole('listitem')).toHaveLength(items.length); + expect(rtlScreen.getAllByRole('listitem')).toHaveLength(2); }); }); diff --git a/src/components/organisms/navbar/navbar.tsx b/src/components/organisms/navbar/navbar.tsx index ee379e9..39f3c45 100644 --- a/src/components/organisms/navbar/navbar.tsx +++ b/src/components/organisms/navbar/navbar.tsx @@ -4,26 +4,8 @@ import { type ReactNode, } from 'react'; import { List, type ListProps } from '../../atoms'; -import { NavbarItem, type NavbarItemProps } from './navbar-item'; import styles from './navbar.module.scss'; -export type NavbarItemData = Pick< - NavbarItemProps, - | 'icon' - | 'id' - | 'isActive' - | 'label' - | 'modalHeading' - | 'modalVisibleFrom' - | 'onDeactivate' - | 'onToggle' - | 'showIconOnModal' -> & { - contents: ReactNode; -}; - -export type NavbarItems = [NavbarItemData, NavbarItemData?, NavbarItemData?]; - export type NavbarProps = Omit< ListProps<false, false>, 'children' | 'hideMarker' | 'isHierarchical' | 'isInline' | 'isOrdered' @@ -34,25 +16,18 @@ export type NavbarProps = Omit< * The number of items should not exceed 3 because of the modal position on * small screens. */ - items: NavbarItems; + children: ReactNode; }; const NavbarWithRef: ForwardRefRenderFunction<HTMLUListElement, NavbarProps> = ( - { className = '', items, ...props }, + { children, className = '', ...props }, ref ) => { const wrapperClass = `${styles.wrapper} ${className}`; - const navItems = items.filter( - (item): item is NavbarItemData => item !== undefined - ); return ( <List {...props} className={wrapperClass} hideMarker isInline ref={ref}> - {navItems.map(({ contents, ...item }) => ( - <NavbarItem {...item} className={styles.item} key={item.id}> - {contents} - </NavbarItem> - ))} + {children} </List> ); }; diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx index 953b0db..ce7f1fa 100644 --- a/src/components/templates/layout/layout.tsx +++ b/src/components/templates/layout/layout.tsx @@ -16,12 +16,7 @@ import type { Person, SearchAction, WebSite, WithContext } from 'schema-dts'; import type { NextPageWithLayoutOptions } from '../../../types'; import { CONFIG } from '../../../utils/config'; import { ROUTES } from '../../../utils/constants'; -import { - useAutofocus, - useBoolean, - useOnRouteChange, - useScrollPosition, -} from '../../../utils/hooks'; +import { useOnRouteChange, useScrollPosition } from '../../../utils/hooks'; import { ButtonLink, Footer, @@ -45,8 +40,10 @@ import { MainNav, SearchForm, SettingsForm, - type NavbarItems, type SearchFormSubmit, + NavbarItem, + type SearchFormRef, + type NavbarItemActivationHandler, } from '../../organisms'; import styles from './layout.module.scss'; @@ -174,21 +171,6 @@ export const Layout: FC<LayoutProps> = ({ children, isHome }) => { }, ]; - 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 labels = { mainNavItem: intl.formatMessage({ defaultMessage: 'Open menu', @@ -231,10 +213,13 @@ export const Layout: FC<LayoutProps> = ({ children, isHome }) => { e.preventDefault(); }, []); - const searchInputRef = useAutofocus<HTMLInputElement>({ - condition: () => isSearchOpen, - delay: 360, - }); + const searchFormRef = useRef<SearchFormRef>(null); + const giveFocusToSearchInput: NavbarItemActivationHandler = useCallback( + (isActive) => { + if (isActive) searchFormRef.current?.focus(); + }, + [] + ); const searchSubmitHandler: SearchFormSubmit = useCallback( ({ query }) => { if (!query) @@ -256,55 +241,6 @@ export const Layout: FC<LayoutProps> = ({ children, isHome }) => { [intl, router] ); - useOnRouteChange(deactivateSearch); - - const navbarItems: NavbarItems = [ - { - contents: <MainNav aria-label={labels.mainNavModal} items={mainNav} />, - icon: 'hamburger', - id: 'main-nav', - isActive: isMainNavOpen, - label: labels.mainNavItem, - modalVisibleFrom: 'md', - onDeactivate: deactivateMainNav, - onToggle: toggleMainNav, - }, - { - contents: ( - <SearchForm - className={styles.search} - isLabelHidden - onSubmit={searchSubmitHandler} - ref={searchInputRef} - /> - ), - icon: 'magnifying-glass', - id: 'search', - isActive: isSearchOpen, - label: labels.searchItem, - onDeactivate: deactivateSearch, - onToggle: toggleSearch, - modalHeading: labels.searchModal, - }, - { - contents: ( - <SettingsForm - aria-label={labels.settingsForm} - className={styles.settings} - onSubmit={settingsSubmitHandler} - /> - ), - icon: 'cog', - id: 'settings', - isActive: isSettingsOpen, - label: labels.settingsItem, - onDeactivate: deactivateSettings, - onToggle: toggleSettings, - modalHeading: labels.settingsModal, - showIconOnModal: true, - }, - ]; - const legalNoticeLabel = intl.formatMessage({ defaultMessage: 'Legal notice', description: 'Layout: Legal notice label', @@ -436,7 +372,44 @@ export const Layout: FC<LayoutProps> = ({ children, isHome }) => { } url="/" /> - <Navbar items={navbarItems} /> + <Navbar> + <NavbarItem + icon="hamburger" + id="main-nav" + label={labels.mainNavItem} + modalVisibleFrom="md" + > + <MainNav aria-label={labels.mainNavModal} items={mainNav} /> + </NavbarItem> + <NavbarItem + activationHandlerDelay={350} + icon="magnifying-glass" + id="search" + label={labels.searchItem} + modalHeading={labels.searchModal} + onActivation={giveFocusToSearchInput} + > + <SearchForm + className={styles.search} + isLabelHidden + onSubmit={searchSubmitHandler} + ref={searchFormRef} + /> + </NavbarItem> + <NavbarItem + icon="cog" + id="settings" + label={labels.settingsItem} + modalHeading={labels.settingsModal} + showIconOnModal + > + <SettingsForm + aria-label={labels.settingsForm} + className={styles.settings} + onSubmit={settingsSubmitHandler} + /> + </NavbarItem> + </Navbar> </div> </Header> <Main id="main" className={styles.main}> diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 240a092..f3bfd75 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -1,6 +1,5 @@ export * from './use-ackee'; export * from './use-article'; -export * from './use-autofocus'; export * from './use-boolean'; export * from './use-breadcrumb'; export * from './use-comments'; diff --git a/src/utils/hooks/use-autofocus/index.ts b/src/utils/hooks/use-autofocus/index.ts deleted file mode 100644 index bb23089..0000000 --- a/src/utils/hooks/use-autofocus/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './use-autofocus'; diff --git a/src/utils/hooks/use-autofocus/use-autofocus.test.ts b/src/utils/hooks/use-autofocus/use-autofocus.test.ts deleted file mode 100644 index 1a9a3be..0000000 --- a/src/utils/hooks/use-autofocus/use-autofocus.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { - afterEach, - beforeEach, - describe, - expect, - it, - jest, -} from '@jest/globals'; -import { renderHook, screen as rtlScreen } from '@testing-library/react'; -import { useAutofocus } from './use-autofocus'; - -describe('useAutofocus', () => { - // When less than 1ms, setTimeout use 1. Default delay is 0ms. - const defaultTimeoutDelay = 1; - const input = document.createElement('input'); - input.type = 'text'; - - beforeEach(() => { - document.body.append(input); - jest.useFakeTimers(); - }); - - afterEach(() => { - document.body.removeChild(input); - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); - - it('gives focus to the element without condition', () => { - const { result } = renderHook(() => useAutofocus<HTMLInputElement>()); - result.current.current = input; - - jest.advanceTimersByTime(defaultTimeoutDelay); - - expect(rtlScreen.getByRole('textbox')).toHaveFocus(); - }); - - it('can give focus to the element with custom delay', () => { - const delay = 2000; - const { result } = renderHook(() => - useAutofocus<HTMLInputElement>({ delay }) - ); - result.current.current = input; - - jest.advanceTimersByTime(defaultTimeoutDelay); - - expect(rtlScreen.getByRole('textbox')).not.toHaveFocus(); - - jest.advanceTimersByTime(delay); - - expect(rtlScreen.getByRole('textbox')).toHaveFocus(); - }); - - it('can give focus to the element when the condition is met', () => { - const condition = jest.fn(() => true); - const { result } = renderHook(() => - useAutofocus<HTMLInputElement>({ condition }) - ); - result.current.current = input; - - jest.advanceTimersByTime(defaultTimeoutDelay); - - expect(rtlScreen.getByRole('textbox')).toHaveFocus(); - expect(condition).toHaveBeenCalledTimes(1); - }); - - it('does not give focus to the element when the condition is not met', () => { - const condition = jest.fn(() => false); - const { result } = renderHook(() => - useAutofocus<HTMLInputElement>({ condition }) - ); - result.current.current = input; - - jest.advanceTimersByTime(defaultTimeoutDelay); - - expect(rtlScreen.getByRole('textbox')).not.toHaveFocus(); - expect(condition).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/utils/hooks/use-autofocus/use-autofocus.ts b/src/utils/hooks/use-autofocus/use-autofocus.ts deleted file mode 100644 index 0d21a59..0000000 --- a/src/utils/hooks/use-autofocus/use-autofocus.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useCallback, useRef, type MutableRefObject } from 'react'; -import { useTimeout } from '../use-timeout'; - -export type UseAutofocusCondition = () => boolean; - -export type UseAutofocusConfig = { - /** - * A condition to met before giving focus to the element. - */ - condition?: UseAutofocusCondition; - /** - * A delay in ms before giving focus to the element. - */ - delay?: number; -}; - -/** - * React hook to give focus to an element automatically. - * - * @param {UseAutofocusConfig} [config] - A configuration object. - * @returns {RefObject<T>} The element reference. - */ -export const useAutofocus = <T extends HTMLElement>( - config?: UseAutofocusConfig -): MutableRefObject<T | null> => { - const { condition, delay } = config ?? {}; - const ref = useRef<T | null>(null); - - const setFocus = useCallback(() => { - const shouldFocus = condition ? condition() : true; - - if (ref.current && shouldFocus) { - ref.current.focus(); - } - }, [condition]); - - useTimeout(setFocus, delay); - - return ref; -}; |
