From 4ecc209c81b92b0b336c2f387f5ba25b88e51f40 Mon Sep 17 00:00:00 2001 From: isqua Date: Tue, 24 Oct 2023 17:25:52 +0300 Subject: [PATCH] feat: animate menu toggling --- src/features/toc/ui/Menu/Item/Item.module.css | 36 +++++++++ src/features/toc/ui/Menu/Item/Item.tsx | 81 +++++++++++++++---- src/features/toc/ui/Menu/Menu.test.tsx | 25 +++++- src/features/toc/ui/Menu/Menu.tsx | 6 +- src/features/toc/ui/Menu/Section/Section.tsx | 16 +++- 5 files changed, 142 insertions(+), 22 deletions(-) diff --git a/src/features/toc/ui/Menu/Item/Item.module.css b/src/features/toc/ui/Menu/Item/Item.module.css index 30e6546..d5f56e9 100644 --- a/src/features/toc/ui/Menu/Item/Item.module.css +++ b/src/features/toc/ui/Menu/Item/Item.module.css @@ -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-in; +} + +.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-in; +} + +.transition-exit-active .text { + opacity: 0; + transition: opacity 0.3s ease-in; +} + .item[aria-level="1"] .text { margin-inline-start: var(--menu-item-level-padding); } diff --git a/src/features/toc/ui/Menu/Item/Item.tsx b/src/features/toc/ui/Menu/Item/Item.tsx index fdc4c15..12346e9 100644 --- a/src/features/toc/ui/Menu/Item/Item.tsx +++ b/src/features/toc/ui/Menu/Item/Item.tsx @@ -1,5 +1,6 @@ import clsx from 'clsx' import { useState, type PropsWithChildren } from 'react' +import { CSSTransition } from 'react-transition-group' import { Chevron } from '../../../../../components/Chevron' import { OptionalLink } from '../../../../../components/OptionalLink' @@ -12,26 +13,72 @@ 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 +} 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(node: HTMLElement, done: () => void) { + node.addEventListener('transitionend', function onTransitionEnd(event) { + if (event.target === node) { + done() + } + }) +} + +function adjustMaximumHeightBeforeTransition(node: HTMLElement) { + const height = node.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 + node.style.maxHeight = `${Math.max(height, MIN_ITEM_HEIGHT)}px` + } +} + +function ItemTransition({ isVisible, children }: PropsWithChildren<{isVisible: boolean}>) { + return ( + + {children} + + ) +} + 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) @@ -42,20 +89,22 @@ export function Item(props: ItemProps): JSX.Element { ) return ( -
  • - - - {isLoading ? - : - children - } - - -
  • + +
  • + + + {isLoading ? + : + children + } + + +
  • +
    ) } -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) @@ -67,11 +116,11 @@ export function ItemToggle({ item, children }: ItemToggleProps): JSX.Element { return ( <> - + {item.title} - {isOpen && children} + {children(Boolean(isOpen && isVisible))} ) } diff --git a/src/features/toc/ui/Menu/Menu.test.tsx b/src/features/toc/ui/Menu/Menu.test.tsx index 31ef32a..9c2114c 100644 --- a/src/features/toc/ui/Menu/Menu.test.tsx +++ b/src/features/toc/ui/Menu/Menu.test.tsx @@ -1,5 +1,6 @@ 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' @@ -7,6 +8,28 @@ 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 ? + {props.children} : + 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 diff --git a/src/features/toc/ui/Menu/Menu.tsx b/src/features/toc/ui/Menu/Menu.tsx index 254bfca..4e23485 100644 --- a/src/features/toc/ui/Menu/Menu.tsx +++ b/src/features/toc/ui/Menu/Menu.tsx @@ -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' @@ -17,7 +19,9 @@ export function Menu({ toc, isLoading }: MenuProps): JSX.Element { diff --git a/src/features/toc/ui/Menu/Section/Section.tsx b/src/features/toc/ui/Menu/Section/Section.tsx index 17a0e0b..350ec1c 100644 --- a/src/features/toc/ui/Menu/Section/Section.tsx +++ b/src/features/toc/ui/Menu/Section/Section.tsx @@ -6,9 +6,10 @@ 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 ( @@ -16,7 +17,7 @@ export function Section({ parentId, level, highlight }: SectionProps): JSX.Eleme {items.map((item) => { if (!item.hasChildren) { return ( - + {item.title} ) @@ -25,8 +26,15 @@ export function Section({ parentId, level, highlight }: SectionProps): JSX.Eleme const subMenuHighlight = item.highlight === 'active' ? 'child' : item.highlight return ( - -
    + + {(isOpen: boolean) => ( +
    + )} ) })}