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..a45a9e2 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..8d0509a 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'
@@ -15,11 +17,13 @@ export function Menu({ toc, isLoading }: MenuProps): JSX.Element {
return (
)
}
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) => (
+
+ )}
)
})}
diff --git a/src/features/toc/ui/Menu/__snapshots__/Menu.test.tsx.snap b/src/features/toc/ui/Menu/__snapshots__/Menu.test.tsx.snap
index 6976267..dd148e7 100644
--- a/src/features/toc/ui/Menu/__snapshots__/Menu.test.tsx.snap
+++ b/src/features/toc/ui/Menu/__snapshots__/Menu.test.tsx.snap
@@ -4,55 +4,57 @@ exports[`features/toc/ui/Menu > should build a menu and highlight current page 1
`;
@@ -60,99 +62,101 @@ exports[`features/toc/ui/Menu > should build a two-levels menu and open all pare
`;
@@ -160,99 +164,101 @@ exports[`features/toc/ui/Menu > should close a submenu when clicking on a chevro
`;
@@ -260,69 +266,71 @@ exports[`features/toc/ui/Menu > should close a submenu when clicking on a chevro
`;
@@ -330,89 +338,91 @@ exports[`features/toc/ui/Menu > should render skeletons while TOC is loading 1`]
`;