aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/organisms
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/components/organisms
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/components/organisms')
-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
39 files changed, 763 insertions, 1259 deletions
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>
- );
-};