diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-11-03 12:22:47 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-11 18:15:27 +0100 |
| commit | 5d3e8a4d0c2ce2ad8f22df857ab3ce54fcfc38ac (patch) | |
| tree | a758333b29e2e6614de609acb312ea9ff0d3a33b /src/components | |
| parent | 655be4404630a20ae4ca40c4af84afcc2e63557b (diff) | |
refactor(components): replace Toolbar with Navbar component
* remove SearchModal and SettingsModal components
* add a generic NavbarItem component (instead of the previous toolbar
items to avoid unreadable styles...)
* move FlippingLabel component logic into NavbarItem since it is only
used here
Diffstat (limited to 'src/components')
50 files changed, 959 insertions, 1516 deletions
diff --git a/src/components/atoms/forms/fields/boolean-field/boolean-field.tsx b/src/components/atoms/forms/fields/boolean-field/boolean-field.tsx index 5476cf5..009635d 100644 --- a/src/components/atoms/forms/fields/boolean-field/boolean-field.tsx +++ b/src/components/atoms/forms/fields/boolean-field/boolean-field.tsx @@ -1,4 +1,8 @@ -import type { FC, InputHTMLAttributes } from 'react'; +import { + forwardRef, + type InputHTMLAttributes, + type ForwardRefRenderFunction, +} from 'react'; import styles from './boolean-field.module.scss'; export type BooleanFieldProps = Omit< @@ -56,20 +60,21 @@ export type BooleanFieldProps = Omit< value: string; }; -/** - * BooleanField component - * - * Render a checkbox or a radio input type. - */ -export const BooleanField: FC<BooleanFieldProps> = ({ - className = '', - isChecked = false, - isDisabled = false, - isHidden = false, - isReadOnly = false, - isRequired = false, - ...props -}) => { +const BooleanFieldWithRef: ForwardRefRenderFunction< + HTMLInputElement, + BooleanFieldProps +> = ( + { + className = '', + isChecked = false, + isDisabled = false, + isHidden = false, + isReadOnly = false, + isRequired = false, + ...props + }, + ref +) => { const visibilityClass = isHidden ? styles['field--hidden'] : ''; const inputClass = `${visibilityClass} ${className}`; @@ -80,7 +85,15 @@ export const BooleanField: FC<BooleanFieldProps> = ({ className={inputClass} disabled={isDisabled} readOnly={isReadOnly} + ref={ref} required={isRequired} /> ); }; + +/** + * BooleanField component + * + * Render a checkbox or a radio input type. + */ +export const BooleanField = forwardRef(BooleanFieldWithRef); diff --git a/src/components/atoms/forms/fields/checkbox/checkbox.tsx b/src/components/atoms/forms/fields/checkbox/checkbox.tsx index 9c175b7..2a8424e 100644 --- a/src/components/atoms/forms/fields/checkbox/checkbox.tsx +++ b/src/components/atoms/forms/fields/checkbox/checkbox.tsx @@ -1,14 +1,19 @@ -import type { FC } from 'react'; +import { forwardRef, type ForwardRefRenderFunction } from 'react'; import { BooleanField, type BooleanFieldProps } from '../boolean-field'; export type CheckboxProps = Omit<BooleanFieldProps, 'type'>; +const CheckboxWithRef: ForwardRefRenderFunction< + HTMLInputElement, + CheckboxProps +> = (props, ref) => ( + // eslint-disable-next-line react/jsx-no-literals -- Type allowed + <BooleanField {...props} ref={ref} type="checkbox" /> +); + /** * Checkbox component * * Render a checkbox input type. */ -export const Checkbox: FC<CheckboxProps> = (props) => ( - // eslint-disable-next-line react/jsx-no-literals -- Type allowed - <BooleanField {...props} type="checkbox" /> -); +export const Checkbox = forwardRef(CheckboxWithRef); diff --git a/src/components/atoms/forms/label/label.tsx b/src/components/atoms/forms/label/label.tsx index 6692205..bfd1a59 100644 --- a/src/components/atoms/forms/label/label.tsx +++ b/src/components/atoms/forms/label/label.tsx @@ -1,4 +1,9 @@ -import type { FC, LabelHTMLAttributes, ReactNode } from 'react'; +import { + forwardRef, + type ForwardRefRenderFunction, + type LabelHTMLAttributes, + type ReactNode, +} from 'react'; import styles from './label.module.scss'; export type LabelSize = 'md' | 'sm'; @@ -31,26 +36,27 @@ export type LabelProps = Omit< size?: LabelSize; }; -/** - * Label Component - * - * Render a HTML label element. - */ -export const Label: FC<LabelProps> = ({ - children, - className = '', - isHidden = false, - isRequired = false, - size = 'sm', - ...props -}) => { - const visibilityClass = isHidden ? 'screen-reader-text' : ''; - const sizeClass = styles[`label--${size}`]; - const labelClass = `${styles.label} ${sizeClass} ${visibilityClass} ${className}`; +const LabelWithRef: ForwardRefRenderFunction<HTMLLabelElement, LabelProps> = ( + { + children, + className = '', + isHidden = false, + isRequired = false, + size = 'sm', + ...props + }, + ref +) => { + const labelClass = [ + styles.label, + styles[`label--${size}`], + isHidden ? 'screen-reader-text' : '', + className, + ].join(' '); const requiredSymbol = ' *'; return ( - <label {...props} className={labelClass}> + <label {...props} className={labelClass} ref={ref}> {children} {isRequired ? ( <span aria-hidden className={styles.required}> @@ -60,3 +66,10 @@ export const Label: FC<LabelProps> = ({ </label> ); }; + +/** + * Label Component + * + * Render a HTML label element. + */ +export const Label = forwardRef(LabelWithRef); diff --git a/src/components/molecules/forms/flipping-label/flipping-label.module.scss b/src/components/molecules/forms/flipping-label/flipping-label.module.scss deleted file mode 100644 index 169bde3..0000000 --- a/src/components/molecules/forms/flipping-label/flipping-label.module.scss +++ /dev/null @@ -1,17 +0,0 @@ -@use "../../../../styles/abstracts/functions" as fun; - -.wrapper { - --size: var(--btn-size, #{fun.convert-px(60)}); - --flipper-speed: 0.5s; - - width: var(--size); - height: var(--size); -} - -.wrapper, -.front, -.back { - display: flex; - place-content: center; - place-items: center; -} diff --git a/src/components/molecules/forms/flipping-label/flipping-label.stories.tsx b/src/components/molecules/forms/flipping-label/flipping-label.stories.tsx deleted file mode 100644 index 906a488..0000000 --- a/src/components/molecules/forms/flipping-label/flipping-label.stories.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { useToggle } from '../../../../utils/hooks'; -import { Button, Icon } from '../../../atoms'; -import { FlippingLabel } from './flipping-label'; - -export default { - title: 'Molecules/Forms/FlippingLabel', - component: FlippingLabel, - argTypes: { - 'aria-label': { - control: { - type: 'text', - }, - description: 'An accessible name for the label.', - table: { - category: 'Accessibility', - }, - type: { - name: 'string', - required: false, - }, - }, - children: { - control: { - type: null, - }, - description: 'An icon for the label front face.', - type: { - name: 'function', - required: true, - }, - }, - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the label.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - htmlFor: { - control: { - type: null, - }, - description: 'Bind the label to a field by id.', - table: { - category: 'Options', - }, - type: { - name: 'string', - required: false, - }, - }, - isActive: { - control: { - type: 'boolean', - }, - description: - 'Which side of the label should be displayed? True for the close icon.', - type: { - name: 'boolean', - required: true, - }, - }, - }, -} as ComponentMeta<typeof FlippingLabel>; - -const Template: ComponentStory<typeof FlippingLabel> = ({ - isActive, - ...args -}) => { - const [active, toggle] = useToggle(isActive); - - return ( - <Button kind="neutral" onClick={toggle} shape="initial" type="button"> - <FlippingLabel {...args} isActive={active} /> - </Button> - ); -}; - -export const Active = Template.bind({}); -Active.args = { - icon: <Icon shape="magnifying-glass" />, - isActive: true, - label: 'Close the search', -}; - -export const Inactive = Template.bind({}); -Inactive.args = { - icon: <Icon shape="magnifying-glass" />, - isActive: false, - label: 'Open the search', -}; diff --git a/src/components/molecules/forms/flipping-label/flipping-label.test.tsx b/src/components/molecules/forms/flipping-label/flipping-label.test.tsx deleted file mode 100644 index d59c5f3..0000000 --- a/src/components/molecules/forms/flipping-label/flipping-label.test.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '@testing-library/react'; -import { Icon } from '../../../atoms'; -import { FlippingLabel } from './flipping-label'; - -describe('FlippingLabel', () => { - it('renders a label', () => { - const label = 'vero quo inventore'; - render( - <FlippingLabel - icon={<Icon shape="arrow" />} - isActive={false} - label={label} - /> - ); - expect(rtlScreen.getByText(label)).toBeInTheDocument(); - }); -}); diff --git a/src/components/molecules/forms/flipping-label/flipping-label.tsx b/src/components/molecules/forms/flipping-label/flipping-label.tsx deleted file mode 100644 index 586301f..0000000 --- a/src/components/molecules/forms/flipping-label/flipping-label.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { FC, ReactNode } from 'react'; -import { - Icon, - Label, - VisuallyHidden, - type LabelProps, - Flip, - FlipSide, -} from '../../../atoms'; -import styles from './flipping-label.module.scss'; - -export type FlippingLabelProps = Omit< - LabelProps, - 'children' | 'isHidden' | 'isRequired' -> & { - /** - * The front icon. - */ - icon: ReactNode; - /** - * Which side of the label should be displayed? True for the close icon. - */ - isActive: boolean; - /** - * An accessible name for the label. - */ - label: string; -}; - -export const FlippingLabel: FC<FlippingLabelProps> = ({ - className = '', - icon, - isActive, - label, - ...props -}) => { - const wrapperClass = `${styles.wrapper} ${className}`; - - return ( - <Label {...props} className={wrapperClass}> - <VisuallyHidden>{label}</VisuallyHidden> - <Flip - aria-hidden - // eslint-disable-next-line react/jsx-no-literals -- Shape allowed - showBack={isActive} - > - <FlipSide className={styles.front}>{icon}</FlipSide> - <FlipSide className={styles.back} isBack> - <Icon aria-hidden shape="cross" /> - </FlipSide> - </Flip> - </Label> - ); -}; diff --git a/src/components/molecules/forms/flipping-label/index.ts b/src/components/molecules/forms/flipping-label/index.ts deleted file mode 100644 index 7b50c75..0000000 --- a/src/components/molecules/forms/flipping-label/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './flipping-label'; diff --git a/src/components/molecules/forms/index.ts b/src/components/molecules/forms/index.ts index 883a033..073f97d 100644 --- a/src/components/molecules/forms/index.ts +++ b/src/components/molecules/forms/index.ts @@ -1,4 +1,3 @@ -export * from './flipping-label'; export * from './labelled-field'; export * from './radio-group'; export * from './switch'; diff --git a/src/components/organisms/forms/search-form/search-form.tsx b/src/components/organisms/forms/search-form/search-form.tsx index 1dcbb8c..5c685c0 100644 --- a/src/components/organisms/forms/search-form/search-form.tsx +++ b/src/components/organisms/forms/search-form/search-form.tsx @@ -14,6 +14,7 @@ import { LabelledField } from '../../../molecules'; import styles from './search-form.module.scss'; export type SearchFormProps = { + className?: string; /** * Should the label be visually hidden? * @@ -29,7 +30,7 @@ export type SearchFormProps = { const SearchFormWithRef: ForwardRefRenderFunction< HTMLInputElement, SearchFormProps -> = ({ isLabelHidden = false, searchPage }, ref) => { +> = ({ className = '', isLabelHidden = false, searchPage }, ref) => { const intl = useIntl(); const fieldLabel = intl.formatMessage({ defaultMessage: 'Search for:', @@ -59,9 +60,10 @@ const SearchFormWithRef: ForwardRefRenderFunction< }, []); const id = useId(); + const formClass = `${styles.wrapper} ${className}`; return ( - <Form className={styles.wrapper} onSubmit={submitHandler}> + <Form className={formClass} onSubmit={submitHandler}> <LabelledField className={styles.field} field={ diff --git a/src/components/organisms/index.ts b/src/components/organisms/index.ts index 5e659b5..092b78e 100644 --- a/src/components/organisms/index.ts +++ b/src/components/organisms/index.ts @@ -1,6 +1,5 @@ export * from './forms'; export * from './layout'; -export * from './modals'; export * from './nav'; -export * from './toolbar'; +export * from './navbar'; export * from './widgets'; diff --git a/src/components/organisms/modals/index.ts b/src/components/organisms/modals/index.ts deleted file mode 100644 index 9385fb2..0000000 --- a/src/components/organisms/modals/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './search-modal'; -export * from './settings-modal'; diff --git a/src/components/organisms/modals/search-modal.module.scss b/src/components/organisms/modals/search-modal.module.scss deleted file mode 100644 index 449aa91..0000000 --- a/src/components/organisms/modals/search-modal.module.scss +++ /dev/null @@ -1,11 +0,0 @@ -@use "../../../styles/abstracts/mixins" as mix; - -.wrapper { - padding-bottom: var(--spacing-md); - - @include mix.media("screen") { - @include mix.dimensions("sm") { - max-width: 40ch; - } - } -} diff --git a/src/components/organisms/modals/search-modal.stories.tsx b/src/components/organisms/modals/search-modal.stories.tsx deleted file mode 100644 index a9cf064..0000000 --- a/src/components/organisms/modals/search-modal.stories.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { SearchModal } from './search-modal'; - -/** - * SearchModal - Storybook Meta - */ -export default { - title: 'Organisms/Modals', - component: SearchModal, - args: { - searchPage: '#', - }, - argTypes: { - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the search modal wrapper.', - table: { - category: 'Options', - }, - type: { - name: 'string', - required: false, - }, - }, - searchPage: { - control: { - type: 'text', - }, - description: 'The search results page url.', - type: { - name: 'string', - required: true, - }, - }, - }, -} as ComponentMeta<typeof SearchModal>; - -const Template: ComponentStory<typeof SearchModal> = (args) => ( - <SearchModal {...args} /> -); - -/** - * Modals Stories - Search - */ -export const Search = Template.bind({}); diff --git a/src/components/organisms/modals/search-modal.test.tsx b/src/components/organisms/modals/search-modal.test.tsx deleted file mode 100644 index a9e1ece..0000000 --- a/src/components/organisms/modals/search-modal.test.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; -import { SearchModal } from './search-modal'; - -describe('SearchModal', () => { - it('renders a search modal', () => { - render(<SearchModal searchPage="#" />); - expect(screen.getByText('Search')).toBeInTheDocument(); - }); -}); diff --git a/src/components/organisms/modals/search-modal.tsx b/src/components/organisms/modals/search-modal.tsx deleted file mode 100644 index be9d489..0000000 --- a/src/components/organisms/modals/search-modal.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { forwardRef, type ForwardRefRenderFunction } from 'react'; -import { useIntl } from 'react-intl'; -import { Heading } from '../../atoms'; -import { Modal, type ModalProps } from '../../molecules'; -import { SearchForm, type SearchFormProps } from '../forms'; -import styles from './search-modal.module.scss'; - -export type SearchModalProps = SearchFormProps & { - /** - * Set additional classnames to modal wrapper. - */ - className?: ModalProps['className']; -}; - -const SearchModalWithRef: ForwardRefRenderFunction< - HTMLInputElement, - SearchModalProps -> = ({ className, searchPage }, ref) => { - const intl = useIntl(); - const modalTitle = intl.formatMessage({ - defaultMessage: 'Search', - description: 'SearchModal: modal title', - id: 'G+Twgm', - }); - - return ( - <Modal - className={`${styles.wrapper} ${className}`} - heading={ - <Heading isFake level={3}> - {modalTitle} - </Heading> - } - > - <SearchForm isLabelHidden ref={ref} searchPage={searchPage} /> - </Modal> - ); -}; - -/** - * SearchModal - * - * Render a search form modal. - */ -export const SearchModal = forwardRef(SearchModalWithRef); diff --git a/src/components/organisms/modals/settings-modal.module.scss b/src/components/organisms/modals/settings-modal.module.scss deleted file mode 100644 index 68bce98..0000000 --- a/src/components/organisms/modals/settings-modal.module.scss +++ /dev/null @@ -1,19 +0,0 @@ -@use "../../../styles/abstracts/functions" as fun; -@use "../../../styles/abstracts/variables" as var; - -.wrapper { - width: 100%; - - @media screen and (max-height: #{var.get-breakpoint("2xs")}) and (max-width: #{var.get-breakpoint("sm")}) { - --first-col-width: #{fun.convert-px(140)}; - --col-gap: var(--spacing-xl); - - display: grid; - grid-template-columns: var(--first-col-width) 1fr; - gap: var(--spacing-xl); - } -} - -.icon { - margin-right: var(--spacing-2xs); -} diff --git a/src/components/organisms/modals/settings-modal.stories.tsx b/src/components/organisms/modals/settings-modal.stories.tsx deleted file mode 100644 index 7c56f27..0000000 --- a/src/components/organisms/modals/settings-modal.stories.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { SettingsModal } from './settings-modal'; - -/** - * SettingsModal - Storybook Meta - */ -export default { - title: 'Organisms/Modals', - component: SettingsModal, - 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, - }, - }, - }, - parameters: { - layout: 'fullscreen', - }, -} as ComponentMeta<typeof SettingsModal>; - -const Template: ComponentStory<typeof SettingsModal> = (args) => ( - <SettingsModal {...args} /> -); - -/** - * Modals Stories - Settings - */ -export const Settings = Template.bind({}); -Settings.args = {}; diff --git a/src/components/organisms/modals/settings-modal.test.tsx b/src/components/organisms/modals/settings-modal.test.tsx deleted file mode 100644 index af2b6e9..0000000 --- a/src/components/organisms/modals/settings-modal.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '../../../../tests/utils'; -import { SettingsModal } from './settings-modal'; - -describe('SettingsModal', () => { - it('renders the modal heading', () => { - render(<SettingsModal />); - expect(rtlScreen.getByText(/Settings/i)).toBeInTheDocument(); - }); - - it('renders a settings form', () => { - render(<SettingsModal />); - expect( - rtlScreen.getByRole('form', { name: /^Settings form/i }) - ).toBeInTheDocument(); - expect( - rtlScreen.getByRole('radiogroup', { name: /^Theme:/i }) - ).toBeInTheDocument(); - expect( - rtlScreen.getByRole('radiogroup', { name: /^Code blocks:/i }) - ).toBeInTheDocument(); - expect( - rtlScreen.getByRole('radiogroup', { name: /^Animations:/i }) - ).toBeInTheDocument(); - expect( - rtlScreen.getByRole('radiogroup', { name: /^Tracking:/i }) - ).toBeInTheDocument(); - }); -}); diff --git a/src/components/organisms/modals/settings-modal.tsx b/src/components/organisms/modals/settings-modal.tsx deleted file mode 100644 index 36c5977..0000000 --- a/src/components/organisms/modals/settings-modal.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useCallback, type FC, type FormEvent } from 'react'; -import { useIntl } from 'react-intl'; -import { Heading, Icon } from '../../atoms'; -import { Modal, type ModalProps } from '../../molecules'; -import { SettingsForm } from '../forms'; -import styles from './settings-modal.module.scss'; - -export type SettingsModalProps = Pick<ModalProps, 'className'>; - -/** - * SettingsModal component - * - * Render a modal with settings options. - */ -export const SettingsModal: FC<SettingsModalProps> = ({ className = '' }) => { - const intl = useIntl(); - const title = intl.formatMessage({ - defaultMessage: 'Settings', - description: 'SettingsModal: title', - id: 'gPfT/K', - }); - const ariaLabel = intl.formatMessage({ - defaultMessage: 'Settings form', - id: 'xYNeKX', - description: 'SettingsModal: an accessible form name', - }); - - const submitHandler = useCallback((e: FormEvent) => { - e.preventDefault(); - }, []); - - return ( - <Modal - className={`${styles.wrapper} ${className}`} - icon={<Icon className={styles.icon} shape="cog" />} - heading={ - <Heading isFake level={3}> - {title} - </Heading> - } - > - <SettingsForm aria-label={ariaLabel} onSubmit={submitHandler} /> - </Modal> - ); -}; diff --git a/src/components/organisms/navbar/index.ts b/src/components/organisms/navbar/index.ts new file mode 100644 index 0000000..f5899d0 --- /dev/null +++ b/src/components/organisms/navbar/index.ts @@ -0,0 +1 @@ +export * from './navbar'; diff --git a/src/components/organisms/navbar/navbar-item/index.ts b/src/components/organisms/navbar/navbar-item/index.ts new file mode 100644 index 0000000..a86a19e --- /dev/null +++ b/src/components/organisms/navbar/navbar-item/index.ts @@ -0,0 +1 @@ +export * from './navbar-item'; diff --git a/src/components/organisms/navbar/navbar-item/navbar-item.module.scss b/src/components/organisms/navbar/navbar-item/navbar-item.module.scss new file mode 100644 index 0000000..2f23588 --- /dev/null +++ b/src/components/organisms/navbar/navbar-item/navbar-item.module.scss @@ -0,0 +1,173 @@ +@use "../../../../styles/abstracts/functions" as fun; +@use "../../../../styles/abstracts/mixins" as mix; +@use "../../../../styles/abstracts/placeholders"; + +.overlay { + @include mix.media("screen") { + @include mix.dimensions(null, "sm") { + bottom: var(--modal-pos, var(--btn-size, --default-btn-size)); + display: flex; + flex-flow: row wrap; + place-content: flex-end; + } + + @include mix.dimensions("sm") { + position: absolute; + inset: calc(100% + var(--spacing-2xs)) auto; + background: transparent; + } + } +} + +.modal { + transition: + all 0.8s ease-in-out 0s, + background 0s; + + @include mix.media("screen") { + @include mix.dimensions(null, "sm") { + max-width: 100vw; + width: 100vw; + margin-bottom: fun.convert-px(2); + border-inline: 0; + } + } +} + +.label { + --draw-border-thickness: #{fun.convert-px(4)}; + + display: flex; + justify-content: center; + align-items: center; + width: fit-content; + min-width: var(--btn-size, --default-btn-size); + min-height: var(--btn-size, --default-btn-size); + padding: var(--spacing-xs); + border-radius: fun.convert-px(5); +} + +.flip { + --flipper-speed: 0.5s; + + place-content: center; + + &__side { + display: flex; + place-content: center; + } +} + +.checkbox { + position: absolute; + + /* 6px = checkbox approximate size */ + inset: calc(50% - 6px) calc(50% - 6px); + opacity: 0; + cursor: pointer; + + &:hover, + &:focus { + &, + + .label { + @extend %draw-borders; + } + } + + &:checked + .label { + --draw-border-color1: var(--color-primary-dark); + --draw-border-color2: var(--color-primary-light); + + .icon--hamburger { + > span { + background: transparent; + border: transparent; + + &::before { + top: 40%; + transform-origin: 50% 50%; + transform: rotate(-45deg); + } + + &::after { + bottom: 40%; + transform-origin: 50% 50%; + transform: rotate(45deg); + } + } + } + } + + &:not(:checked) { + + .label { + --draw-border-color1: var(--color-primary-light); + --draw-border-color2: var(--color-primary-lighter); + } + + ~ .overlay { + @include mix.media("screen") { + @include mix.dimensions("sm") { + overflow: visible; + transition: all 0.3s ease-in-out 0.8s; + } + } + + > .modal { + @include mix.media("screen") { + @include mix.dimensions(null, "sm") { + transform: translateX(-100vw); + } + + @include mix.dimensions("sm") { + transform: scale(0) perspective(#{fun.convert-px(250)}) + translate3d(0, 0, #{fun.convert-px(-250)}); + transform-origin: var(--transform-origin, 15% -15%); + } + } + } + } + } +} + +@mixin modal-visible { + > .checkbox, + > .label { + display: none; + } + + > .overlay { + display: contents; + } + + .checkbox:is(:checked, :not(:checked)) ~ .overlay .modal { + padding: 0; + background: transparent; + border: none; + box-shadow: none; + transform: none; + opacity: 1; + visibility: visible; + } +} + +.item { + --default-btn-size: #{fun.convert-px(70)}; + + position: relative; + + &--hidden-controller-sm { + @include mix.media("screen") { + @include mix.dimensions("sm") { + @include modal-visible; + } + } + } + + &--hidden-controller-md { + @include mix.media("screen") { + @include mix.dimensions("md") { + @include modal-visible; + } + } + } +} diff --git a/src/components/organisms/navbar/navbar-item/navbar-item.stories.tsx b/src/components/organisms/navbar/navbar-item/navbar-item.stories.tsx new file mode 100644 index 0000000..1c56768 --- /dev/null +++ b/src/components/organisms/navbar/navbar-item/navbar-item.stories.tsx @@ -0,0 +1,55 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useBoolean } from '../../../../utils/hooks'; +import { NavbarItem } from './navbar-item'; + +/** + * NavbarItem - Storybook Meta + */ +export default { + title: 'Organisms/Navbar/Item', + component: NavbarItem, + 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} + /> + ); +}; + +/** + * NavbarItem Stories - Default + */ +export const Default = Template.bind({}); +Default.args = { + children: 'The modal contents.', + icon: 'cog', + id: 'default', + isActive: false, + label: 'Open example', +}; + +/** + * NavbarItem Stories - ModalVisibleAfterBreakpoint + */ +export const ModalVisibleAfterBreakpoint = Template.bind({}); +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 new file mode 100644 index 0000000..2e7edea --- /dev/null +++ b/src/components/organisms/navbar/navbar-item/navbar-item.test.tsx @@ -0,0 +1,79 @@ +import { describe, expect, it } 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} + /> + ); +}; + +describe('NavbarItem', () => { + it('renders a labelled checkbox to open/close a modal', async () => { + const label = 'quod'; + const modal = 'tempore ipsam laborum'; + const user = userEvent.setup(); + + render( + <ControlledNavbarItem + icon="arrow" + id="vel" + isActive={false} + label={label} + > + {modal} + </ControlledNavbarItem> + ); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(3); + + const controller = rtlScreen.getByRole('checkbox', { name: label }); + + expect(controller).not.toBeChecked(); + // Since the visibility is declared in CSS we cannot use this assertion. + //expect(rtlScreen.getByText(modal)).not.toBeVisible(); + + await user.click(controller); + + expect(controller).toBeChecked(); + expect(rtlScreen.getByText(modal)).toBeVisible(); + }); + + it('can deactivate the modal when clicking outside', async () => { + const label = 'qui'; + const modal = 'laborum doloremque id'; + const user = userEvent.setup(); + + render( + <ControlledNavbarItem icon="arrow" id="et" isActive label={label}> + {modal} + </ControlledNavbarItem> + ); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(2); + + const controller = rtlScreen.getByRole('checkbox', { name: label }); + + expect(controller).toBeChecked(); + + if (controller.parentElement) await user.click(controller.parentElement); + + expect(controller).not.toBeChecked(); + // Since the visibility is declared in CSS we cannot use this assertion. + //expect(rtlScreen.getByText(modal)).not.toBeVisible(); + }); +}); diff --git a/src/components/organisms/navbar/navbar-item/navbar-item.tsx b/src/components/organisms/navbar/navbar-item/navbar-item.tsx new file mode 100644 index 0000000..8ef6ce3 --- /dev/null +++ b/src/components/organisms/navbar/navbar-item/navbar-item.tsx @@ -0,0 +1,179 @@ +import { + type ReactNode, + useCallback, + type ForwardRefRenderFunction, + forwardRef, + useRef, +} from 'react'; +import { + useOnClickOutside, + type useOnClickOutsideHandler, +} from '../../../../utils/hooks'; +import { + Checkbox, + Heading, + Icon, + type IconShape, + Label, + Overlay, + Flip, + FlipSide, + type ListItemProps, + ListItem, +} from '../../../atoms'; +import { Modal } from '../../../molecules'; +import styles from './navbar-item.module.scss'; + +export type NavbarItemProps = Omit< + ListItemProps, + 'children' | 'hideMarker' | 'id' +> & { + /** + * The modal contents. + */ + children: ReactNode; + /** + * An icon to illustrate the nav item. + */ + icon: IconShape; + /** + * The item id. + */ + id: string; + /** + * Should the modal be visible? + */ + isActive: boolean; + /** + * An accessible name for the nav item. + */ + label: string; + /** + * The modal heading. + */ + modalHeading?: string; + /** + * Make the modal always visible from the given breakpoint. + */ + modalVisibleFrom?: 'sm' | 'md'; + /** + * A callback function to handle modal deactivation. + */ + onDeactivate?: () => void; + /** + * A callback function to handle modal toggle. + */ + onToggle: () => void; + /** + * Should we add the icon on the modal? + * + * @default false + */ + showIconOnModal?: boolean; +}; + +const NavbarItemWithRef: ForwardRefRenderFunction< + HTMLLIElement, + NavbarItemProps +> = ( + { + children, + className = '', + icon, + id, + isActive, + label, + modalHeading, + modalVisibleFrom, + onDeactivate, + onToggle, + showIconOnModal = false, + ...props + }, + ref +) => { + const itemClass = [ + styles.item, + modalVisibleFrom + ? styles[`item--hidden-controller-${modalVisibleFrom}`] + : '', + className, + ].join(' '); + const labelRef = useRef<HTMLLabelElement>(null); + const checkboxRef = useRef<HTMLInputElement>(null); + const deactivateItem: useOnClickOutsideHandler = useCallback( + (e) => { + const isCheckbox = + 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(); + }, + [onDeactivate] + ); + const modalRef = useOnClickOutside<HTMLDivElement>(deactivateItem); + + return ( + <ListItem {...props} className={itemClass} hideMarker ref={ref}> + <Checkbox + className={styles.checkbox} + id={id} + isChecked={isActive} + name={id} + onChange={onToggle} + ref={checkboxRef} + value={id} + /> + <Label + aria-label={label} + className={styles.label} + htmlFor={id} + ref={labelRef} + > + {icon === 'hamburger' ? ( + <Icon + aria-hidden + className={styles[`icon--${icon}`]} + shape={icon} + // eslint-disable-next-line react/jsx-no-literals + size="lg" + /> + ) : ( + <Flip aria-hidden className={styles.flip} showBack={isActive}> + <FlipSide className={styles.flip__side}> + <Icon + shape={icon} + // eslint-disable-next-line react/jsx-no-literals + size="lg" + /> + </FlipSide> + <FlipSide className={styles.flip__side} isBack> + <Icon + // eslint-disable-next-line react/jsx-no-literals + shape="cross" + /> + </FlipSide> + </Flip> + )} + </Label> + <Overlay className={styles.overlay} isVisible={isActive}> + <Modal + className={styles.modal} + heading={ + modalHeading ? ( + <Heading isFake level={3}> + {modalHeading} + </Heading> + ) : null + } + icon={showIconOnModal ? <Icon shape={icon} /> : null} + ref={modalRef} + > + {children} + </Modal> + </Overlay> + </ListItem> + ); +}; + +export const NavbarItem = forwardRef(NavbarItemWithRef); diff --git a/src/components/organisms/navbar/navbar.module.scss b/src/components/organisms/navbar/navbar.module.scss new file mode 100644 index 0000000..4041825 --- /dev/null +++ b/src/components/organisms/navbar/navbar.module.scss @@ -0,0 +1,53 @@ +@use "../../../styles/abstracts/functions" as fun; +@use "../../../styles/abstracts/mixins" as mix; + +.wrapper { + --btn-size: #{fun.convert-px(65)}; + + display: flex; + flex-flow: row wrap; + align-items: center; + gap: var(--spacing-sm); + justify-content: space-between; + + :global { + animation: slide-in-from-bottom 0.8s ease-in-out 0s 1; + } + + @include mix.media("screen") { + @include mix.dimensions(null, "sm") { + padding-inline: var(--spacing-sm); + position: fixed; + bottom: 0; + inset-inline: 0; + z-index: 5; + background: var(--color-bg); + border-top: fun.convert-px(4) solid; + border-image: radial-gradient( + ellipse at top, + var(--color-primary-lighter) 20%, + var(--color-primary) 100% + ) + 1; + box-shadow: 0 fun.convert-px(-2) fun.convert-px(3) fun.convert-px(-1) + var(--color-shadow-dark); + } + + @include mix.dimensions("sm") { + --transform-origin: 95% -10%; + + position: relative; + inset: unset; + width: fit-content; + + :global { + animation: slide-in-from-top 0.8s ease-in-out 0s 1; + } + } + } +} + +.item { + display: flex; + justify-content: flex-end; +} diff --git a/src/components/organisms/navbar/navbar.stories.tsx b/src/components/organisms/navbar/navbar.stories.tsx new file mode 100644 index 0000000..fef995e --- /dev/null +++ b/src/components/organisms/navbar/navbar.stories.tsx @@ -0,0 +1,110 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Navbar as NavbarComponent } from './navbar'; + +/** + * Navbar - Storybook Meta + */ +export default { + title: 'Organisms/Navbar', + component: NavbarComponent, + args: { + searchPage: '#', + }, + argTypes: { + nav: { + description: 'The main nav 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', + }, +} as ComponentMeta<typeof NavbarComponent>; + +const Template: ComponentStory<typeof NavbarComponent> = (args) => ( + <NavbarComponent {...args} /> +); + +const doNothing = () => { + // do nothing; +}; + +/** + * Navbar Stories - With all items inactive + */ +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, + }, + ], +}; + +/** + * Navbar Stories - With one item active + */ +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, + }, + ], +}; diff --git a/src/components/organisms/navbar/navbar.test.tsx b/src/components/organisms/navbar/navbar.test.tsx new file mode 100644 index 0000000..35b33f2 --- /dev/null +++ b/src/components/organisms/navbar/navbar.test.tsx @@ -0,0 +1,42 @@ +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, + }, +]; + +describe('Navbar', () => { + it('renders the given items', () => { + render(<Navbar items={items} />); + + expect(rtlScreen.getAllByRole('listitem')).toHaveLength(items.length); + }); +}); diff --git a/src/components/organisms/navbar/navbar.tsx b/src/components/organisms/navbar/navbar.tsx new file mode 100644 index 0000000..ee379e9 --- /dev/null +++ b/src/components/organisms/navbar/navbar.tsx @@ -0,0 +1,65 @@ +import { + type ForwardRefRenderFunction, + forwardRef, + 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' +> & { + /** + * The navbar items. + * + * The number of items should not exceed 3 because of the modal position on + * small screens. + */ + items: NavbarItems; +}; + +const NavbarWithRef: ForwardRefRenderFunction<HTMLUListElement, NavbarProps> = ( + { className = '', items, ...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> + ))} + </List> + ); +}; + +/** + * Navbar component + * + * Render the website navbar. + */ +export const Navbar = forwardRef(NavbarWithRef); diff --git a/src/components/organisms/toolbar/index.ts b/src/components/organisms/toolbar/index.ts deleted file mode 100644 index 316a52a..0000000 --- a/src/components/organisms/toolbar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './toolbar'; diff --git a/src/components/organisms/toolbar/main-nav.module.scss b/src/components/organisms/toolbar/main-nav.module.scss deleted file mode 100644 index bedf38e..0000000 --- a/src/components/organisms/toolbar/main-nav.module.scss +++ /dev/null @@ -1,86 +0,0 @@ -@use "../../../styles/abstracts/functions" as fun; -@use "../../../styles/abstracts/mixins" as mix; - -.item { - @include mix.media("screen") { - @include mix.dimensions("md") { - .checkbox, - .label { - display: none; - } - - .modal { - position: relative; - } - } - } - - .modal { - @include mix.media("screen") { - @include mix.dimensions(null, "md") { - padding: var(--spacing-2xs); - background: var(--color-bg-secondary); - border-top: fun.convert-px(4) solid; - border-bottom: fun.convert-px(4) solid; - border-image: radial-gradient( - ellipse at top, - var(--color-primary-lighter) 20%, - var(--color-primary) 100% - ) - 1; - box-shadow: fun.convert-px(2) fun.convert-px(-2) fun.convert-px(3) - fun.convert-px(-1) var(--color-shadow-dark); - } - - @include mix.dimensions("sm", "md") { - border-left: fun.convert-px(4) solid; - border-right: fun.convert-px(4) solid; - } - - @include mix.dimensions("md") { - top: unset; - } - } - } - - .checkbox { - &:checked { - ~ .label .icon { - background: transparent; - border: transparent; - - &::before { - top: 0; - transform-origin: 50% 50%; - transform: rotate(-45deg); - } - - &::after { - bottom: 0; - transform-origin: 50% 50%; - transform: rotate(45deg); - } - } - } - - &:not(:checked) { - @include mix.media("screen") { - @include mix.dimensions("md") { - ~ .modal { - opacity: 1; - visibility: visible; - transform: none; - } - } - } - } - } -} - -.label { - display: flex; - place-content: center; - place-items: center; - width: var(--btn-size, #{fun.convert-px(60)}); - height: var(--btn-size, #{fun.convert-px(60)}); -} diff --git a/src/components/organisms/toolbar/main-nav.stories.tsx b/src/components/organisms/toolbar/main-nav.stories.tsx deleted file mode 100644 index 31e2b65..0000000 --- a/src/components/organisms/toolbar/main-nav.stories.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { useToggle } from '../../../utils/hooks'; -import { MainNavItem } from './main-nav'; - -/** - * MainNavItem - Storybook Meta - */ -export default { - title: 'Organisms/Toolbar/MainNavItem', - component: MainNavItem, - argTypes: { - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the main nav wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - isActive: { - control: { - type: null, - }, - description: 'Determine if the main nav is open or not.', - type: { - name: 'boolean', - required: true, - }, - }, - items: { - description: 'The main nav items.', - type: { - name: 'object', - required: true, - value: {}, - }, - }, - setIsActive: { - control: { - type: null, - }, - description: 'A callback function to change main nav state.', - table: { - category: 'Events', - }, - type: { - name: 'function', - required: true, - }, - }, - }, -} as ComponentMeta<typeof MainNavItem>; - -const Template: ComponentStory<typeof MainNavItem> = ({ - isActive = false, - setIsActive: _setIsActive, - ...args -}) => { - const [isOpen, toggle] = useToggle(isActive); - - return <MainNavItem isActive={isOpen} setIsActive={toggle} {...args} />; -}; - -/** - * MainNavItem Stories - Inactive - */ -export const Inactive = Template.bind({}); -Inactive.args = { - isActive: false, - items: [ - { id: 'home', label: 'Home', href: '#' }, - { id: 'contact', label: 'Contact', href: '#' }, - ], -}; - -/** - * MainNavItem Stories - Active - */ -export const Active = Template.bind({}); -Active.args = { - isActive: true, - items: [ - { id: 'home', label: 'Home', href: '#' }, - { id: 'contact', label: 'Contact', href: '#' }, - ], -}; diff --git a/src/components/organisms/toolbar/main-nav.test.tsx b/src/components/organisms/toolbar/main-nav.test.tsx deleted file mode 100644 index 177e692..0000000 --- a/src/components/organisms/toolbar/main-nav.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '../../../../tests/utils'; -import { MainNavItem } from './main-nav'; - -const doNothing = () => { - // do nothing -}; - -const items = [ - { id: 'home', label: 'Home', href: '/' }, - { id: 'blog', label: 'Blog', href: '/blog' }, - { id: 'contact', label: 'Contact', href: '/contact' }, -]; - -describe('MainNavItem', () => { - it('renders a checkbox to open main nav', () => { - render( - <MainNavItem items={items} isActive={false} setIsActive={doNothing} /> - ); - expect(rtlScreen.getByRole('checkbox')).toHaveAccessibleName('Open menu'); - }); - - it('renders a checkbox to close main nav', () => { - render( - <MainNavItem items={items} isActive={true} setIsActive={doNothing} /> - ); - expect(rtlScreen.getByRole('checkbox')).toHaveAccessibleName('Close menu'); - }); - - it('renders the correct number of items', () => { - render( - <MainNavItem items={items} isActive={true} setIsActive={doNothing} /> - ); - expect(rtlScreen.getAllByRole('listitem')).toHaveLength(items.length); - }); - - it('renders some links with the right label', () => { - render( - <MainNavItem items={items} isActive={true} setIsActive={doNothing} /> - ); - expect( - rtlScreen.getByRole('link', { name: items[0].label }) - ).toHaveAttribute('href', items[0].href); - }); -}); diff --git a/src/components/organisms/toolbar/main-nav.tsx b/src/components/organisms/toolbar/main-nav.tsx deleted file mode 100644 index ee799f5..0000000 --- a/src/components/organisms/toolbar/main-nav.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { forwardRef, type ForwardRefRenderFunction } from 'react'; -import { useIntl } from 'react-intl'; -import { BooleanField, type BooleanFieldProps, Icon, Label } from '../../atoms'; -import { type MainNavItem as Item, MainNav } from '../nav'; -import mainNavStyles from './main-nav.module.scss'; -import sharedStyles from './toolbar-items.module.scss'; - -export type MainNavItemProps = { - /** - * Set additional classnames to the nav element. - */ - className?: string; - /** - * The button state. - */ - isActive: BooleanFieldProps['isChecked']; - /** - * The main nav items. - */ - items: Item[]; - /** - * A callback function to handle button state. - */ - setIsActive: BooleanFieldProps['onChange']; -}; - -const MainNavItemWithRef: ForwardRefRenderFunction< - HTMLDivElement, - MainNavItemProps -> = ({ className = '', isActive = false, items, setIsActive }, ref) => { - const intl = useIntl(); - const label = isActive - ? intl.formatMessage({ - defaultMessage: 'Close menu', - description: 'MainNav: Close label', - id: 'aJC7D2', - }) - : intl.formatMessage({ - defaultMessage: 'Open menu', - description: 'MainNav: Open label', - id: 'GTbGMy', - }); - - return ( - <div className={`${sharedStyles.item} ${mainNavStyles.item}`} ref={ref}> - <BooleanField - className={`${sharedStyles.checkbox} ${mainNavStyles.checkbox}`} - id="main-nav-button" - isChecked={isActive} - name="main-nav-button" - onChange={setIsActive} - type="checkbox" - value="open" - /> - <Label - aria-label={label} - className={`${sharedStyles.label} ${mainNavStyles.label}`} - htmlFor="main-nav-button" - > - <Icon shape="hamburger" /> - </Label> - <MainNav - className={`${sharedStyles.modal} ${mainNavStyles.modal} ${className}`} - items={items} - /> - </div> - ); -}; - -/** - * MainNavItem component - * - * Render the main navigation as toolbar item. - */ -export const MainNavItem = forwardRef(MainNavItemWithRef); diff --git a/src/components/organisms/toolbar/search.module.scss b/src/components/organisms/toolbar/search.module.scss deleted file mode 100644 index 0dc36de..0000000 --- a/src/components/organisms/toolbar/search.module.scss +++ /dev/null @@ -1,11 +0,0 @@ -@use "../../../styles/abstracts/mixins" as mix; - -.modal { - padding-bottom: var(--spacing-md); - - @include mix.media("screen") { - @include mix.dimensions(null, "sm") { - border-inline: 0; - } - } -} diff --git a/src/components/organisms/toolbar/search.stories.tsx b/src/components/organisms/toolbar/search.stories.tsx deleted file mode 100644 index 0f211bd..0000000 --- a/src/components/organisms/toolbar/search.stories.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { useToggle } from '../../../utils/hooks'; -import { Search } from './search'; - -/** - * Search - Storybook Meta - */ -export default { - title: 'Organisms/Toolbar/Search', - component: Search, - args: { - searchPage: '#', - }, - argTypes: { - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the modal wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - isActive: { - control: { - type: null, - }, - description: 'Define the modal state: either opened or closed.', - type: { - name: 'boolean', - required: true, - }, - }, - searchPage: { - control: { - type: 'text', - }, - description: 'The search results page url.', - type: { - name: 'string', - required: true, - }, - }, - setIsActive: { - control: { - type: null, - }, - description: 'A callback function to update modal state.', - table: { - category: 'Events', - }, - type: { - name: 'function', - required: true, - }, - }, - }, -} as ComponentMeta<typeof Search>; - -const Template: ComponentStory<typeof Search> = ({ - isActive = false, - setIsActive: _setIsActive, - ...args -}) => { - const [isOpen, toggle] = useToggle(isActive); - - return <Search isActive={isOpen} setIsActive={toggle} {...args} />; -}; - -/** - * Search Stories - Inactive - */ -export const Inactive = Template.bind({}); -Inactive.args = { - isActive: false, -}; - -/** - * Search Stories - Active - */ -export const Active = Template.bind({}); -Active.args = { - isActive: true, -}; diff --git a/src/components/organisms/toolbar/search.test.tsx b/src/components/organisms/toolbar/search.test.tsx deleted file mode 100644 index 6f5ed7e..0000000 --- a/src/components/organisms/toolbar/search.test.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; -import { Search } from './search'; - -describe('Search', () => { - it('renders a button to open search modal', () => { - render(<Search searchPage="#" isActive={false} setIsActive={() => null} />); - expect(screen.getByRole('checkbox')).toHaveAccessibleName('Open search'); - }); - - it('renders a button to close search modal', () => { - render(<Search searchPage="#" isActive={true} setIsActive={() => null} />); - expect(screen.getByRole('checkbox')).toHaveAccessibleName('Close search'); - }); -}); diff --git a/src/components/organisms/toolbar/search.tsx b/src/components/organisms/toolbar/search.tsx deleted file mode 100644 index 4429770..0000000 --- a/src/components/organisms/toolbar/search.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { forwardRef, type ForwardRefRenderFunction } from 'react'; -import { useIntl } from 'react-intl'; -import { useAutofocus } from '../../../utils/hooks'; -import { BooleanField, type BooleanFieldProps, Icon } from '../../atoms'; -import { SearchModal, type SearchModalProps } from '../modals'; -import searchStyles from './search.module.scss'; -import sharedStyles from './toolbar-items.module.scss'; - -export type SearchProps = { - /** - * Set additional classnames to the modal wrapper. - */ - className?: SearchModalProps['className']; - /** - * The button state. - */ - isActive: BooleanFieldProps['isChecked']; - /** - * A callback function to execute search. - */ - searchPage: SearchModalProps['searchPage']; - /** - * A callback function to handle button state. - */ - setIsActive: BooleanFieldProps['onChange']; -}; - -const SearchWithRef: ForwardRefRenderFunction<HTMLDivElement, SearchProps> = ( - { className = '', isActive = false, searchPage, setIsActive }, - ref -) => { - const intl = useIntl(); - const label = isActive - ? intl.formatMessage({ - defaultMessage: 'Close search', - id: 'LDDUNO', - description: 'Search: Close label', - }) - : intl.formatMessage({ - defaultMessage: 'Open search', - id: 'Xj+WXB', - description: 'Search: Open label', - }); - - const searchInputRef = useAutofocus<HTMLInputElement>({ - condition: () => isActive, - delay: 360, - }); - - return ( - <div className={`${sharedStyles.item} ${searchStyles.item}`} ref={ref}> - <BooleanField - className={`${sharedStyles.checkbox} ${searchStyles.checkbox}`} - id="search-button" - isChecked={isActive} - name="search-button" - onChange={setIsActive} - type="checkbox" - value="open" - /> - <FlippingLabel - className={sharedStyles.label} - htmlFor="search-button" - icon={<Icon aria-hidden={true} shape="magnifying-glass" size="lg" />} - isActive={isActive} - label={label} - /> - <SearchModal - className={`${sharedStyles.modal} ${searchStyles.modal} ${className}`} - ref={searchInputRef} - searchPage={searchPage} - /> - </div> - ); -}; - -export const Search = forwardRef(SearchWithRef); diff --git a/src/components/organisms/toolbar/settings.module.scss b/src/components/organisms/toolbar/settings.module.scss deleted file mode 100644 index 2c473b7..0000000 --- a/src/components/organisms/toolbar/settings.module.scss +++ /dev/null @@ -1,9 +0,0 @@ -@use "../../../styles/abstracts/mixins" as mix; - -.modal { - @include mix.media("screen") { - @include mix.dimensions(null, "sm") { - border-inline: 0; - } - } -} diff --git a/src/components/organisms/toolbar/settings.stories.tsx b/src/components/organisms/toolbar/settings.stories.tsx deleted file mode 100644 index c1fe37d..0000000 --- a/src/components/organisms/toolbar/settings.stories.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { useToggle } from '../../../utils/hooks'; -import { Settings } from './settings'; - -/** - * Settings - Storybook Meta - */ -export default { - title: 'Organisms/Toolbar/Settings', - component: Settings, - argTypes: { - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the modal wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - isActive: { - control: { - type: null, - }, - description: 'Define the modal state: either opened or closed.', - table: { - category: 'Events', - }, - type: { - name: 'boolean', - required: true, - }, - }, - setIsActive: { - control: { - type: null, - }, - description: 'A callback function to update modal state.', - type: { - name: 'function', - required: true, - }, - }, - tooltipClassName: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the tooltip wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - }, -} as ComponentMeta<typeof Settings>; - -const Template: ComponentStory<typeof Settings> = ({ - isActive = false, - setIsActive: _setIsActive, - ...args -}) => { - const [isOpen, toggle] = useToggle(isActive); - - return <Settings isActive={isOpen} setIsActive={toggle} {...args} />; -}; - -/** - * Settings Stories - Inactive - */ -export const Inactive = Template.bind({}); -Inactive.args = { - isActive: false, -}; - -/** - * Settings Stories - Active - */ -export const Active = Template.bind({}); -Active.args = { - isActive: true, -}; diff --git a/src/components/organisms/toolbar/settings.test.tsx b/src/components/organisms/toolbar/settings.test.tsx deleted file mode 100644 index 6dbed2b..0000000 --- a/src/components/organisms/toolbar/settings.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '../../../../tests/utils'; -import { Settings } from './settings'; - -const doNothing = () => { - // do nothing -}; - -describe('Settings', () => { - it('renders a button to open settings modal', () => { - render(<Settings isActive={false} setIsActive={doNothing} />); - expect( - rtlScreen.getByRole('checkbox', { name: 'Open settings' }) - ).toBeInTheDocument(); - }); - - it('renders a button to close settings modal', () => { - render(<Settings isActive={true} setIsActive={doNothing} />); - expect( - rtlScreen.getByRole('checkbox', { name: 'Close settings' }) - ).toBeInTheDocument(); - }); -}); diff --git a/src/components/organisms/toolbar/settings.tsx b/src/components/organisms/toolbar/settings.tsx deleted file mode 100644 index a0aad8c..0000000 --- a/src/components/organisms/toolbar/settings.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { forwardRef, type ForwardRefRenderFunction } from 'react'; -import { useIntl } from 'react-intl'; -import { BooleanField, type BooleanFieldProps, Icon } from '../../atoms'; -import { FlippingLabel } from '../../molecules'; -import { SettingsModal, type SettingsModalProps } from '../modals'; -import styles from './settings.module.scss'; -import sharedStyles from './toolbar-items.module.scss'; - -export type SettingsProps = SettingsModalProps & { - /** - * The button state. - */ - isActive: BooleanFieldProps['isChecked']; - /** - * A callback function to handle button state. - */ - setIsActive: BooleanFieldProps['onChange']; -}; - -const SettingsWithRef: ForwardRefRenderFunction< - HTMLDivElement, - SettingsProps -> = ({ className = '', isActive = false, setIsActive }, ref) => { - const intl = useIntl(); - const label = isActive - ? intl.formatMessage({ - defaultMessage: 'Close settings', - id: '+viX9b', - description: 'Settings: Close label', - }) - : intl.formatMessage({ - defaultMessage: 'Open settings', - id: 'QCW3cy', - description: 'Settings: Open label', - }); - - return ( - <div className={sharedStyles.item} ref={ref}> - <BooleanField - className={sharedStyles.checkbox} - id="settings-button" - isChecked={isActive} - name="settings-button" - onChange={setIsActive} - type="checkbox" - value="open" - /> - <FlippingLabel - className={sharedStyles.label} - htmlFor="settings-button" - icon={<Icon aria-hidden={true} shape="cog" size="lg" />} - isActive={isActive} - label={label} - /> - <SettingsModal - className={`${sharedStyles.modal} ${styles.modal} ${className}`} - /> - </div> - ); -}; - -export const Settings = forwardRef(SettingsWithRef); diff --git a/src/components/organisms/toolbar/toolbar-items.module.scss b/src/components/organisms/toolbar/toolbar-items.module.scss deleted file mode 100644 index 540844b..0000000 --- a/src/components/organisms/toolbar/toolbar-items.module.scss +++ /dev/null @@ -1,91 +0,0 @@ -@use "../../../styles/abstracts/functions" as fun; -@use "../../../styles/abstracts/mixins" as mix; -@use "../../../styles/abstracts/placeholders"; - -.item { - --btn-size: #{fun.convert-px(65)}; - - display: flex; - position: relative; - - @include mix.media("screen") { - @include mix.dimensions("sm") { - justify-content: flex-end; - } - - @include mix.dimensions("md") { - justify-content: flex-end; - } - } -} - -.modal { - position: absolute; - top: var(--toolbar-size, calc(var(--btn-size) + var(--spacing-2xs))); - transition: all 0.8s ease-in-out 0s, background 0s; - - @include mix.media("screen") { - @include mix.dimensions(null, "sm") { - position: fixed; - left: 0; - right: 0; - } - } -} - -.label { - --draw-border-thickness: #{fun.convert-px(4)}; - --draw-border-color1: var(--color-primary-light); - --draw-border-color2: var(--color-primary-lighter); - - @include mix.media("screen") { - @include mix.dimensions("sm") { - border-radius: fun.convert-px(5); - } - } - - &:hover { - @extend %draw-borders; - } - - &:active { - --draw-border-color1: var(--color-primary-dark); - --draw-border-color2: var(--color-primary-light); - - @extend %draw-borders; - } -} - -.checkbox { - position: absolute; - top: calc(var(--btn-size) / 2); - left: calc(var(--btn-size) / 2); - opacity: 0; - cursor: pointer; - - &:hover, - &:focus { - ~ .label { - @extend %draw-borders; - } - } - - &:not(:checked) { - ~ .modal { - opacity: 0; - visibility: hidden; - - @include mix.media("screen") { - @include mix.dimensions(null, "sm") { - transform: translateX(-100vw); - } - - @include mix.dimensions("sm") { - transform: perspective(#{fun.convert-px(400)}) - translate3d(0, 0, #{fun.convert-px(-400)}); - transform-origin: 100% -50%; - } - } - } - } -} diff --git a/src/components/organisms/toolbar/toolbar.module.scss b/src/components/organisms/toolbar/toolbar.module.scss deleted file mode 100644 index 6c138a3..0000000 --- a/src/components/organisms/toolbar/toolbar.module.scss +++ /dev/null @@ -1,63 +0,0 @@ -@use "../../../styles/abstracts/functions" as fun; -@use "../../../styles/abstracts/variables" as var; -@use "../../../styles/abstracts/placeholders"; - -.wrapper { - --toolbar-size: #{fun.convert-px(75)}; - - display: flex; - flex-flow: row wrap; - align-items: center; - gap: var(--spacing-sm); - width: 100%; - height: var(--toolbar-size); - position: relative; - background: var(--color-bg); - border-top: fun.convert-px(4) solid; - border-image: radial-gradient( - ellipse at top, - var(--color-primary-lighter) 20%, - var(--color-primary) 100% - ) - 1; - box-shadow: 0 fun.convert-px(-2) fun.convert-px(3) fun.convert-px(-1) - var(--color-shadow-dark); - - :global { - animation: slide-in-from-bottom 0.8s ease-in-out 0s 1; - - @media screen and (min-width: #{var.get-breakpoint("sm")}) { - animation: slide-in-from-top 0.8s ease-in-out 0s 1; - } - } - - @media screen and (max-width: #{var.get-breakpoint("sm")}) { - justify-content: space-around; - position: fixed; - bottom: 0; - left: 0; - z-index: 5; - - .modal { - width: 100%; - position: fixed; - top: unset; - left: 0; - bottom: calc(var(--toolbar-size) - #{fun.convert-px(4)}); - max-height: calc(100vh - var(--toolbar-size)); - } - } - - @media screen and (max-height: #{var.get-breakpoint("2xs")}) { - --toolbar-size: #{fun.convert-px(70)}; - } - - @media screen and (min-width: #{var.get-breakpoint("sm")}) { - .modal { - &--search, - &--settings { - min-width: fun.convert-px(420); - } - } - } -} diff --git a/src/components/organisms/toolbar/toolbar.stories.tsx b/src/components/organisms/toolbar/toolbar.stories.tsx deleted file mode 100644 index 19dc135..0000000 --- a/src/components/organisms/toolbar/toolbar.stories.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { Toolbar as ToolbarComponent } from './toolbar'; - -/** - * Toolbar - Storybook Meta - */ -export default { - title: 'Organisms/Toolbar', - component: ToolbarComponent, - args: { - searchPage: '#', - }, - argTypes: { - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the toolbar wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - nav: { - description: 'The main nav 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', - }, -} as ComponentMeta<typeof ToolbarComponent>; - -const Template: ComponentStory<typeof ToolbarComponent> = (args) => ( - <ToolbarComponent {...args} /> -); - -const nav = [ - { id: 'home-link', href: '#', label: 'Home' }, - { id: 'blog-link', href: '#', label: 'Blog' }, - { id: 'cv-link', href: '#', label: 'CV' }, - { id: 'contact-link', href: '#', label: 'Contact' }, -]; - -/** - * Toolbar Story - */ -export const Toolbar = Template.bind({}); -Toolbar.args = { - nav, -}; diff --git a/src/components/organisms/toolbar/toolbar.test.tsx b/src/components/organisms/toolbar/toolbar.test.tsx deleted file mode 100644 index 23b13c1..0000000 --- a/src/components/organisms/toolbar/toolbar.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '../../../../tests/utils'; -import { Toolbar } from './toolbar'; - -const nav = [ - { id: 'home-link', href: '/', label: 'Home' }, - { id: 'blog-link', href: '/blog', label: 'Blog' }, - { id: 'cv-link', href: '/cv', label: 'CV' }, - { id: 'contact-link', href: '/contact', label: 'Contact' }, -]; - -describe('Toolbar', () => { - it('renders a navigation menu', () => { - render(<Toolbar nav={nav} searchPage="#" />); - expect(rtlScreen.getByRole('navigation')).toBeInTheDocument(); - }); -}); diff --git a/src/components/organisms/toolbar/toolbar.tsx b/src/components/organisms/toolbar/toolbar.tsx deleted file mode 100644 index c0be464..0000000 --- a/src/components/organisms/toolbar/toolbar.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* eslint-disable max-statements */ -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'; -import styles from './toolbar.module.scss'; - -export type ToolbarProps = Pick<SearchProps, 'searchPage'> & { - /** - * Set additional classnames to the toolbar wrapper. - */ - className?: string; - /** - * The main nav items. - */ - nav: MainNavItemProps['items']; -}; - -/** - * Toolbar component - * - * Render the website toolbar. - */ -export const Toolbar: FC<ToolbarProps> = ({ - className = '', - nav, - searchPage, -}) => { - 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>( - () => isMainNavOpen && deactivateMainNav() - ); - const searchRef = useOnClickOutside<HTMLDivElement>( - () => isSearchOpen && deactivateSearch() - ); - const settingsRef = useOnClickOutside<HTMLDivElement>( - () => isSettingsOpen && deactivateSettings() - ); - - useRouteChange(deactivateSearch); - - return ( - <div className={`${styles.wrapper} ${className}`}> - <MainNavItem - className={styles.modal} - isActive={isMainNavOpen} - items={nav} - ref={mainNavRef} - setIsActive={toggleMainNav} - /> - <Search - className={`${styles.modal} ${styles['modal--search']}`} - isActive={isSearchOpen} - ref={searchRef} - searchPage={searchPage} - setIsActive={toggleSearch} - /> - <Settings - className={`${styles.modal} ${styles['modal--settings']}`} - isActive={isSettingsOpen} - ref={settingsRef} - setIsActive={toggleSettings} - /> - </div> - ); -}; diff --git a/src/components/templates/layout/layout.module.scss b/src/components/templates/layout/layout.module.scss index 4695948..03276bf 100644 --- a/src/components/templates/layout/layout.module.scss +++ b/src/components/templates/layout/layout.module.scss @@ -77,32 +77,11 @@ } } -.toolbar { - justify-content: space-around; - position: fixed; - bottom: 0; - left: 0; - z-index: 5; - background: var(--color-bg); - border-top: fun.convert-px(4) solid; - border-image: radial-gradient( - ellipse at top, - var(--color-primary-lighter) 20%, - var(--color-primary) 100% - ) - 1; - box-shadow: 0 fun.convert-px(-2) fun.convert-px(3) fun.convert-px(-1) - var(--color-shadow-dark); - +.search, +.settings { @include mix.media("screen") { @include mix.dimensions("sm") { - justify-content: flex-end; - width: auto; - position: relative; - left: unset; - background: inherit; - border: none; - box-shadow: none; + min-width: 30ch; } } } diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx index 9017d3c..cdbb414 100644 --- a/src/components/templates/layout/layout.tsx +++ b/src/components/templates/layout/layout.tsx @@ -8,12 +8,16 @@ import { useRef, useState, type CSSProperties, + type FormEvent, + useCallback, } from 'react'; import { useIntl } from 'react-intl'; import type { Person, SearchAction, WebSite, WithContext } from 'schema-dts'; import type { NextPageWithLayoutOptions } from '../../../types'; import { ROUTES } from '../../../utils/constants'; import { + useAutofocus, + useBoolean, useRouteChange, useScrollPosition, useSettings, @@ -35,7 +39,14 @@ import { Copyright, FlippingLogo, } from '../../molecules'; -import { type MainNavItem, Toolbar } from '../../organisms'; +import { + type MainNavItem, + Navbar, + MainNav, + SearchForm, + SettingsForm, + type NavbarItems, +} from '../../organisms'; import styles from './layout.module.scss'; export type QueryAction = SearchAction & { @@ -177,6 +188,117 @@ export const Layout: FC<LayoutProps> = ({ }, ]; + 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', + description: 'Layout: main nav button label in navbar', + id: 'Fgt/RZ', + }), + mainNavModal: intl.formatMessage({ + defaultMessage: 'Main navigation', + description: 'Layout: main nav accessible name', + id: 'dfTljv', + }), + searchItem: intl.formatMessage({ + defaultMessage: 'Open search', + id: 'XRwEoA', + description: 'Layout: search button label in navbar', + }), + searchModal: intl.formatMessage({ + defaultMessage: 'Search', + description: 'Layout: search modal title in navbar', + id: 'Mq+O6q', + }), + settingsItem: intl.formatMessage({ + defaultMessage: 'Open settings', + id: 'mDKiaN', + description: 'Layout: settings button label in navbar', + }), + settingsForm: intl.formatMessage({ + defaultMessage: 'Settings form', + id: 'h3J0a+', + description: 'Layout: an accessible name for the settings form in navbar', + }), + settingsModal: intl.formatMessage({ + defaultMessage: 'Settings', + description: 'Layout: settings modal title in navbar', + id: 'o3WSz5', + }), + }; + + const settingsSubmitHandler = useCallback((e: FormEvent) => { + e.preventDefault(); + }, []); + + const searchInputRef = useAutofocus<HTMLInputElement>({ + condition: () => isSearchOpen, + delay: 360, + }); + + useRouteChange(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 + ref={searchInputRef} + searchPage={ROUTES.SEARCH} + /> + ), + 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', @@ -311,11 +433,7 @@ export const Layout: FC<LayoutProps> = ({ } url="/" /> - <Toolbar - className={styles.toolbar} - nav={mainNav} - searchPage={ROUTES.SEARCH} - /> + <Navbar items={navbarItems} /> </div> </Header> <Main id="main" className={styles.main}> |
