aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/organisms/navbar/navbar-item
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/navbar/navbar-item
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/navbar/navbar-item')
-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
5 files changed, 487 insertions, 0 deletions
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);