Skip to content

Commit

Permalink
feat: animate menu toggling
Browse files Browse the repository at this point in the history
  • Loading branch information
isqua committed Oct 24, 2023
1 parent 99bd5ef commit 21f30d6
Show file tree
Hide file tree
Showing 5 changed files with 161 additions and 22 deletions.
36 changes: 36 additions & 0 deletions src/features/toc/ui/Menu/Item/Item.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,42 @@
background-color: var(--color-active-secondary-background);
}

.transition-enter {
overflow: hidden;
max-height: 0;
}

.transition-enter .text {
opacity: 0;
}

.transition-enter-active {
max-height: 200px;
transition: max-height 1.5s ease-out;
}

.transition-enter-active .text {
opacity: 1;
transition: opacity 0.3s ease-out;
}

.transition-exit .text {
opacity: 0;
transition: opacity 0.3s ease-out;
}

.transition-exit-active {
overflow: hidden;
/* We have to use important because we set max-height in a style attribute of a node */
max-height: 0 !important;
transition: max-height 0.3s ease-out;
}

.transition-exit-active .text {
opacity: 0;
transition: opacity 0.3s ease-out;
}

.item[aria-level="1"] .text {
margin-inline-start: var(--menu-item-level-padding);
}
Expand Down
97 changes: 81 additions & 16 deletions src/features/toc/ui/Menu/Item/Item.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import clsx from 'clsx'
import { useState, type PropsWithChildren } from 'react'
import { useRef, useState, type PropsWithChildren, RefObject } from 'react'
import { CSSTransition } from 'react-transition-group'

import { Chevron } from '../../../../../components/Chevron'
import { OptionalLink } from '../../../../../components/OptionalLink'
Expand All @@ -12,50 +13,114 @@ import styles from './Item.module.css'
type ItemProps = PropsWithChildren<{
item: MenuItem
onClick?: () => void
isVisible?: boolean
}>

type ItemToggleProps = PropsWithChildren<{
type ItemToggleProps = {
item: MenuItem
children: (isOpen: boolean) => JSX.Element
isVisible?: boolean
}

type ItemTransitionProps = PropsWithChildren<{
isVisible: boolean
nodeRef: RefObject<HTMLElement>
}>

const INDENT_LEVEL_LIMIT = 6

// the height of a one-line menu item
const MIN_ITEM_HEIGHT = 38

const highlightStyles = {
active: styles.active,
parent: styles.parent,
child: styles.child,
}

const transitionClassNames = {
enter: styles['transition-enter'],
enterActive: styles['transition-enter-active'],
exit: styles['transition-exit'],
exitActive: styles['transition-exit-active'],
}

function getItemHighlightStyles(item: MenuItem): string | undefined {
return item.highlight && highlightStyles[item.highlight]
}

function addEndListener(ref: RefObject<HTMLElement>) {
return function (done: () => void) {
const node = ref.current

node?.addEventListener('transitionend', function onTransitionEnd(event) {
if (event.target === node) {
done()
}
})
}
}

function adjustMaximumHeightBeforeTransition(ref: RefObject<HTMLElement>) {
return function() {
if (ref.current) {
const height = ref.current.offsetHeight

if (height > 0) {
// Create transition from actual item height
// if item is outside the viewport, height must be not precise,
// then fallback to minimum item height
ref.current.style.maxHeight = `${Math.max(height, MIN_ITEM_HEIGHT)}px`
}
}
}
}

function ItemTransition({ isVisible, children, nodeRef }: ItemTransitionProps): JSX.Element {
return (
<CSSTransition
mountOnEnter
unmountOnExit
nodeRef={nodeRef}
in={isVisible}
addEndListener={addEndListener(nodeRef)}
onExit={adjustMaximumHeightBeforeTransition(nodeRef)}
classNames={transitionClassNames}
>
{children}
</CSSTransition>
)
}

export function Item(props: ItemProps): JSX.Element {
const { item, children, onClick } = props
const { item, children, onClick, isVisible = true } = props
const isLoading = useIsLoading()
const itemUrl = isLoading ? '' : item.url
const ariaLevel = Math.min(item.level + 1, INDENT_LEVEL_LIMIT)
const itemRef = useRef<HTMLLIElement>(null)

const linkClassName = clsx(
styles.link,
!isLoading && getItemHighlightStyles(item),
)

return (
<li className={styles.item} aria-level={ariaLevel}>
<OptionalLink to={itemUrl} className={linkClassName} onClick={onClick}>
<span className={styles.text}>
{isLoading ?
<Skeleton className={styles.loader} /> :
children
}
</span>
</OptionalLink>
</li>
<ItemTransition nodeRef={itemRef} isVisible={isVisible}>
<li ref={itemRef} className={styles.item} aria-level={ariaLevel}>
<OptionalLink to={itemUrl} className={linkClassName} onClick={onClick}>
<span className={styles.text}>
{isLoading ?
<Skeleton className={styles.loader} /> :
children
}
</span>
</OptionalLink>
</li>
</ItemTransition>
)
}

export function ItemToggle({ item, children }: ItemToggleProps): JSX.Element {
export function ItemToggle({ item, children, isVisible }: ItemToggleProps): JSX.Element {
const isLoading = useIsLoading()
const [ isOpen, setOpen ] = useState(isLoading ? true : item.defaultOpenState)

Expand All @@ -67,11 +132,11 @@ export function ItemToggle({ item, children }: ItemToggleProps): JSX.Element {

return (
<>
<Item item={item} onClick={onLinkClick}>
<Item item={item} onClick={onLinkClick} isVisible={isVisible}>
<Chevron className={styles.toggle} open={isOpen} onClick={onToggle} />
{item.title}
</Item>
{isOpen && children}
{children(Boolean(isOpen && isVisible))}
</>
)
}
25 changes: 24 additions & 1 deletion src/features/toc/ui/Menu/Menu.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,35 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { PropsWithChildren } from 'react'
import { describe, expect, it, vi } from 'vitest'

import { renderInApp } from '../../../../test'
import tocFlat from '../../../../test/fixtures/toc/flat.json'
import tocTwoLevels from '../../../../test/fixtures/toc/two-levels.json'
import type { TableOfContent } from '../../types'
import { Menu } from './Menu'

vi.mock('react-transition-group', () => {
const FakeTransitionGroup = vi.fn(
({ children }: PropsWithChildren) => children
)

const FakeTransition = vi.fn(
({ children }: PropsWithChildren) => children
)

const FakeCSSTransition = vi.fn(
(props: PropsWithChildren<{ in: boolean }>) => props.in ?
<FakeTransition>{props.children}</FakeTransition> :
null,
)

return {
TransitionGroup: FakeTransitionGroup,
CSSTransition: FakeCSSTransition,
Transition: FakeTransition,
}
})

describe('features/toc/ui/Menu', () => {
it('should render skeletons while TOC is loading', async () => {
const toc: TableOfContent = tocTwoLevels
Expand Down
9 changes: 8 additions & 1 deletion src/features/toc/ui/Menu/Menu.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { TransitionGroup } from 'react-transition-group'

import { useCurrentPageUrl } from '../../../../hooks/useCurrentPageUrl'
import type { TableOfContent } from '../../types'
import { MenuProvider } from './Context/MenuProvider'
Expand All @@ -17,7 +19,12 @@ export function Menu({ toc, isLoading }: MenuProps): JSX.Element {
<nav className={styles.menu}>
<ul className={styles.list}>
<MenuProvider toc={toc} url={currentUrl} isLoading={isLoading}>
<Section parentId='' level={0} />
{isLoading && (<Section parentId='' level={0} />)}
{!isLoading && (
<TransitionGroup component={null}>
<Section parentId='' level={0} />
</TransitionGroup>
)}
</MenuProvider>
</ul>
</nav>
Expand Down
16 changes: 12 additions & 4 deletions src/features/toc/ui/Menu/Section/Section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@ type SectionProps = {
parentId: PageId
level: number
highlight?: SectionHighlight
isVisible?: boolean
}

export function Section({ parentId, level, highlight }: SectionProps): JSX.Element {
export function Section({ parentId, level, highlight, isVisible = true }: SectionProps): JSX.Element {
const items = useSectionItems(parentId, level, highlight)

return (
<>
{items.map((item) => {
if (!item.hasChildren) {
return (
<Item key={item.id} item={item}>
<Item key={item.id} item={item} isVisible={isVisible}>
{item.title}
</Item>
)
Expand All @@ -25,8 +26,15 @@ export function Section({ parentId, level, highlight }: SectionProps): JSX.Eleme
const subMenuHighlight = item.highlight === 'active' ? 'child' : item.highlight

return (
<ItemToggle key={item.id} item={item}>
<Section highlight={subMenuHighlight} parentId={item.id} level={level + 1} />
<ItemToggle key={item.id} item={item} isVisible={isVisible}>
{(isOpen: boolean) => (
<Section
isVisible={isOpen}
highlight={subMenuHighlight}
parentId={item.id}
level={level + 1}
/>
)}
</ItemToggle>
)
})}
Expand Down

0 comments on commit 21f30d6

Please sign in to comment.