context-menu-manager-spec.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. const ContextMenuManager = require('../src/context-menu-manager');
  2. describe('ContextMenuManager', function() {
  3. let [contextMenu, parent, child, grandchild] = [];
  4. beforeEach(function() {
  5. const { resourcePath } = atom.getLoadSettings();
  6. contextMenu = new ContextMenuManager({ keymapManager: atom.keymaps });
  7. contextMenu.initialize({ resourcePath });
  8. parent = document.createElement('div');
  9. child = document.createElement('div');
  10. grandchild = document.createElement('div');
  11. parent.tabIndex = -1;
  12. child.tabIndex = -1;
  13. grandchild.tabIndex = -1;
  14. parent.classList.add('parent');
  15. child.classList.add('child');
  16. grandchild.classList.add('grandchild');
  17. child.appendChild(grandchild);
  18. parent.appendChild(child);
  19. document.body.appendChild(parent);
  20. });
  21. afterEach(function() {
  22. document.body.blur();
  23. document.body.removeChild(parent);
  24. });
  25. describe('::add(itemsBySelector)', function() {
  26. it('can add top-level menu items that can be removed with the returned disposable', function() {
  27. const disposable = contextMenu.add({
  28. '.parent': [{ label: 'A', command: 'a' }],
  29. '.child': [{ label: 'B', command: 'b' }],
  30. '.grandchild': [{ label: 'C', command: 'c' }]
  31. });
  32. expect(contextMenu.templateForElement(grandchild)).toEqual([
  33. { label: 'C', id: 'C', command: 'c' },
  34. { label: 'B', id: 'B', command: 'b' },
  35. { label: 'A', id: 'A', command: 'a' }
  36. ]);
  37. disposable.dispose();
  38. expect(contextMenu.templateForElement(grandchild)).toEqual([]);
  39. });
  40. it('can add submenu items to existing menus that can be removed with the returned disposable', function() {
  41. const disposable1 = contextMenu.add({
  42. '.grandchild': [{ label: 'A', submenu: [{ label: 'B', command: 'b' }] }]
  43. });
  44. const disposable2 = contextMenu.add({
  45. '.grandchild': [{ label: 'A', submenu: [{ label: 'C', command: 'c' }] }]
  46. });
  47. expect(contextMenu.templateForElement(grandchild)).toEqual([
  48. {
  49. label: 'A',
  50. id: 'A',
  51. submenu: [
  52. { label: 'B', id: 'B', command: 'b' },
  53. { label: 'C', id: 'C', command: 'c' }
  54. ]
  55. }
  56. ]);
  57. disposable2.dispose();
  58. expect(contextMenu.templateForElement(grandchild)).toEqual([
  59. {
  60. label: 'A',
  61. id: 'A',
  62. submenu: [{ label: 'B', id: 'B', command: 'b' }]
  63. }
  64. ]);
  65. disposable1.dispose();
  66. expect(contextMenu.templateForElement(grandchild)).toEqual([]);
  67. });
  68. it('favors the most specific / recently added item in the case of a duplicate label', function() {
  69. grandchild.classList.add('foo');
  70. const disposable1 = contextMenu.add({
  71. '.grandchild': [{ label: 'A', command: 'a' }]
  72. });
  73. const disposable2 = contextMenu.add({
  74. '.grandchild.foo': [{ label: 'A', command: 'b' }]
  75. });
  76. const disposable3 = contextMenu.add({
  77. '.grandchild': [{ label: 'A', command: 'c' }]
  78. });
  79. contextMenu.add({
  80. '.child': [{ label: 'A', command: 'd' }]
  81. });
  82. expect(contextMenu.templateForElement(grandchild)).toEqual([
  83. { label: 'A', id: 'A', command: 'b' }
  84. ]);
  85. disposable2.dispose();
  86. expect(contextMenu.templateForElement(grandchild)).toEqual([
  87. { label: 'A', id: 'A', command: 'c' }
  88. ]);
  89. disposable3.dispose();
  90. expect(contextMenu.templateForElement(grandchild)).toEqual([
  91. { label: 'A', id: 'A', command: 'a' }
  92. ]);
  93. disposable1.dispose();
  94. expect(contextMenu.templateForElement(grandchild)).toEqual([
  95. { label: 'A', id: 'A', command: 'd' }
  96. ]);
  97. });
  98. it('allows multiple separators, but not adjacent to each other', function() {
  99. contextMenu.add({
  100. '.grandchild': [
  101. { label: 'A', command: 'a' },
  102. { type: 'separator' },
  103. { type: 'separator' },
  104. { label: 'B', command: 'b' },
  105. { type: 'separator' },
  106. { type: 'separator' },
  107. { label: 'C', command: 'c' }
  108. ]
  109. });
  110. expect(contextMenu.templateForElement(grandchild)).toEqual([
  111. { label: 'A', id: 'A', command: 'a' },
  112. { type: 'separator' },
  113. { label: 'B', id: 'B', command: 'b' },
  114. { type: 'separator' },
  115. { label: 'C', id: 'C', command: 'c' }
  116. ]);
  117. });
  118. it('excludes items marked for display in devMode unless in dev mode', function() {
  119. contextMenu.add({
  120. '.grandchild': [
  121. { label: 'A', command: 'a', devMode: true },
  122. { label: 'B', command: 'b', devMode: false }
  123. ]
  124. });
  125. expect(contextMenu.templateForElement(grandchild)).toEqual([
  126. { label: 'B', id: 'B', command: 'b' }
  127. ]);
  128. contextMenu.devMode = true;
  129. expect(contextMenu.templateForElement(grandchild)).toEqual([
  130. { label: 'A', id: 'A', command: 'a' },
  131. { label: 'B', id: 'B', command: 'b' }
  132. ]);
  133. });
  134. it('allows items to be associated with `created` hooks which are invoked on template construction with the item and event', function() {
  135. let createdEvent = null;
  136. const item = {
  137. label: 'A',
  138. command: 'a',
  139. created(event) {
  140. this.command = 'b';
  141. createdEvent = event;
  142. }
  143. };
  144. contextMenu.add({ '.grandchild': [item] });
  145. const dispatchedEvent = { target: grandchild };
  146. expect(contextMenu.templateForEvent(dispatchedEvent)).toEqual([
  147. { label: 'A', id: 'A', command: 'b' }
  148. ]);
  149. expect(item.command).toBe('a'); // doesn't modify original item template
  150. expect(createdEvent).toBe(dispatchedEvent);
  151. });
  152. it('allows items to be associated with `shouldDisplay` hooks which are invoked on construction to determine whether the item should be included', function() {
  153. let shouldDisplayEvent = null;
  154. let shouldDisplay = true;
  155. const item = {
  156. label: 'A',
  157. command: 'a',
  158. shouldDisplay(event) {
  159. this.foo = 'bar';
  160. shouldDisplayEvent = event;
  161. return shouldDisplay;
  162. }
  163. };
  164. contextMenu.add({ '.grandchild': [item] });
  165. const dispatchedEvent = { target: grandchild };
  166. expect(contextMenu.templateForEvent(dispatchedEvent)).toEqual([
  167. { label: 'A', id: 'A', command: 'a' }
  168. ]);
  169. expect(item.foo).toBeUndefined(); // doesn't modify original item template
  170. expect(shouldDisplayEvent).toBe(dispatchedEvent);
  171. shouldDisplay = false;
  172. expect(contextMenu.templateForEvent(dispatchedEvent)).toEqual([]);
  173. });
  174. it('prunes a trailing separator', function() {
  175. contextMenu.add({
  176. '.grandchild': [
  177. { label: 'A', command: 'a' },
  178. { type: 'separator' },
  179. { label: 'B', command: 'b' },
  180. { type: 'separator' }
  181. ]
  182. });
  183. expect(contextMenu.templateForEvent({ target: grandchild }).length).toBe(
  184. 3
  185. );
  186. });
  187. it('prunes a leading separator', function() {
  188. contextMenu.add({
  189. '.grandchild': [
  190. { type: 'separator' },
  191. { label: 'A', command: 'a' },
  192. { type: 'separator' },
  193. { label: 'B', command: 'b' }
  194. ]
  195. });
  196. expect(contextMenu.templateForEvent({ target: grandchild }).length).toBe(
  197. 3
  198. );
  199. });
  200. it('prunes duplicate separators', function() {
  201. contextMenu.add({
  202. '.grandchild': [
  203. { label: 'A', command: 'a' },
  204. { type: 'separator' },
  205. { type: 'separator' },
  206. { label: 'B', command: 'b' }
  207. ]
  208. });
  209. expect(contextMenu.templateForEvent({ target: grandchild }).length).toBe(
  210. 3
  211. );
  212. });
  213. it('prunes all redundant separators', function() {
  214. contextMenu.add({
  215. '.grandchild': [
  216. { type: 'separator' },
  217. { type: 'separator' },
  218. { label: 'A', command: 'a' },
  219. { type: 'separator' },
  220. { type: 'separator' },
  221. { label: 'B', command: 'b' },
  222. { label: 'C', command: 'c' },
  223. { type: 'separator' },
  224. { type: 'separator' }
  225. ]
  226. });
  227. expect(contextMenu.templateForEvent({ target: grandchild }).length).toBe(
  228. 4
  229. );
  230. });
  231. it('throws an error when the selector is invalid', function() {
  232. let addError = null;
  233. try {
  234. contextMenu.add({ '<>': [{ label: 'A', command: 'a' }] });
  235. } catch (error) {
  236. addError = error;
  237. }
  238. expect(addError.message).toContain('<>');
  239. });
  240. it('calls `created` hooks for submenu items', function() {
  241. const item = {
  242. label: 'A',
  243. command: 'B',
  244. submenu: [
  245. {
  246. label: 'C',
  247. created(event) {
  248. this.label = 'D';
  249. }
  250. }
  251. ]
  252. };
  253. contextMenu.add({ '.grandchild': [item] });
  254. const dispatchedEvent = { target: grandchild };
  255. expect(contextMenu.templateForEvent(dispatchedEvent)).toEqual([
  256. {
  257. label: 'A',
  258. id: 'A',
  259. command: 'B',
  260. submenu: [
  261. {
  262. label: 'D',
  263. id: 'D'
  264. }
  265. ]
  266. }
  267. ]);
  268. });
  269. });
  270. describe('::templateForEvent(target)', function() {
  271. let [keymaps, item] = [];
  272. beforeEach(function() {
  273. keymaps = atom.keymaps.add('source', {
  274. '.child': {
  275. 'ctrl-a': 'test:my-command',
  276. 'shift-b': 'test:my-other-command'
  277. }
  278. });
  279. item = {
  280. label: 'My Command',
  281. command: 'test:my-command',
  282. submenu: [
  283. {
  284. label: 'My Other Command',
  285. command: 'test:my-other-command'
  286. }
  287. ]
  288. };
  289. contextMenu.add({ '.parent': [item] });
  290. });
  291. afterEach(() => keymaps.dispose());
  292. it('adds Electron-style accelerators to items that have keybindings', function() {
  293. child.focus();
  294. const dispatchedEvent = { target: child };
  295. expect(contextMenu.templateForEvent(dispatchedEvent)).toEqual([
  296. {
  297. label: 'My Command',
  298. id: 'My Command',
  299. command: 'test:my-command',
  300. accelerator: 'Ctrl+A',
  301. submenu: [
  302. {
  303. label: 'My Other Command',
  304. id: 'My Other Command',
  305. command: 'test:my-other-command',
  306. accelerator: 'Shift+B'
  307. }
  308. ]
  309. }
  310. ]);
  311. });
  312. it('adds accelerators when a parent node has key bindings for a given command', function() {
  313. grandchild.focus();
  314. const dispatchedEvent = { target: grandchild };
  315. expect(contextMenu.templateForEvent(dispatchedEvent)).toEqual([
  316. {
  317. label: 'My Command',
  318. id: 'My Command',
  319. command: 'test:my-command',
  320. accelerator: 'Ctrl+A',
  321. submenu: [
  322. {
  323. label: 'My Other Command',
  324. id: 'My Other Command',
  325. command: 'test:my-other-command',
  326. accelerator: 'Shift+B'
  327. }
  328. ]
  329. }
  330. ]);
  331. });
  332. it('does not add accelerators when a child node has key bindings for a given command', function() {
  333. parent.focus();
  334. const dispatchedEvent = { target: parent };
  335. expect(contextMenu.templateForEvent(dispatchedEvent)).toEqual([
  336. {
  337. label: 'My Command',
  338. id: 'My Command',
  339. command: 'test:my-command',
  340. submenu: [
  341. {
  342. label: 'My Other Command',
  343. id: 'My Other Command',
  344. command: 'test:my-other-command'
  345. }
  346. ]
  347. }
  348. ]);
  349. });
  350. it('adds accelerators based on focus, not context menu target', function() {
  351. grandchild.focus();
  352. const dispatchedEvent = { target: parent };
  353. expect(contextMenu.templateForEvent(dispatchedEvent)).toEqual([
  354. {
  355. label: 'My Command',
  356. id: 'My Command',
  357. command: 'test:my-command',
  358. accelerator: 'Ctrl+A',
  359. submenu: [
  360. {
  361. label: 'My Other Command',
  362. id: 'My Other Command',
  363. command: 'test:my-other-command',
  364. accelerator: 'Shift+B'
  365. }
  366. ]
  367. }
  368. ]);
  369. });
  370. it('does not add accelerators for multi-keystroke key bindings', function() {
  371. atom.keymaps.add('source', {
  372. '.child': {
  373. 'ctrl-a ctrl-b': 'test:multi-keystroke-command'
  374. }
  375. });
  376. contextMenu.clear();
  377. contextMenu.add({
  378. '.parent': [
  379. {
  380. label: 'Multi-keystroke command',
  381. command: 'test:multi-keystroke-command'
  382. }
  383. ]
  384. });
  385. child.focus();
  386. const label = process.platform === 'darwin' ? '⌃A ⌃B' : 'Ctrl+A Ctrl+B';
  387. expect(contextMenu.templateForEvent({ target: child })).toEqual([
  388. {
  389. label: `Multi-keystroke command [${label}]`,
  390. id: `Multi-keystroke command`,
  391. command: 'test:multi-keystroke-command'
  392. }
  393. ]);
  394. });
  395. });
  396. describe('::templateForEvent(target) (sorting)', function() {
  397. it('applies simple sorting rules', function() {
  398. contextMenu.add({
  399. '.parent': [
  400. {
  401. label: 'My Command',
  402. command: 'test:my-command',
  403. after: ['test:my-other-command']
  404. },
  405. {
  406. label: 'My Other Command',
  407. command: 'test:my-other-command'
  408. }
  409. ]
  410. });
  411. const dispatchedEvent = { target: parent };
  412. expect(contextMenu.templateForEvent(dispatchedEvent)).toEqual([
  413. {
  414. label: 'My Other Command',
  415. id: 'My Other Command',
  416. command: 'test:my-other-command'
  417. },
  418. {
  419. label: 'My Command',
  420. id: 'My Command',
  421. command: 'test:my-command',
  422. after: ['test:my-other-command']
  423. }
  424. ]);
  425. });
  426. it('applies sorting rules recursively to submenus', function() {
  427. contextMenu.add({
  428. '.parent': [
  429. {
  430. label: 'Parent',
  431. submenu: [
  432. {
  433. label: 'My Command',
  434. command: 'test:my-command',
  435. after: ['test:my-other-command']
  436. },
  437. {
  438. label: 'My Other Command',
  439. command: 'test:my-other-command'
  440. }
  441. ]
  442. }
  443. ]
  444. });
  445. const dispatchedEvent = { target: parent };
  446. expect(contextMenu.templateForEvent(dispatchedEvent)).toEqual([
  447. {
  448. label: 'Parent',
  449. id: `Parent`,
  450. submenu: [
  451. {
  452. label: 'My Other Command',
  453. id: 'My Other Command',
  454. command: 'test:my-other-command'
  455. },
  456. {
  457. label: 'My Command',
  458. id: 'My Command',
  459. command: 'test:my-command',
  460. after: ['test:my-other-command']
  461. }
  462. ]
  463. }
  464. ]);
  465. });
  466. });
  467. });