aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-11-03 12:22:47 +0100
committerArmand Philippot <git@armandphilippot.com>2023-11-11 18:15:27 +0100
commit5d3e8a4d0c2ce2ad8f22df857ab3ce54fcfc38ac (patch)
treea758333b29e2e6614de609acb312ea9ff0d3a33b /src
parent655be4404630a20ae4ca40c4af84afcc2e63557b (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')
-rw-r--r--src/components/atoms/forms/fields/boolean-field/boolean-field.tsx43
-rw-r--r--src/components/atoms/forms/fields/checkbox/checkbox.tsx15
-rw-r--r--src/components/atoms/forms/label/label.tsx49
-rw-r--r--src/components/molecules/forms/flipping-label/flipping-label.module.scss17
-rw-r--r--src/components/molecules/forms/flipping-label/flipping-label.stories.tsx98
-rw-r--r--src/components/molecules/forms/flipping-label/flipping-label.test.tsx18
-rw-r--r--src/components/molecules/forms/flipping-label/flipping-label.tsx54
-rw-r--r--src/components/molecules/forms/flipping-label/index.ts1
-rw-r--r--src/components/molecules/forms/index.ts1
-rw-r--r--src/components/organisms/forms/search-form/search-form.tsx6
-rw-r--r--src/components/organisms/index.ts3
-rw-r--r--src/components/organisms/modals/index.ts2
-rw-r--r--src/components/organisms/modals/search-modal.module.scss11
-rw-r--r--src/components/organisms/modals/search-modal.stories.tsx47
-rw-r--r--src/components/organisms/modals/search-modal.test.tsx10
-rw-r--r--src/components/organisms/modals/search-modal.tsx45
-rw-r--r--src/components/organisms/modals/settings-modal.module.scss19
-rw-r--r--src/components/organisms/modals/settings-modal.stories.tsx51
-rw-r--r--src/components/organisms/modals/settings-modal.test.tsx29
-rw-r--r--src/components/organisms/modals/settings-modal.tsx45
-rw-r--r--src/components/organisms/navbar/index.ts1
-rw-r--r--src/components/organisms/navbar/navbar-item/index.ts1
-rw-r--r--src/components/organisms/navbar/navbar-item/navbar-item.module.scss173
-rw-r--r--src/components/organisms/navbar/navbar-item/navbar-item.stories.tsx55
-rw-r--r--src/components/organisms/navbar/navbar-item/navbar-item.test.tsx79
-rw-r--r--src/components/organisms/navbar/navbar-item/navbar-item.tsx179
-rw-r--r--src/components/organisms/navbar/navbar.module.scss53
-rw-r--r--src/components/organisms/navbar/navbar.stories.tsx110
-rw-r--r--src/components/organisms/navbar/navbar.test.tsx42
-rw-r--r--src/components/organisms/navbar/navbar.tsx65
-rw-r--r--src/components/organisms/toolbar/index.ts1
-rw-r--r--src/components/organisms/toolbar/main-nav.module.scss86
-rw-r--r--src/components/organisms/toolbar/main-nav.stories.tsx91
-rw-r--r--src/components/organisms/toolbar/main-nav.test.tsx45
-rw-r--r--src/components/organisms/toolbar/main-nav.tsx75
-rw-r--r--src/components/organisms/toolbar/search.module.scss11
-rw-r--r--src/components/organisms/toolbar/search.stories.tsx88
-rw-r--r--src/components/organisms/toolbar/search.test.tsx15
-rw-r--r--src/components/organisms/toolbar/search.tsx77
-rw-r--r--src/components/organisms/toolbar/settings.module.scss9
-rw-r--r--src/components/organisms/toolbar/settings.stories.tsx88
-rw-r--r--src/components/organisms/toolbar/settings.test.tsx23
-rw-r--r--src/components/organisms/toolbar/settings.tsx62
-rw-r--r--src/components/organisms/toolbar/toolbar-items.module.scss91
-rw-r--r--src/components/organisms/toolbar/toolbar.module.scss63
-rw-r--r--src/components/organisms/toolbar/toolbar.stories.tsx68
-rw-r--r--src/components/organisms/toolbar/toolbar.test.tsx17
-rw-r--r--src/components/organisms/toolbar/toolbar.tsx86
-rw-r--r--src/components/templates/layout/layout.module.scss27
-rw-r--r--src/components/templates/layout/layout.tsx130
-rw-r--r--src/i18n/en.json54
-rw-r--r--src/i18n/fr.json54
52 files changed, 1005 insertions, 1578 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}>
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 5984b2d..4b7e756 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -15,10 +15,6 @@
"defaultMessage": "Comments",
"description": "PageLayout: comments title"
},
- "+viX9b": {
- "defaultMessage": "Close settings",
- "description": "Settings: Close label"
- },
"/42Z0z": {
"defaultMessage": "Related topics",
"description": "ThematicPage: related topics list widget title"
@@ -231,13 +227,9 @@
"defaultMessage": "{title} cover",
"description": "ProjectsPage: figure (cover) accessible name"
},
- "G+Twgm": {
- "defaultMessage": "Search",
- "description": "SearchModal: modal title"
- },
- "GTbGMy": {
+ "Fgt/RZ": {
"defaultMessage": "Open menu",
- "description": "MainNav: Open label"
+ "description": "Layout: main nav button label in navbar"
},
"GVpTIl": {
"defaultMessage": "Topics",
@@ -307,10 +299,6 @@
"defaultMessage": "Cancel reply",
"description": "Comment: cancel reply button"
},
- "LDDUNO": {
- "defaultMessage": "Close search",
- "description": "Search: Close label"
- },
"LszkU6": {
"defaultMessage": "All posts in {thematicName}",
"description": "ThematicPage: posts list heading"
@@ -319,6 +307,10 @@
"defaultMessage": "Written by:",
"description": "ArticlePage: author label"
},
+ "Mq+O6q": {
+ "defaultMessage": "Search",
+ "description": "Layout: search modal title in navbar"
+ },
"N44SOc": {
"defaultMessage": "Projects",
"description": "HomePage: link to projects"
@@ -359,10 +351,6 @@
"defaultMessage": "LinkedIn profile",
"description": "ContactPage: LinkedIn profile link"
},
- "QCW3cy": {
- "defaultMessage": "Open settings",
- "description": "Settings: Open label"
- },
"QLisK6": {
"defaultMessage": "Dark Theme 🌙",
"description": "usePrism: toggle dark theme button text"
@@ -451,9 +439,9 @@
"defaultMessage": "You can also try a search:",
"description": "Error404Page: try a search message"
},
- "Xj+WXB": {
+ "XRwEoA": {
"defaultMessage": "Open search",
- "description": "Search: Open label"
+ "description": "Layout: search button label in navbar"
},
"Y+DYja": {
"defaultMessage": "Share on LinkedIn",
@@ -483,10 +471,6 @@
"defaultMessage": "Read more<a11y> about {title}</a11y>",
"description": "Summary: read more link"
},
- "aJC7D2": {
- "defaultMessage": "Close menu",
- "description": "MainNav: Close label"
- },
"azgQuH": {
"defaultMessage": "You should read {title}",
"description": "Sharing: subject text"
@@ -507,6 +491,10 @@
"defaultMessage": "Sidebar",
"description": "PageLayout: accessible name for the sidebar"
},
+ "dfTljv": {
+ "defaultMessage": "Main navigation",
+ "description": "Layout: main nav accessible name"
+ },
"dz2kDV": {
"defaultMessage": "Comment form",
"description": "CommentForm: aria label"
@@ -535,9 +523,9 @@
"defaultMessage": "It has been approved.",
"description": "PageLayout: comment approved."
},
- "gPfT/K": {
- "defaultMessage": "Settings",
- "description": "SettingsModal: title"
+ "h3J0a+": {
+ "defaultMessage": "Settings form",
+ "description": "Layout: an accessible name for the settings form in navbar"
},
"hHVgW3": {
"defaultMessage": "Light Theme 🌞",
@@ -595,6 +583,10 @@
"defaultMessage": "Breadcrumb",
"description": "PageLayout: an accessible name for the breadcrumb nav."
},
+ "mDKiaN": {
+ "defaultMessage": "Open settings",
+ "description": "Layout: settings button label in navbar"
+ },
"nGss/j": {
"defaultMessage": "Ackee tracking (analytics)",
"description": "AckeeToggle: tooltip title"
@@ -619,6 +611,10 @@
"defaultMessage": "Share on Facebook",
"description": "Sharing: Facebook sharing link"
},
+ "o3WSz5": {
+ "defaultMessage": "Settings",
+ "description": "Layout: settings modal title in navbar"
+ },
"oVLRW8": {
"defaultMessage": "Share on Diaspora",
"description": "Sharing: Diaspora sharing link"
@@ -743,10 +739,6 @@
"defaultMessage": "Created on:",
"description": "ProjectsPage: creation date label"
},
- "xYNeKX": {
- "defaultMessage": "Settings form",
- "description": "SettingsModal: an accessible form name"
- },
"xYemkP": {
"defaultMessage": "Loading more articles...",
"description": "PostsList: loading more articles message"
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 2b20b14..0934548 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -15,10 +15,6 @@
"defaultMessage": "Commentaires",
"description": "PageLayout: comments title"
},
- "+viX9b": {
- "defaultMessage": "Fermer les réglages",
- "description": "Settings: Close label"
- },
"/42Z0z": {
"defaultMessage": "Sujets liés",
"description": "ThematicPage: related topics list widget title"
@@ -231,13 +227,9 @@
"defaultMessage": "Illustration de {title}",
"description": "ProjectsPage: figure (cover) accessible name"
},
- "G+Twgm": {
- "defaultMessage": "Recherche",
- "description": "SearchModal: modal title"
- },
- "GTbGMy": {
+ "Fgt/RZ": {
"defaultMessage": "Ouvrir le menu",
- "description": "MainNav: Open label"
+ "description": "Layout: main nav button label in navbar"
},
"GVpTIl": {
"defaultMessage": "Sujets",
@@ -307,10 +299,6 @@
"defaultMessage": "Annuler la réponse",
"description": "Comment: cancel reply button"
},
- "LDDUNO": {
- "defaultMessage": "Fermer la recherche",
- "description": "Search: Close label"
- },
"LszkU6": {
"defaultMessage": "Tous les articles dans {thematicName}",
"description": "ThematicPage: posts list heading"
@@ -319,6 +307,10 @@
"defaultMessage": "Écrit par :",
"description": "ArticlePage: author label"
},
+ "Mq+O6q": {
+ "defaultMessage": "Recherche",
+ "description": "Layout: search modal title in navbar"
+ },
"N44SOc": {
"defaultMessage": "Projets",
"description": "HomePage: link to projects"
@@ -359,10 +351,6 @@
"defaultMessage": "Profil LinkedIn",
"description": "ContactPage: LinkedIn profile link"
},
- "QCW3cy": {
- "defaultMessage": "Ouvrir les réglages",
- "description": "Settings: Open label"
- },
"QLisK6": {
"defaultMessage": "Thème sombre 🌙",
"description": "usePrism: toggle dark theme button text"
@@ -451,9 +439,9 @@
"defaultMessage": "Vous pouvez également tenter une recherche :",
"description": "Error404Page: try a search message"
},
- "Xj+WXB": {
+ "XRwEoA": {
"defaultMessage": "Ouvrir la recherche",
- "description": "Search: Open label"
+ "description": "Layout: search button label in navbar"
},
"Y+DYja": {
"defaultMessage": "Partager sur LinkedIn",
@@ -483,10 +471,6 @@
"defaultMessage": "En lire plus<a11y> à propos de {title}</a11y>",
"description": "Summary: read more link"
},
- "aJC7D2": {
- "defaultMessage": "Fermer le menu",
- "description": "MainNav: Close label"
- },
"azgQuH": {
"defaultMessage": "Vous devriez lire {title}",
"description": "Sharing: subject text"
@@ -507,6 +491,10 @@
"defaultMessage": "Barre latérale",
"description": "PageLayout: accessible name for the sidebar"
},
+ "dfTljv": {
+ "defaultMessage": "Navigation principale",
+ "description": "Layout: main nav accessible name"
+ },
"dz2kDV": {
"defaultMessage": "Formulaire des commentaires",
"description": "CommentForm: aria label"
@@ -535,9 +523,9 @@
"defaultMessage": "Il a été approuvé.",
"description": "PageLayout: comment approved."
},
- "gPfT/K": {
- "defaultMessage": "Réglages",
- "description": "SettingsModal: title"
+ "h3J0a+": {
+ "defaultMessage": "Formulaire des réglages",
+ "description": "Layout: an accessible name for the settings form in navbar"
},
"hHVgW3": {
"defaultMessage": "Thème clair 🌞",
@@ -595,6 +583,10 @@
"defaultMessage": "Fil d’Ariane",
"description": "PageLayout: an accessible name for the breadcrumb nav."
},
+ "mDKiaN": {
+ "defaultMessage": "Ouvrir les réglages",
+ "description": "Layout: settings button label in navbar"
+ },
"nGss/j": {
"defaultMessage": "Suivi Ackee (analytique)",
"description": "AckeeToggle: tooltip title"
@@ -619,6 +611,10 @@
"defaultMessage": "Partager sur Facebook",
"description": "Sharing: Facebook sharing link"
},
+ "o3WSz5": {
+ "defaultMessage": "Réglages",
+ "description": "Layout: settings modal title in navbar"
+ },
"oVLRW8": {
"defaultMessage": "Partager sur Diaspora",
"description": "Sharing: Diaspora sharing link"
@@ -743,10 +739,6 @@
"defaultMessage": "Créé le :",
"description": "ProjectsPage: creation date label"
},
- "xYNeKX": {
- "defaultMessage": "Formulaire des réglages",
- "description": "SettingsModal: an accessible form name"
- },
"xYemkP": {
"defaultMessage": "Chargement des articles précédents…",
"description": "PostsList: loading more articles message"