From 0ffbb3cdd0f10acc44cde5c9d70173c9e9b2a67b Mon Sep 17 00:00:00 2001 From: illionillion Date: Tue, 10 Mar 2026 00:29:05 +0900 Subject: [PATCH] feat(side-menu): animate nested menu expand/collapse --- src/components/SideMenu/MenuItems.tsx | 97 ++++++++++++++++++- src/components/SideMenu/menuItemsA11y.ts | 8 ++ src/components/SideMenu/styled.elements.ts | 16 ++- .../__tests__/menuItemsA11y.test.ts | 17 ++++ 4 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 src/components/SideMenu/menuItemsA11y.ts create mode 100644 src/components/__tests__/menuItemsA11y.test.ts diff --git a/src/components/SideMenu/MenuItems.tsx b/src/components/SideMenu/MenuItems.tsx index af9ab197a2..57c9cd9996 100644 --- a/src/components/SideMenu/MenuItems.tsx +++ b/src/components/SideMenu/MenuItems.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import type { IMenuItem } from '../../services'; +import { getMenuItemsA11yProps } from './menuItemsA11y'; import { MenuItem } from './MenuItem'; import { MenuItemUl } from './styled.elements'; @@ -16,17 +17,107 @@ export interface MenuItemsProps { className?: string; } +interface MenuItemsState { + animatedMaxHeight?: string; +} + @observer -export class MenuItems extends React.Component { +export class MenuItems extends React.Component { + private listRef = React.createRef(); + private animationFrameId?: number; + + constructor(props: MenuItemsProps) { + super(props); + const isRoot = !!props.root; + const expanded = props.expanded == null ? true : props.expanded; + + this.state = { + animatedMaxHeight: isRoot ? undefined : expanded ? 'none' : '0px', + }; + } + + componentDidUpdate(prevProps: MenuItemsProps) { + if (this.props.root) { + return; + } + + const expanded = this.props.expanded == null ? true : this.props.expanded; + const prevExpanded = prevProps.expanded == null ? true : prevProps.expanded; + + if (expanded === prevExpanded) { + return; + } + + if (expanded) { + this.expandWithAnimation(); + return; + } + + this.collapseWithAnimation(); + } + + componentWillUnmount() { + if (this.animationFrameId !== undefined) { + cancelAnimationFrame(this.animationFrameId); + } + } + + private expandWithAnimation() { + const list = this.listRef.current; + if (!list) { + return; + } + + const expandedHeight = `${list.scrollHeight}px`; + this.setState({ animatedMaxHeight: expandedHeight }); + } + + private collapseWithAnimation() { + const list = this.listRef.current; + if (!list) { + return; + } + + if (this.animationFrameId !== undefined) { + cancelAnimationFrame(this.animationFrameId); + } + + const expandedHeight = `${list.scrollHeight}px`; + this.setState({ animatedMaxHeight: expandedHeight }, () => { + this.animationFrameId = requestAnimationFrame(() => { + this.setState({ animatedMaxHeight: '0px' }); + }); + }); + } + + private onTransitionEnd = (event: React.TransitionEvent) => { + if (event.target !== event.currentTarget || event.propertyName !== 'max-height') { + return; + } + + const expanded = this.props.expanded == null ? true : this.props.expanded; + if (expanded && this.state.animatedMaxHeight !== 'none') { + this.setState({ animatedMaxHeight: 'none' }); + } + }; + render() { const { items, root, className } = this.props; const expanded = this.props.expanded == null ? true : this.props.expanded; + const isRoot = !!root; + const style = isRoot + ? this.props.style + : { ...this.props.style, maxHeight: this.state.animatedMaxHeight }; + return ( {items.map((item, idx) => ( diff --git a/src/components/SideMenu/menuItemsA11y.ts b/src/components/SideMenu/menuItemsA11y.ts new file mode 100644 index 0000000000..921498cd8f --- /dev/null +++ b/src/components/SideMenu/menuItemsA11y.ts @@ -0,0 +1,8 @@ +import * as React from 'react'; + +export function getMenuItemsA11yProps( + isRoot: boolean, + expanded: boolean, +): React.HTMLAttributes { + return isRoot ? { role: 'menu' } : { 'aria-hidden': !expanded }; +} diff --git a/src/components/SideMenu/styled.elements.ts b/src/components/SideMenu/styled.elements.ts index 33de76ab3b..b797e4fe81 100644 --- a/src/components/SideMenu/styled.elements.ts +++ b/src/components/SideMenu/styled.elements.ts @@ -84,7 +84,7 @@ function menuItemActive( } } -export const MenuItemUl = styled.ul<{ $expanded: boolean }>` +export const MenuItemUl = styled.ul<{ $expanded: boolean; $root?: boolean }>` margin: 0; padding: 0; @@ -96,7 +96,19 @@ export const MenuItemUl = styled.ul<{ $expanded: boolean }>` font-size: 0.929em; } - ${props => (props.$expanded ? '' : 'display: none;')}; + ${props => + props.$root + ? '' + : css` + overflow: hidden; + max-height: 0; + pointer-events: ${props.$expanded ? 'auto' : 'none'}; + transition: max-height 0.2s ease-out; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + `}; `; export const MenuItemLi = styled.li<{ depth: number }>` diff --git a/src/components/__tests__/menuItemsA11y.test.ts b/src/components/__tests__/menuItemsA11y.test.ts new file mode 100644 index 0000000000..45a34b7a45 --- /dev/null +++ b/src/components/__tests__/menuItemsA11y.test.ts @@ -0,0 +1,17 @@ +/* tslint:disable:no-implicit-dependencies */ + +import { getMenuItemsA11yProps } from '../SideMenu/menuItemsA11y'; + +describe('getMenuItemsA11yProps', () => { + it('returns menu role for root list', () => { + expect(getMenuItemsA11yProps(true, false)).toEqual({ role: 'menu' }); + }); + + it('hides collapsed nested list from assistive tech', () => { + expect(getMenuItemsA11yProps(false, false)).toEqual({ 'aria-hidden': true }); + }); + + it('keeps expanded nested list visible to assistive tech', () => { + expect(getMenuItemsA11yProps(false, true)).toEqual({ 'aria-hidden': false }); + }); +});