Skip to content

Commit

Permalink
refactor: convert options menu list to a function component
Browse files Browse the repository at this point in the history
  • Loading branch information
anastasialanz committed Oct 2, 2024
1 parent 1ea1059 commit fcb43d8
Showing 1 changed file with 90 additions and 128 deletions.
218 changes: 90 additions & 128 deletions packages/react/src/components/OptionsMenu/OptionsMenuList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { OptionsMenuProps } from './OptionsMenu';
import ClickOutsideListener from '../ClickOutsideListener';
import classnames from 'classnames';
Expand All @@ -11,56 +11,38 @@ export interface OptionsMenuListProps
className?: string;
}

interface OptionsMenuListState {
itemIndex: number;
}

export default class OptionsMenuList extends React.Component<
OptionsMenuListProps,
OptionsMenuListState
> {
static defaultProps = {
closeOnSelect: true,
// eslint-disable-next-line @typescript-eslint/no-empty-function
onSelect: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
onClose: () => {}
};

private itemRefs: Array<HTMLLIElement | null>;
private menuRef: HTMLUListElement | null;

constructor(props: OptionsMenuProps) {
super(props);
this.itemRefs = [];
this.state = { itemIndex: 0 };
}

componentDidUpdate(
prevProps: OptionsMenuProps,
prevState: OptionsMenuListState
) {
const { itemIndex } = this.state;
const { show } = this.props;

if (!prevProps.show && show && this.itemRefs.length) {
const OptionsMenuList: React.FC<OptionsMenuListProps> = ({
children,
menuRef,
show,
className,
onClose = () => {}, // eslint-disable-line @typescript-eslint/no-empty-function
onSelect = () => {}, // eslint-disable-line @typescript-eslint/no-empty-function
closeOnSelect = true,
...other
}) => {
const [itemIndex, setItemIndex] = useState(0);
const itemRefs = useRef<Array<HTMLLIElement | null>>([]);
const menuRefInternal = useRef<HTMLUListElement | null>(null);

useEffect(() => {
if (show && itemRefs.current.length) {
// handles opens
this.itemRefs[0]?.focus();
this.setState({ itemIndex: 0 });
} else if (prevState.itemIndex !== itemIndex) {
// handle up/down arrows
this.itemRefs[itemIndex]?.focus();
itemRefs.current[0]?.focus();
setItemIndex(0);
}
}
}, [show]);

useEffect(() => {
itemRefs.current[itemIndex]?.focus();
}, [itemIndex]);

private handleKeyDown = (e: KeyboardEvent) => {
const { onClose = OptionsMenuList.defaultProps.onClose } = this.props;
const handleKeyDown = (e: KeyboardEvent) => {
const { which, target } = e;
switch (which) {
case up:
case down: {
const { itemIndex } = this.state;
const itemCount = this.itemRefs.length;
const itemCount = itemRefs.current.length;
let newIndex = which === 38 ? itemIndex - 1 : itemIndex + 1;

// circularity
Expand All @@ -71,36 +53,39 @@ export default class OptionsMenuList extends React.Component<
}

e.preventDefault();
this.setState({
itemIndex: newIndex
});

setItemIndex(newIndex);
break;
}
case esc:
onClose();

break;
case enter:
case space:
e.preventDefault();
(target as HTMLElement).click();

break;
case tab:
e.preventDefault();
onClose();
}
};

private handleClick = (e: React.MouseEvent<HTMLElement>) => {
const { menuRef, props } = this;
const { onSelect, onClose = OptionsMenuList.defaultProps.onClose } = props;
if (menuRef && menuRef.contains(e.target as HTMLElement)) {
if (!e.defaultPrevented && props.closeOnSelect) {
useEffect(() => {
const currentMenuRef = menuRefInternal.current;
currentMenuRef?.addEventListener('keydown', handleKeyDown);
return () => {
currentMenuRef?.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);

const handleClick = (e: React.MouseEvent<HTMLElement>) => {
if (
menuRefInternal.current &&
menuRefInternal.current.contains(e.target as HTMLElement)
) {
if (!e.defaultPrevented && closeOnSelect) {
onClose();
}

onSelect(e);
}

Expand All @@ -110,82 +95,59 @@ export default class OptionsMenuList extends React.Component<
}
};

private handleClickOutside = () => {
const { onClose = OptionsMenuList.defaultProps.onClose, show } = this.props;
const handleClickOutside = () => {
if (show) {
onClose();
}
};

componentDidMount() {
// see https://github.com/dequelabs/cauldron-react/issues/150
this.menuRef?.addEventListener('keydown', this.handleKeyDown);
}

componentWillUnmount() {
this.menuRef?.removeEventListener('keydown', this.handleKeyDown);
}

render() {
const { props, handleClick } = this;
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
children,
menuRef,
show,
className,
onClose,
onSelect,
closeOnSelect,
...other
} = props;
/* eslint-enable @typescript-eslint/no-unused-vars */

const items = React.Children.toArray(children).map((child, i) => {
const { className, ...other } = (child as React.ReactElement<any>).props;
return React.cloneElement(child as React.ReactElement<any>, {
key: `list-item-${i}`,
className: classnames('OptionsMenu__list-item', className),
tabIndex: -1,
role: 'menuitem',
ref: (el: HTMLLIElement) => (this.itemRefs[i] = el),
...other
});
const items = React.Children.toArray(children).map((child, i) => {
const { className: childClassName, ...childProps } = (
child as React.ReactElement<any>
).props;
return React.cloneElement(child as React.ReactElement<any>, {
key: `list-item-${i}`,
className: classnames('OptionsMenu__list-item', childClassName),
tabIndex: -1,
role: 'menuitem',
ref: (el: HTMLLIElement) => (itemRefs.current[i] = el),
...childProps
});

// This allows the ClickOutsideListener to only be activated when the menu is
// currently open. This prevents an obscure behavior where the activation of a
// different menu would cause all menus to close
const clickOutsideEventActive = !show ? false : undefined;

// Key event is being handled in componentDidMount
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/role-supports-aria-props */
return (
<ClickOutsideListener
onClickOutside={this.handleClickOutside}
mouseEvent={clickOutsideEventActive}
touchEvent={clickOutsideEventActive}
});

// This allows the ClickOutsideListener to only be activated when the menu is
// currently open. This prevents an obscure behavior where the activation of a
// different menu would cause all menus to close
const clickOutsideEventActive = !show ? false : undefined;

// Key event is being handled in the useEffect above
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/role-supports-aria-props */
return (
<ClickOutsideListener
onClickOutside={handleClickOutside}
mouseEvent={clickOutsideEventActive}
touchEvent={clickOutsideEventActive}
>
<ul
{...other}
className={classnames('OptionsMenu__list', className)}
/* aria-expanded is not correct usage here, but the pattern library
currently styles the open state of the menu based on this attribute */
aria-expanded={show}
role="menu"
onClick={handleClick}
ref={(el) => {
menuRefInternal.current = el;
if (menuRef) {
setRef(menuRef, el);
}
}}
>
<ul
{...other}
className={classnames('OptionsMenu__list', className)}
/* aria-expanded is not correct usage here, but the pattern library
currently styles the open state of the menu. based on this attribute */
aria-expanded={show}
role="menu"
onClick={handleClick}
ref={(el) => {
this.menuRef = el;
if (menuRef) {
setRef(menuRef, el);
}
}}
>
{items}
</ul>
</ClickOutsideListener>
);
/* eslint-enable jsx-a11y/click-events-have-key-events */
}
}
{items}
</ul>
</ClickOutsideListener>
);
};

export default OptionsMenuList;

0 comments on commit fcb43d8

Please sign in to comment.