Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 94 additions & 3 deletions src/components/SideMenu/MenuItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -16,17 +17,107 @@ export interface MenuItemsProps {
className?: string;
}

interface MenuItemsState {
animatedMaxHeight?: string;
}

@observer
export class MenuItems extends React.Component<MenuItemsProps> {
export class MenuItems extends React.Component<MenuItemsProps, MenuItemsState> {
private listRef = React.createRef<HTMLUListElement>();
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<HTMLUListElement>) => {
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 (
<MenuItemUl
ref={this.listRef}
className={className}
style={this.props.style}
style={style}
$expanded={expanded}
{...(root ? { role: 'menu' } : {})}
$root={isRoot}
onTransitionEnd={isRoot ? undefined : this.onTransitionEnd}
{...getMenuItemsA11yProps(isRoot, expanded)}
>
{items.map((item, idx) => (
<MenuItem key={idx} item={item} onActivate={this.props.onActivate} />
Expand Down
8 changes: 8 additions & 0 deletions src/components/SideMenu/menuItemsA11y.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as React from 'react';

export function getMenuItemsA11yProps(
isRoot: boolean,
expanded: boolean,
): React.HTMLAttributes<HTMLUListElement> {
return isRoot ? { role: 'menu' } : { 'aria-hidden': !expanded };
}
16 changes: 14 additions & 2 deletions src/components/SideMenu/styled.elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 }>`
Expand Down
17 changes: 17 additions & 0 deletions src/components/__tests__/menuItemsA11y.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});