diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-11-03 12:22:47 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-11 18:15:27 +0100 |
| commit | 5d3e8a4d0c2ce2ad8f22df857ab3ce54fcfc38ac (patch) | |
| tree | a758333b29e2e6614de609acb312ea9ff0d3a33b /src/components/organisms/navbar/navbar-item | |
| parent | 655be4404630a20ae4ca40c4af84afcc2e63557b (diff) | |
refactor(components): replace Toolbar with Navbar component
* remove SearchModal and SettingsModal components
* add a generic NavbarItem component (instead of the previous toolbar
items to avoid unreadable styles...)
* move FlippingLabel component logic into NavbarItem since it is only
used here
Diffstat (limited to 'src/components/organisms/navbar/navbar-item')
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); |
