dropdown_menu.js 9.6 KB

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