diff --git a/package-lock.json b/package-lock.json
index acb745fa..1acbb615 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6556,9 +6556,9 @@
}
},
"csstype": {
- "version": "2.6.7",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.7.tgz",
- "integrity": "sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ=="
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.9.tgz",
+ "integrity": "sha512-xz39Sb4+OaTsULgUERcCk+TJj8ylkL4aSVDQiX/ksxbELSqwkgt4d4RD7fovIdgJGSuNYqwZEiVjYY5l0ask+Q=="
},
"currently-unhandled": {
"version": "0.4.1",
diff --git a/package.json b/package.json
index 723ddb64..e6692b72 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,7 @@
"react-redux": "^7.1.3",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
+ "react-transition-group": "^4.3.0",
"redux": "^4.0.5",
"regenerator-runtime": "^0.13.3"
},
diff --git a/src/courseware/course/Menu.jsx b/src/courseware/course/Menu.jsx
new file mode 100644
index 00000000..2e737cc9
--- /dev/null
+++ b/src/courseware/course/Menu.jsx
@@ -0,0 +1,246 @@
+import React, {
+ useState, useCallback, useRef, useEffect,
+} from 'react';
+import { CSSTransition } from 'react-transition-group';
+import PropTypes from 'prop-types';
+
+export function MenuTrigger({ tag, className, ...attributes }) {
+ return React.createElement(tag, {
+ className: `menu-trigger ${className}`,
+ ...attributes,
+ });
+}
+MenuTrigger.propTypes = {
+ tag: PropTypes.string,
+ className: PropTypes.string,
+};
+MenuTrigger.defaultProps = {
+ tag: 'div',
+ className: null,
+};
+const MenuTriggerType = ().type;
+
+export function MenuContent({ tag, className, ...attributes }) {
+ return React.createElement(tag, {
+ className: ['menu-content', className].join(' '),
+ ...attributes,
+ });
+}
+MenuContent.propTypes = {
+ tag: PropTypes.string,
+ className: PropTypes.string,
+};
+MenuContent.defaultProps = {
+ tag: 'div',
+ className: null,
+};
+
+export function Menu({
+ className,
+ children,
+ tag,
+ transitionTimeout,
+ transitionClassName,
+ respondToPointerEvents,
+ onOpen,
+ onClose,
+ closeOnDocumentClick,
+ ...attributes
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const menu = useRef(null);
+
+ const open = useCallback(() => {
+ if (onOpen) {
+ onOpen();
+ }
+ setExpanded(true);
+ }, [onOpen]);
+
+ const close = useCallback(() => {
+ if (onClose) {
+ onClose();
+ }
+ setExpanded(false);
+ }, [onClose]);
+
+ const toggle = useCallback(() => {
+ if (expanded) {
+ close();
+ } else {
+ open();
+ }
+ }, [expanded]);
+
+ const onDocumentClick = useCallback((e) => {
+ if (!closeOnDocumentClick) {
+ return;
+ }
+
+ const clickIsInMenu = menu.current === e.target || menu.current.contains(e.target);
+ if (clickIsInMenu) {
+ return;
+ }
+
+ close();
+ }, [closeOnDocumentClick]);
+
+ useEffect(() => {
+ if (expanded) {
+ // Listen to touchend and click events to ensure the menu
+ // can be closed on mobile, pointer, and mixed input devices
+ document.addEventListener('touchend', onDocumentClick, true);
+ document.addEventListener('click', onDocumentClick, true);
+ } else {
+ document.removeEventListener('touchend', onDocumentClick, true);
+ document.removeEventListener('click', onDocumentClick, true);
+ }
+ return () => {
+ document.removeEventListener('touchend', onDocumentClick, true);
+ document.removeEventListener('click', onDocumentClick, true);
+ };
+ }, [expanded]);
+
+ const onTriggerClick = useCallback((e) => {
+ // Let the browser follow the link of the trigger if the menu
+ // is already expanded and the trigger has an href attribute
+ if (expanded && e.target.getAttribute('href')) {
+ return;
+ }
+
+ e.preventDefault();
+ toggle();
+ }, []);
+
+ const onMouseEnter = useCallback(() => {
+ if (!respondToPointerEvents) { return; }
+ open();
+ }, [respondToPointerEvents]);
+
+ const onMouseLeave = useCallback(() => {
+ if (!respondToPointerEvents) { return; }
+ close();
+ }, [respondToPointerEvents]);
+
+ const getFocusableElements = useCallback(() => menu.current.querySelectorAll('button:not([disabled]), [href]:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])'), []);
+
+ const focusNext = useCallback(() => {
+ const focusableElements = Array.from(getFocusableElements());
+ const activeIndex = focusableElements.indexOf(document.activeElement);
+ const nextIndex = (activeIndex + 1) % focusableElements.length;
+ focusableElements[nextIndex].focus();
+ }, []);
+
+ const focusPrevious = useCallback(() => {
+ const focusableElements = Array.from(getFocusableElements());
+ const activeIndex = focusableElements.indexOf(document.activeElement);
+ const previousIndex = (activeIndex || focusableElements.length) - 1;
+ focusableElements[previousIndex].focus();
+ }, []);
+
+ const onKeyDown = useCallback((e) => {
+ if (!expanded) {
+ return;
+ }
+ switch (e.key) {
+ case 'Escape': {
+ e.preventDefault();
+ e.stopPropagation();
+ getFocusableElements()[0].focus();
+ close();
+ break;
+ }
+ case 'Enter': {
+ // Using focusable elements instead of a ref to the trigger
+ // because Hyperlink and Button can handle refs as functional components
+ if (document.activeElement === getFocusableElements()[0]) {
+ e.preventDefault();
+ toggle();
+ }
+ break;
+ }
+ case 'Tab': {
+ e.preventDefault();
+ if (e.shiftKey) {
+ focusPrevious();
+ } else {
+ focusNext();
+ }
+ break;
+ }
+ case 'ArrowDown': {
+ e.preventDefault();
+ focusNext();
+ break;
+ }
+ case 'ArrowUp': {
+ e.preventDefault();
+ focusPrevious();
+ break;
+ }
+ default:
+ }
+ }, [expanded]);
+
+ useEffect(() => () => {
+ // Call onClose callback when unmounting and open
+ if (expanded && onClose) {
+ onClose();
+ }
+ }, []);
+
+ const wrappedChildren = React.Children.map(children, (child) => {
+ if (child.type === MenuTriggerType) {
+ return React.cloneElement(child, {
+ onClick: onTriggerClick,
+ 'aria-haspopup': 'menu',
+ 'aria-expanded': expanded,
+ });
+ }
+ return (
+
+ {child}
+
+ );
+ });
+
+ const rootClassName = expanded ? 'menu expanded' : 'menu';
+
+ return React.createElement(tag, {
+ className: `${rootClassName} ${className}`,
+ ref: menu,
+ onKeyDown,
+ onMouseEnter,
+ onMouseLeave,
+ ...attributes,
+ }, wrappedChildren);
+}
+
+
+Menu.propTypes = {
+ tag: PropTypes.string,
+ onClose: PropTypes.func,
+ onOpen: PropTypes.func,
+ closeOnDocumentClick: PropTypes.bool,
+ respondToPointerEvents: PropTypes.bool,
+ className: PropTypes.string,
+ transitionTimeout: PropTypes.number,
+ transitionClassName: PropTypes.string,
+ children: PropTypes.arrayOf(PropTypes.node).isRequired,
+};
+
+Menu.defaultProps = {
+ tag: 'div',
+ className: null,
+ onClose: null,
+ onOpen: null,
+ respondToPointerEvents: false,
+ closeOnDocumentClick: true,
+ transitionTimeout: 250,
+ transitionClassName: 'menu-content',
+};