Compare commits
1 Commits
master
...
djoy/menu_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
155ac821eb |
6
package-lock.json
generated
6
package-lock.json
generated
@@ -6556,9 +6556,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"csstype": {
|
"csstype": {
|
||||||
"version": "2.6.7",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.9.tgz",
|
||||||
"integrity": "sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ=="
|
"integrity": "sha512-xz39Sb4+OaTsULgUERcCk+TJj8ylkL4aSVDQiX/ksxbELSqwkgt4d4RD7fovIdgJGSuNYqwZEiVjYY5l0ask+Q=="
|
||||||
},
|
},
|
||||||
"currently-unhandled": {
|
"currently-unhandled": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"react-redux": "^7.1.3",
|
"react-redux": "^7.1.3",
|
||||||
"react-router": "^5.1.2",
|
"react-router": "^5.1.2",
|
||||||
"react-router-dom": "^5.1.2",
|
"react-router-dom": "^5.1.2",
|
||||||
|
"react-transition-group": "^4.3.0",
|
||||||
"redux": "^4.0.5",
|
"redux": "^4.0.5",
|
||||||
"regenerator-runtime": "^0.13.3"
|
"regenerator-runtime": "^0.13.3"
|
||||||
},
|
},
|
||||||
|
|||||||
246
src/courseware/course/Menu.jsx
Normal file
246
src/courseware/course/Menu.jsx
Normal file
@@ -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 = (<MenuTrigger />).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 (
|
||||||
|
<CSSTransition
|
||||||
|
in={expanded}
|
||||||
|
timeout={transitionTimeout}
|
||||||
|
classNames={transitionClassName}
|
||||||
|
unmountOnExit
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</CSSTransition>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user