dropdown_menu.jsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. import PropTypes from 'prop-types';
  2. import { PureComponent, cloneElement, Children } from 'react';
  3. import classNames from 'classnames';
  4. import { withRouter } from 'react-router-dom';
  5. import ImmutablePropTypes from 'react-immutable-proptypes';
  6. import { supportsPassiveEvents } from 'detect-passive-events';
  7. import Overlay from 'react-overlays/Overlay';
  8. import CloseIcon from '@/material-icons/400-24px/close.svg?react';
  9. import { CircularProgress } from 'mastodon/components/circular_progress';
  10. import { WithRouterPropTypes } from 'mastodon/utils/react_router';
  11. import { IconButton } from './icon_button';
  12. const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
  13. let id = 0;
  14. class DropdownMenu extends PureComponent {
  15. static propTypes = {
  16. items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
  17. loading: PropTypes.bool,
  18. scrollable: PropTypes.bool,
  19. onClose: PropTypes.func.isRequired,
  20. style: PropTypes.object,
  21. openedViaKeyboard: PropTypes.bool,
  22. renderItem: PropTypes.func,
  23. renderHeader: PropTypes.func,
  24. onItemClick: PropTypes.func.isRequired,
  25. };
  26. static defaultProps = {
  27. style: {},
  28. };
  29. handleDocumentClick = e => {
  30. if (this.node && !this.node.contains(e.target)) {
  31. this.props.onClose();
  32. e.stopPropagation();
  33. }
  34. };
  35. componentDidMount () {
  36. document.addEventListener('click', this.handleDocumentClick, { capture: true });
  37. document.addEventListener('keydown', this.handleKeyDown, { capture: true });
  38. document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
  39. if (this.focusedItem && this.props.openedViaKeyboard) {
  40. this.focusedItem.focus({ preventScroll: true });
  41. }
  42. }
  43. componentWillUnmount () {
  44. document.removeEventListener('click', this.handleDocumentClick, { capture: true });
  45. document.removeEventListener('keydown', this.handleKeyDown, { capture: true });
  46. document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
  47. }
  48. setRef = c => {
  49. this.node = c;
  50. };
  51. setFocusRef = c => {
  52. this.focusedItem = c;
  53. };
  54. handleKeyDown = e => {
  55. const items = Array.from(this.node.querySelectorAll('a, button'));
  56. const index = items.indexOf(document.activeElement);
  57. let element = null;
  58. switch(e.key) {
  59. case 'ArrowDown':
  60. element = items[index+1] || items[0];
  61. break;
  62. case 'ArrowUp':
  63. element = items[index-1] || items[items.length-1];
  64. break;
  65. case 'Tab':
  66. if (e.shiftKey) {
  67. element = items[index-1] || items[items.length-1];
  68. } else {
  69. element = items[index+1] || items[0];
  70. }
  71. break;
  72. case 'Home':
  73. element = items[0];
  74. break;
  75. case 'End':
  76. element = items[items.length-1];
  77. break;
  78. case 'Escape':
  79. this.props.onClose();
  80. break;
  81. }
  82. if (element) {
  83. element.focus();
  84. e.preventDefault();
  85. e.stopPropagation();
  86. }
  87. };
  88. handleItemKeyPress = e => {
  89. if (e.key === 'Enter' || e.key === ' ') {
  90. this.handleClick(e);
  91. }
  92. };
  93. handleClick = e => {
  94. const { onItemClick } = this.props;
  95. onItemClick(e);
  96. };
  97. renderItem = (option, i) => {
  98. if (option === null) {
  99. return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
  100. }
  101. const { text, href = '#', target = '_blank', method, dangerous } = option;
  102. return (
  103. <li className={classNames('dropdown-menu__item', { 'dropdown-menu__item--dangerous': dangerous })} key={`${text}-${i}`}>
  104. <a href={href} target={target} data-method={method} rel='noopener noreferrer' role='button' tabIndex={0} ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
  105. {text}
  106. </a>
  107. </li>
  108. );
  109. };
  110. render () {
  111. const { items, scrollable, renderHeader, loading } = this.props;
  112. let renderItem = this.props.renderItem || this.renderItem;
  113. return (
  114. <div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })} ref={this.setRef}>
  115. {loading && (
  116. <CircularProgress size={30} strokeWidth={3.5} />
  117. )}
  118. {!loading && renderHeader && (
  119. <div className='dropdown-menu__container__header'>
  120. {renderHeader(items)}
  121. </div>
  122. )}
  123. {!loading && (
  124. <ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}>
  125. {items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
  126. </ul>
  127. )}
  128. </div>
  129. );
  130. }
  131. }
  132. class Dropdown extends PureComponent {
  133. static propTypes = {
  134. children: PropTypes.node,
  135. icon: PropTypes.string,
  136. iconComponent: PropTypes.func,
  137. items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
  138. loading: PropTypes.bool,
  139. size: PropTypes.number,
  140. title: PropTypes.string,
  141. disabled: PropTypes.bool,
  142. scrollable: PropTypes.bool,
  143. status: ImmutablePropTypes.map,
  144. isUserTouching: PropTypes.func,
  145. onOpen: PropTypes.func.isRequired,
  146. onClose: PropTypes.func.isRequired,
  147. openDropdownId: PropTypes.number,
  148. openedViaKeyboard: PropTypes.bool,
  149. renderItem: PropTypes.func,
  150. renderHeader: PropTypes.func,
  151. onItemClick: PropTypes.func,
  152. ...WithRouterPropTypes
  153. };
  154. static defaultProps = {
  155. title: 'Menu',
  156. };
  157. state = {
  158. id: id++,
  159. };
  160. handleClick = ({ type }) => {
  161. if (this.state.id === this.props.openDropdownId) {
  162. this.handleClose();
  163. } else {
  164. this.props.onOpen(this.state.id, this.handleItemClick, type !== 'click');
  165. }
  166. };
  167. handleClose = () => {
  168. if (this.activeElement) {
  169. this.activeElement.focus({ preventScroll: true });
  170. this.activeElement = null;
  171. }
  172. this.props.onClose(this.state.id);
  173. };
  174. handleMouseDown = () => {
  175. if (!this.state.open) {
  176. this.activeElement = document.activeElement;
  177. }
  178. };
  179. handleButtonKeyDown = (e) => {
  180. switch(e.key) {
  181. case ' ':
  182. case 'Enter':
  183. this.handleMouseDown();
  184. break;
  185. }
  186. };
  187. handleKeyPress = (e) => {
  188. switch(e.key) {
  189. case ' ':
  190. case 'Enter':
  191. this.handleClick(e);
  192. e.stopPropagation();
  193. e.preventDefault();
  194. break;
  195. }
  196. };
  197. handleItemClick = e => {
  198. const { onItemClick } = this.props;
  199. const i = Number(e.currentTarget.getAttribute('data-index'));
  200. const item = this.props.items[i];
  201. this.handleClose();
  202. if (typeof onItemClick === 'function') {
  203. e.preventDefault();
  204. onItemClick(item, i);
  205. } else if (item && typeof item.action === 'function') {
  206. e.preventDefault();
  207. item.action();
  208. } else if (item && item.to) {
  209. e.preventDefault();
  210. this.props.history.push(item.to);
  211. }
  212. };
  213. setTargetRef = c => {
  214. this.target = c;
  215. };
  216. findTarget = () => {
  217. return this.target?.buttonRef?.current ?? this.target;
  218. };
  219. componentWillUnmount = () => {
  220. if (this.state.id === this.props.openDropdownId) {
  221. this.handleClose();
  222. }
  223. };
  224. close = () => {
  225. this.handleClose();
  226. };
  227. render () {
  228. const {
  229. icon,
  230. iconComponent,
  231. items,
  232. size,
  233. title,
  234. disabled,
  235. loading,
  236. scrollable,
  237. openDropdownId,
  238. openedViaKeyboard,
  239. children,
  240. renderItem,
  241. renderHeader,
  242. } = this.props;
  243. const open = this.state.id === openDropdownId;
  244. const button = children ? cloneElement(Children.only(children), {
  245. onClick: this.handleClick,
  246. onMouseDown: this.handleMouseDown,
  247. onKeyDown: this.handleButtonKeyDown,
  248. onKeyPress: this.handleKeyPress,
  249. ref: this.setTargetRef,
  250. }) : (
  251. <IconButton
  252. icon={!open ? icon : 'close'}
  253. iconComponent={!open ? iconComponent : CloseIcon}
  254. title={title}
  255. active={open}
  256. disabled={disabled}
  257. size={size}
  258. onClick={this.handleClick}
  259. onMouseDown={this.handleMouseDown}
  260. onKeyDown={this.handleButtonKeyDown}
  261. onKeyPress={this.handleKeyPress}
  262. ref={this.setTargetRef}
  263. />
  264. );
  265. return (
  266. <>
  267. {button}
  268. <Overlay show={open} offset={[5, 5]} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
  269. {({ props, arrowProps, placement }) => (
  270. <div {...props}>
  271. <div className={`dropdown-animation dropdown-menu ${placement}`}>
  272. <div className={`dropdown-menu__arrow ${placement}`} {...arrowProps} />
  273. <DropdownMenu
  274. items={items}
  275. loading={loading}
  276. scrollable={scrollable}
  277. onClose={this.handleClose}
  278. openedViaKeyboard={openedViaKeyboard}
  279. renderItem={renderItem}
  280. renderHeader={renderHeader}
  281. onItemClick={this.handleItemClick}
  282. />
  283. </div>
  284. </div>
  285. )}
  286. </Overlay>
  287. </>
  288. );
  289. }
  290. }
  291. export default withRouter(Dropdown);