PopupMenuComponent.js 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. import {
  2. useEffect,
  3. useRef,
  4. useState,
  5. useLayoutEffect,
  6. html,
  7. useMemo,
  8. useCallback
  9. } from '../../ui';
  10. import {
  11. closest as domClosest,
  12. matches as domMatches
  13. } from 'min-dom';
  14. import PopupMenuList from './PopupMenuList';
  15. import classNames from 'clsx';
  16. import { isDefined } from 'min-dash';
  17. /**
  18. * A component that renders the popup menus.
  19. *
  20. * @param {function} onClose
  21. * @param {function} position
  22. * @param {string} className
  23. * @param {Array} entries
  24. * @param {Array} headerEntries
  25. * @param {number} scale
  26. * @param {string} [title]
  27. * @param {boolean} [search]
  28. * @param {number} [width]
  29. */
  30. export default function PopupMenuComponent(props) {
  31. const {
  32. onClose,
  33. onSelect,
  34. className,
  35. headerEntries,
  36. position,
  37. title,
  38. width,
  39. scale,
  40. search,
  41. entries: originalEntries,
  42. onOpened,
  43. onClosed
  44. } = props;
  45. const searchable = useMemo(() => {
  46. if (!isDefined(search)) {
  47. return false;
  48. }
  49. return originalEntries.length > 5;
  50. }, [ search, originalEntries ]);
  51. const inputRef = useRef();
  52. const [ value, setValue ] = useState('');
  53. const filterEntries = useCallback((originalEntries, value) => {
  54. if (!searchable) {
  55. return originalEntries;
  56. }
  57. const filter = entry => {
  58. if (!value) {
  59. return (entry.rank || 0) >= 0;
  60. }
  61. const search = [
  62. entry.description || '',
  63. entry.label || '',
  64. entry.search || ''
  65. ]
  66. .join('---')
  67. .toLowerCase();
  68. return value
  69. .toLowerCase()
  70. .split(/\s/g)
  71. .every(term => search.includes(term));
  72. };
  73. return originalEntries.filter(filter);
  74. }, [ searchable ]);
  75. const [ entries, setEntries ] = useState(filterEntries(originalEntries, value));
  76. const [ selectedEntry, setSelectedEntry ] = useState(entries[0]);
  77. const updateEntries = useCallback((newEntries) => {
  78. // select first entry if non is selected
  79. if (!selectedEntry || !newEntries.includes(selectedEntry)) {
  80. setSelectedEntry(newEntries[0]);
  81. }
  82. setEntries(newEntries);
  83. }, [ selectedEntry, setEntries, setSelectedEntry ]);
  84. // filter entries on value change
  85. useEffect(() => {
  86. updateEntries(filterEntries(originalEntries, value));
  87. }, [ value, originalEntries ]);
  88. // register global <Escape> handler
  89. useEffect(() => {
  90. const handleKeyDown = event => {
  91. if (event.key === 'Escape') {
  92. event.preventDefault();
  93. return onClose();
  94. }
  95. };
  96. document.documentElement.addEventListener('keydown', handleKeyDown);
  97. return () => {
  98. document.documentElement.removeEventListener('keydown', handleKeyDown);
  99. };
  100. }, []);
  101. // focus input on initial mount
  102. useLayoutEffect(() => {
  103. inputRef.current && inputRef.current.focus();
  104. }, []);
  105. // handle keyboard seleciton
  106. const keyboardSelect = useCallback(direction => {
  107. const idx = entries.indexOf(selectedEntry);
  108. let nextIdx = idx + direction;
  109. if (nextIdx < 0) {
  110. nextIdx = entries.length - 1;
  111. }
  112. if (nextIdx >= entries.length) {
  113. nextIdx = 0;
  114. }
  115. setSelectedEntry(entries[nextIdx]);
  116. }, [ entries, selectedEntry, setSelectedEntry ]);
  117. const handleKeyDown = useCallback(event => {
  118. if (event.key === 'Enter' && selectedEntry) {
  119. return onSelect(event, selectedEntry);
  120. }
  121. // ARROW_UP or SHIFT + TAB navigation
  122. if (event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey)) {
  123. keyboardSelect(-1);
  124. return event.preventDefault();
  125. }
  126. // ARROW_DOWN or TAB navigation
  127. if (event.key === 'ArrowDown' || event.key === 'Tab') {
  128. keyboardSelect(1);
  129. return event.preventDefault();
  130. }
  131. }, [ onSelect, onClose, selectedEntry, keyboardSelect ]);
  132. const handleKey = useCallback(event => {
  133. if (domMatches(event.target, 'input')) {
  134. setValue(() => event.target.value);
  135. }
  136. }, [ setValue ]);
  137. useEffect(() => {
  138. onOpened();
  139. return () => {
  140. onClosed();
  141. };
  142. }, []);
  143. const displayHeader = useMemo(() => title || headerEntries.length > 0, [ title, headerEntries ]);
  144. return html`
  145. <${PopupMenuWrapper}
  146. onClose=${ onClose }
  147. onKeyup=${ handleKey }
  148. onKeydown=${ handleKeyDown }
  149. className=${ className }
  150. position=${position}
  151. width=${ width }
  152. scale=${ scale }
  153. >
  154. ${ displayHeader && html`
  155. <div class="djs-popup-header">
  156. <h3 class="djs-popup-title" title=${ title }>${ title }</h3>
  157. ${ headerEntries.map(entry => html`
  158. <span
  159. class=${ getHeaderClasses(entry, entry === selectedEntry) }
  160. onClick=${ event => onSelect(event, entry) }
  161. title=${ entry.title || entry.label }
  162. data-id=${ entry.id }
  163. onMouseEnter=${ () => setSelectedEntry(entry) }
  164. onMouseLeave=${ () => setSelectedEntry(null) }
  165. >
  166. ${ entry.imageUrl ? html`
  167. <img class="djs-popup-entry-icon" src=${ entry.imageUrl } alt="" />
  168. ` : null }
  169. ${ entry.label ? html`
  170. <span class="djs-popup-label">${ entry.label }</span>
  171. ` : null }
  172. </span>
  173. `) }
  174. </div>
  175. ` }
  176. ${ originalEntries.length > 0 && html`
  177. <div class="djs-popup-body">
  178. ${ searchable && html`
  179. <div class="djs-popup-search">
  180. <svg class="djs-popup-search-icon" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
  181. <path fill-rule="evenodd" clip-rule="evenodd" d="M9.0325 8.5H9.625L13.3675 12.25L12.25 13.3675L8.5 9.625V9.0325L8.2975 8.8225C7.4425 9.5575 6.3325 10 5.125 10C2.4325 10 0.25 7.8175 0.25 5.125C0.25 2.4325 2.4325 0.25 5.125 0.25C7.8175 0.25 10 2.4325 10 5.125C10 6.3325 9.5575 7.4425 8.8225 8.2975L9.0325 8.5ZM1.75 5.125C1.75 6.9925 3.2575 8.5 5.125 8.5C6.9925 8.5 8.5 6.9925 8.5 5.125C8.5 3.2575 6.9925 1.75 5.125 1.75C3.2575 1.75 1.75 3.2575 1.75 5.125Z" fill="#22242A"/>
  182. </svg>
  183. <input
  184. ref=${ inputRef }
  185. type="text"
  186. />
  187. </div>
  188. ` }
  189. <${PopupMenuList}
  190. entries=${ entries }
  191. selectedEntry=${ selectedEntry }
  192. setSelectedEntry=${ setSelectedEntry }
  193. onAction=${ onSelect }
  194. />
  195. </div>
  196. ${ entries.length === 0 && html`
  197. <div class="djs-popup-no-results">No matching entries found.</div>
  198. ` }
  199. ` }
  200. </${PopupMenuWrapper}>
  201. `;
  202. }
  203. /**
  204. * A component that wraps the popup menu.
  205. *
  206. * @param {any} props
  207. */
  208. function PopupMenuWrapper(props) {
  209. const {
  210. onClose,
  211. onKeydown,
  212. onKeyup,
  213. className,
  214. children,
  215. position: positionGetter
  216. } = props;
  217. const popupRef = useRef();
  218. const checkClose = useCallback((event) => {
  219. const popup = domClosest(event.target, '.djs-popup', true);
  220. if (popup) {
  221. return;
  222. }
  223. onClose();
  224. }, [ onClose ]);
  225. useLayoutEffect(() => {
  226. if (typeof positionGetter !== 'function') {
  227. return;
  228. }
  229. const popupEl = popupRef.current;
  230. const position = positionGetter(popupEl);
  231. popupEl.style.left = `${position.x}px`;
  232. popupEl.style.top = `${position.y}px`;
  233. }, [ popupRef.current, positionGetter ]);
  234. // focus popup initially, on mount
  235. useLayoutEffect(() => {
  236. popupRef.current && popupRef.current.focus();
  237. }, []);
  238. return html`
  239. <div
  240. class="djs-popup-backdrop"
  241. onClick=${ checkClose }
  242. >
  243. <div
  244. class=${ classNames('djs-popup', className) }
  245. style=${ getPopupStyle(props) }
  246. onKeydown=${ onKeydown }
  247. onKeyup=${ onKeyup }
  248. ref=${ popupRef }
  249. tabIndex="-1"
  250. >
  251. ${children}
  252. </div>
  253. </div>
  254. `;
  255. }
  256. // helpers //////////////////////
  257. function getPopupStyle(props) {
  258. return {
  259. transform: `scale(${props.scale})`,
  260. width: `${props.width}px`
  261. };
  262. }
  263. function getHeaderClasses(entry, selected) {
  264. return classNames(
  265. 'entry',
  266. entry.className,
  267. entry.active ? 'active' : '',
  268. entry.disabled ? 'disabled' : '',
  269. selected ? 'selected' : ''
  270. );
  271. }