Merge pull request #21755 from civicrm/5.42
[civicrm-core.git] / js / crm.menubar.js
1 // https://civicrm.org/licensing
2 (function($, _) {
3 "use strict";
4 var templates, initialized,
5 ENTER_KEY = 13,
6 SPACE_KEY = 32;
7 CRM.menubar = _.extend({
8 data: null,
9 settings: {collapsibleBehavior: 'accordion'},
10 position: 'over-cms-menu',
11 toggleButton: true,
12 attachTo: (CRM.menubar && CRM.menubar.position === 'above-crm-container') ? '#crm-container' : 'body',
13 initialize: function() {
14 var cache = CRM.cache.get('menubar');
15 if (cache && cache.code === CRM.menubar.cacheCode && cache.locale === CRM.config.locale && cache.cid === CRM.config.cid && localStorage.civiMenubar) {
16 CRM.menubar.data = cache.data;
17 insert(localStorage.civiMenubar);
18 } else {
19 $.getJSON(CRM.url('civicrm/ajax/navmenu', {code: CRM.menubar.cacheCode, locale: CRM.config.locale, cid: CRM.config.cid}))
20 .done(function(data) {
21 var markup = getTpl('tree')(data);
22 CRM.cache.set('menubar', {code: CRM.menubar.cacheCode, locale: CRM.config.locale, cid: CRM.config.cid, data: data});
23 CRM.menubar.data = data;
24 localStorage.setItem('civiMenubar', markup);
25 insert(markup);
26 });
27 }
28
29 // Wait for crm-container present on the page as it's faster than document.ready
30 function insert(markup) {
31 if ($('#crm-container').length) {
32 render(markup);
33 } else {
34 new MutationObserver(function(mutations, observer) {
35 _.each(mutations, function(mutant) {
36 _.each(mutant.addedNodes, function(node) {
37 if ($(node).is('#crm-container')) {
38 render(markup);
39 observer.disconnect();
40 }
41 });
42 });
43 }).observe(document, {childList: true, subtree: true});
44 }
45 }
46
47 function render(markup) {
48 var position = CRM.menubar.attachTo === 'body' ? 'beforeend' : 'afterbegin';
49 $(CRM.menubar.attachTo)[0].insertAdjacentHTML(position, markup);
50 CRM.menubar.initializePosition();
51 $('#civicrm-menu').trigger('crmLoad');
52 $(document).ready(function() {
53 handleResize();
54 $('#civicrm-menu')
55 .on('click', 'a[href="#"]', function() {
56 // For empty links - keep the menu open and don't jump the page anchor
57 return false;
58 })
59 .on('click', 'a:not([href^="#"])', function(e) {
60 if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) {
61 // Prevent menu closing when link is clicked with a keyboard modifier.
62 e.stopPropagation();
63 }
64 })
65 .on('dragstart', function() {
66 // Stop user from accidentally dragging menu links
67 // This was added because a user noticed they could drag the civi icon into the quicksearch box.
68 return false;
69 })
70 .on('click', 'a[href="#hidemenu"]', function(e) {
71 e.preventDefault();
72 CRM.menubar.hide(250, true);
73 })
74 .on('keyup', 'a', function(e) {
75 // Simulate a click when spacebar key is pressed
76 if (e.which == SPACE_KEY) {
77 $(e.currentTarget)[0].click();
78 }
79 })
80 .on('show.smapi', function(e, menu) {
81 // Focus menu when opened with an accesskey
82 if ($(menu).parent().data('name') === 'Home') {
83 $('#crm-menubar-drilldown').focus();
84 } else {
85 $(menu).siblings('a[accesskey]').focus();
86 }
87 })
88 .smartmenus(CRM.menubar.settings);
89 initialized = true;
90 CRM.menubar.initializeResponsive();
91 CRM.menubar.initializeSearch();
92 CRM.menubar.initializeDrill();
93 });
94 }
95 },
96 destroy: function() {
97 $.SmartMenus.destroy();
98 $('#civicrm-menu-nav').remove();
99 initialized = false;
100 $('body[class]').attr('class', function(i, c) {
101 return c.replace(/(^|\s)crm-menubar-\S+/g, '');
102 });
103 },
104 show: function(speed) {
105 if (typeof speed === 'number') {
106 $('#civicrm-menu').slideDown(speed, function() {
107 $(this).css('display', '');
108 });
109 }
110 $('body')
111 .removeClass('crm-menubar-hidden')
112 .addClass('crm-menubar-visible');
113 },
114 hide: function(speed, showMessage) {
115 if (typeof speed === 'number') {
116 $('#civicrm-menu').slideUp(speed, function() {
117 $(this).css('display', '');
118 });
119 }
120 $('body')
121 .addClass('crm-menubar-hidden')
122 .removeClass('crm-menubar-visible');
123 if (showMessage === true && $('#crm-notification-container').length && initialized) {
124 var alert = CRM.alert('<a href="#" id="crm-restore-menu" >' + _.escape(ts('Restore CiviCRM Menu')) + '</a>', ts('Menu hidden'), 'none', {expires: 10000});
125 $('#crm-restore-menu')
126 .click(function(e) {
127 e.preventDefault();
128 alert.close();
129 CRM.menubar.show(speed);
130 });
131 }
132 },
133 open: function(itemName) {
134 var $item = $('li[data-name="' + itemName + '"] > a', '#civicrm-menu');
135 if ($item.length) {
136 $('#civicrm-menu').smartmenus('itemActivate', $item);
137 $item[0].focus();
138 }
139 },
140 close: $.SmartMenus.hideAll,
141 isOpen: function(itemName) {
142 if (itemName) {
143 return !!$('li[data-name="' + itemName + '"] > ul[aria-expanded="true"]', '#civicrm-menu').length;
144 }
145 return !!$('ul[aria-expanded="true"]', '#civicrm-menu').length;
146 },
147 spin: function(spin) {
148 $('.crm-logo-sm', '#civicrm-menu').toggleClass('fa-spin', spin);
149 },
150 getItem: function(itemName) {
151 return traverse(CRM.menubar.data.menu, itemName, 'get');
152 },
153 findItems: function(searchTerm) {
154 return findRecursive(CRM.menubar.data.menu, searchTerm.toLowerCase().replace(/ /g, ''));
155 },
156 addItems: function(position, targetName, items) {
157 var list, container, $ul;
158 if (position === 'before' || position === 'after') {
159 if (!targetName) {
160 throw 'Cannot add sibling of main menu';
161 }
162 list = traverse(CRM.menubar.data.menu, targetName, 'parent');
163 if (!list) {
164 throw targetName + ' not found';
165 }
166 var offset = position === 'before' ? 0 : 1;
167 position = offset + _.findIndex(list, {name: targetName});
168 $ul = $('li[data-name="' + targetName + '"]', '#civicrm-menu').closest('ul');
169 } else if (targetName) {
170 container = traverse(CRM.menubar.data.menu, targetName, 'get');
171 if (!container) {
172 throw targetName + ' not found';
173 }
174 container.child = container.child || [];
175 list = container.child;
176 var $target = $('li[data-name="' + targetName + '"]', '#civicrm-menu');
177 if (!$target.children('ul').length) {
178 $target.append('<ul>');
179 }
180 $ul = $target.children('ul').first();
181 } else {
182 list = CRM.menubar.data.menu;
183 }
184 if (position < 0) {
185 position = list.length + 1 + position;
186 }
187 if (position >= list.length) {
188 list.push.apply(list, items);
189 position = list.length - 1;
190 } else {
191 list.splice.apply(list, [position, 0].concat(items));
192 }
193 if (targetName && !$ul.is('#civicrm-menu')) {
194 $ul.html(getTpl('branch')({items: list, branchTpl: getTpl('branch')}));
195 } else {
196 $('#civicrm-menu > li').eq(position).after(getTpl('branch')({items: items, branchTpl: getTpl('branch')}));
197 }
198 CRM.menubar.refresh();
199 },
200 removeItem: function(itemName) {
201 var item = traverse(CRM.menubar.data.menu, itemName, 'delete');
202 if (item) {
203 $('li[data-name="' + itemName + '"]', '#civicrm-menu').remove();
204 CRM.menubar.refresh();
205 }
206 return item;
207 },
208 updateItem: function(item) {
209 if (!item.name) {
210 throw 'No name passed to CRM.menubar.updateItem';
211 }
212 var menuItem = CRM.menubar.getItem(item.name);
213 if (!menuItem) {
214 throw item.name + ' not found';
215 }
216 _.extend(menuItem, item);
217 $('li[data-name="' + item.name + '"]', '#civicrm-menu').replaceWith(getTpl('branch')({items: [menuItem], branchTpl: getTpl('branch')}));
218 CRM.menubar.refresh();
219 },
220 refresh: function() {
221 if (initialized) {
222 $('#civicrm-menu').smartmenus('refresh');
223 handleResize();
224 }
225 },
226 togglePosition: function(persist) {
227 $('body').toggleClass('crm-menubar-over-cms-menu crm-menubar-below-cms-menu');
228 CRM.menubar.position = CRM.menubar.position === 'over-cms-menu' ? 'below-cms-menu' : 'over-cms-menu';
229 handleResize();
230 if (persist !== false) {
231 CRM.cache.set('menubarPosition', CRM.menubar.position);
232 }
233 },
234 initializePosition: function() {
235 if (CRM.menubar.toggleButton && (CRM.menubar.position === 'over-cms-menu' || CRM.menubar.position === 'below-cms-menu')) {
236 $('#civicrm-menu')
237 .on('click', 'a[href="#toggle-position"]', function(e) {
238 e.preventDefault();
239 CRM.menubar.togglePosition();
240 })
241 .append('<li id="crm-menubar-toggle-position"><a href="#toggle-position" title="' + ts('Adjust menu position') + '"><i class="crm-i fa-arrow-up" aria-hidden="true"></i></a>');
242 CRM.menubar.position = CRM.cache.get('menubarPosition', CRM.menubar.position);
243 }
244 $('body').addClass('crm-menubar-visible crm-menubar-' + CRM.menubar.position);
245 },
246 removeToggleButton: function() {
247 $('#crm-menubar-toggle-position').remove();
248 CRM.menubar.toggleButton = false;
249 if (CRM.menubar.position === 'below-cms-menu') {
250 CRM.menubar.togglePosition();
251 }
252 },
253 initializeResponsive: function() {
254 var $mainMenuState = $('#crm-menubar-state');
255 // hide mobile menu beforeunload
256 $(window).on('beforeunload unload', function() {
257 CRM.menubar.spin(true);
258 if ($mainMenuState[0].checked) {
259 $mainMenuState[0].click();
260 }
261 })
262 .on('resize', function() {
263 if (!isMobile() && $mainMenuState[0].checked) {
264 $mainMenuState[0].click();
265 }
266 handleResize();
267 });
268 $mainMenuState.click(function() {
269 // Use absolute position instead of fixed when open to allow scrolling menu
270 var open = $(this).is(':checked');
271 if (open) {
272 window.scroll({top: 0});
273 }
274 $('#civicrm-menu-nav')
275 .css('position', open ? 'absolute' : '')
276 .parentsUntil('body')
277 .css('position', open ? 'static' : '');
278 });
279 },
280 initializeSearch: function() {
281 $('input[name=qfKey]', '#crm-qsearch').attr('value', CRM.menubar.qfKey);
282 $('#crm-qsearch-input')
283 .autocomplete({
284 source: function(request, response) {
285 //start spinning the civi logo
286 CRM.menubar.spin(true);
287 var
288 option = $('input[name=quickSearchField]:checked'),
289 params = {
290 name: request.term,
291 field_name: option.val()
292 };
293 CRM.api3('contact', 'getquick', params).done(function(result) {
294 var ret = [];
295 if (result.values.length > 0) {
296 $('#crm-qsearch-input').autocomplete('widget').menu('option', 'disabled', false);
297 $.each(result.values, function(k, v) {
298 ret.push({value: v.id, label: v.data});
299 });
300 } else {
301 $('#crm-qsearch-input').autocomplete('widget').menu('option', 'disabled', true);
302 var label = option.closest('label').text();
303 var msg = ts('%1 not found.', {1: label});
304 // Remind user they are not searching by contact name (unless they enter a number)
305 if (params.field_name !== 'sort_name' && !(/[\d].*/.test(params.name))) {
306 msg += ' ' + ts('Did you mean to search by Name/Email instead?');
307 }
308 ret.push({value: '0', label: msg});
309 }
310 response(ret);
311 //stop spinning the civi logo
312 CRM.menubar.spin(false);
313 CRM.menubar.close();
314 });
315 },
316 focus: function (event, ui) {
317 // This is when an item is 'focussed' by keyboard up/down or mouse hover.
318 // It is not the same as actually having focus, i.e. it is not :focus
319 var lis = $(event.currentTarget).find('li[data-cid="' + ui.item.value + '"]');
320 lis.children('div').addClass('ui-state-active');
321 lis.siblings().children('div').removeClass('ui-state-active');
322 // Returning false leaves the user-entered text as it was.
323 return false;
324 },
325 select: function (event, ui) {
326 if (ui.item.value > 0) {
327 document.location = CRM.url('civicrm/contact/view', {reset: 1, cid: ui.item.value});
328 }
329 return false;
330 },
331 create: function() {
332 $(this).autocomplete('widget').addClass('crm-quickSearch-results');
333 }
334 })
335 .on('keyup change', function() {
336 $(this).toggleClass('has-user-input', !!$(this).val());
337 })
338 .keyup(function(e) {
339 CRM.menubar.close();
340 if (e.which === ENTER_KEY) {
341 if (!$(this).val()) {
342 CRM.menubar.open('QuickSearch');
343 }
344 }
345 })
346 .autocomplete( "instance" )._renderItem = function( ul, item ) {
347 var uiMenuItemWrapper = $("<div class='ui-menu-item-uiMenuItemWrapper'>");
348 if (item.value == 0) {
349 // "No results"
350 uiMenuItemWrapper.text(item.label);
351 }
352 else {
353 uiMenuItemWrapper.append($('<a>')
354 .attr('href', CRM.url('civicrm/contact/view', {reset: 1, cid: item.value}))
355 .css({ display: 'block' })
356 .text(item.label)
357 .click(function(e) {
358 if (e.ctrlKey || e.shiftKey || e.altKey) {
359 // Special-clicking lets you open several tabs.
360 e.stopPropagation();
361 }
362 else {
363 // Fall back to original behaviour.
364 e.preventDefault();
365 }
366 }));
367 }
368
369 return $( "<li class='ui-menu-item' data-cid=" + item.value + ">" )
370 .append(uiMenuItemWrapper)
371 .appendTo( ul );
372 };
373 $('#crm-qsearch > a').keyup(function(e) {
374 if ($(e.target).is(this)) {
375 $('#crm-qsearch-input').focus();
376 CRM.menubar.close();
377 }
378 });
379 $('#crm-qsearch form[name=search_block]').on('submit', function() {
380 if (!$('#crm-qsearch-input').val()) {
381 return false;
382 }
383 var $menu = $('#crm-qsearch-input').autocomplete('widget');
384 if ($('li.ui-menu-item', $menu).length === 1) {
385 var cid = $('li.ui-menu-item', $menu).data('ui-autocomplete-item').value;
386 if (cid > 0) {
387 document.location = CRM.url('civicrm/contact/view', {reset: 1, cid: cid});
388 return false;
389 }
390 }
391 });
392 $('#civicrm-menu').on('show.smapi', function(e, menu) {
393 if ($(menu).parent().attr('data-name') === 'QuickSearch') {
394 $('#crm-qsearch-input').focus();
395 }
396 });
397 function setQuickSearchValue() {
398 var $selection = $('.crm-quickSearchField input:checked'),
399 label = $selection.parent().text(),
400 value = $selection.val();
401 $('#crm-qsearch-input').attr({name: value, placeholder: '\uf002 ' + label});
402 }
403 $('.crm-quickSearchField').click(function() {
404 var input = $('input', this);
405 // Wait for event - its default was prevented by our link handler which interferes with checking the radio input
406 window.setTimeout(function() {
407 input.prop('checked', true);
408 CRM.cache.set('quickSearchField', input.val());
409 setQuickSearchValue();
410 $('#crm-qsearch-input').focus().autocomplete("search");
411 }, 1);
412 });
413 var savedDefault = CRM.cache.get('quickSearchField');
414 if (savedDefault) {
415 $('.crm-quickSearchField input[value="' + savedDefault + '"]').prop('checked', true);
416 } else {
417 $('.crm-quickSearchField:first input').prop('checked', true);
418 }
419 setQuickSearchValue();
420 $('#civicrm-menu').on('activate.smapi', function(e, item) {
421 return !$('ul.crm-quickSearch-results').is(':visible:not(.ui-state-disabled)');
422 });
423 },
424 initializeDrill: function() {
425 $('#civicrm-menu').on('keyup', '#crm-menubar-drilldown', function() {
426 var term = $(this).val(),
427 results = term ? CRM.menubar.findItems(term).slice(0, 20) : [];
428 $(this).parent().next('ul').html(getTpl('branch')({items: results, branchTpl: getTpl('branch'), drillTpl: _.noop}));
429 $('#civicrm-menu').smartmenus('refresh').smartmenus('itemActivate', $(this).closest('a'));
430 });
431 },
432 treeTpl:
433 '<nav id="civicrm-menu-nav">' +
434 '<input id="crm-menubar-state" type="checkbox" />' +
435 '<label class="crm-menubar-toggle-btn" for="crm-menubar-state">' +
436 '<span class="crm-menu-logo"></span>' +
437 '<span class="crm-menubar-toggle-btn-icon"></span>' +
438 '<%- ts("Toggle main menu") %>' +
439 '</label>' +
440 '<ul id="civicrm-menu" class="sm sm-civicrm">' +
441 '<%= searchTpl({items: search}) %>' +
442 '<%= branchTpl({items: menu, branchTpl: branchTpl}) %>' +
443 '</ul>' +
444 '</nav>',
445 searchTpl:
446 '<li id="crm-qsearch" data-name="QuickSearch">' +
447 '<a href="#"> ' +
448 '<form action="<%= CRM.url(\'civicrm/contact/search/advanced\') %>" name="search_block" method="post">' +
449 '<div>' +
450 '<input type="text" id="crm-qsearch-input" name="sort_name" placeholder="\uf002" accesskey="q" />' +
451 '<input type="hidden" name="hidden_location" value="1" />' +
452 '<input type="hidden" name="hidden_custom" value="1" />' +
453 '<input type="hidden" name="qfKey" />' +
454 '<input type="hidden" name="_qf_Advanced_refresh" value="Search" />' +
455 '</div>' +
456 '</form>' +
457 '</a>' +
458 '<ul>' +
459 '<% _.forEach(items, function(item) { %>' +
460 '<li><a href="#" class="crm-quickSearchField"><label><input type="radio" value="<%= item.key %>" name="quickSearchField"> <%- item.value %></label></a></li>' +
461 '<% }) %>' +
462 '</ul>' +
463 '</li>',
464 drillTpl:
465 '<li class="crm-menu-border-bottom" data-name="MenubarDrillDown">' +
466 '<a href="#"><input type="text" id="crm-menubar-drilldown" placeholder="' + _.escape(ts('Find menu item...')) + '"></a>' +
467 '<ul></ul>' +
468 '</li>',
469 branchTpl:
470 '<% _.forEach(items, function(item) { %>' +
471 '<li <%= attr("li", item) %>>' +
472 '<a <%= attr("a", item) %>>' +
473 '<% if (item.icon) { %>' +
474 '<i class="<%- item.icon %>"></i>' +
475 '<% } %>' +
476 '<% if (item.label) { %>' +
477 '<span><%- item.label %></span>' +
478 '<% } %>' +
479 '</a>' +
480 '<% if (item.child) { %>' +
481 '<ul>' +
482 '<% if (item.name === "Home") { %><%= drillTpl() %><% } %>' +
483 '<%= branchTpl({items: item.child, branchTpl: branchTpl}) %>' +
484 '</ul>' +
485 '<% } %>' +
486 '</li>' +
487 '<% }) %>'
488 }, CRM.menubar || {});
489
490 function getTpl(name) {
491 if (!templates) {
492 templates = {
493 drill: _.template(CRM.menubar.drillTpl, {}),
494 search: _.template(CRM.menubar.searchTpl, {imports: {_: _, ts: ts, CRM: CRM}})
495 };
496 templates.branch = _.template(CRM.menubar.branchTpl, {imports: {_: _, attr: attr, drillTpl: templates.drill}});
497 templates.tree = _.template(CRM.menubar.treeTpl, {imports: {branchTpl: templates.branch, searchTpl: templates.search, ts: ts}});
498 }
499 return templates[name];
500 }
501
502 function handleResize() {
503 if (!isMobile() && ($('#civicrm-menu').height() >= (2 * $('#civicrm-menu > li').height()))) {
504 $('body').addClass('crm-menubar-wrapped');
505 } else {
506 $('body').removeClass('crm-menubar-wrapped');
507 }
508 }
509
510 // Figure out if we've hit the mobile breakpoint, based on the rule in crm-menubar.css
511 function isMobile() {
512 return $('.crm-menubar-toggle-btn', '#civicrm-menu-nav').css('top') !== '-99999px';
513 }
514
515 function traverse(items, itemName, op) {
516 var found;
517 _.each(items, function(item, index) {
518 if (item.name === itemName) {
519 found = (op === 'parent' ? items : item);
520 if (op === 'delete') {
521 items.splice(index, 1);
522 }
523 return false;
524 }
525 if (item.child) {
526 found = traverse(item.child, itemName, op);
527 if (found) {
528 return false;
529 }
530 }
531 });
532 return found;
533 }
534
535 function findRecursive(collection, searchTerm) {
536 var items = _.filter(collection, function(item) {
537 return item.label && _.includes(item.label.toLowerCase().replace(/ /g, ''), searchTerm);
538 });
539 _.each(collection, function(item) {
540 if (_.isPlainObject(item) && item.child) {
541 var childMatches = findRecursive(item.child, searchTerm);
542 if (childMatches.length) {
543 Array.prototype.push.apply(items, childMatches);
544 }
545 }
546 });
547 return items;
548 }
549
550 function attr(el, item) {
551 var ret = [], attr = _.cloneDeep(item.attr || {}), a = ['rel', 'accesskey', 'target'];
552 if (el === 'a') {
553 attr = _.pick(attr, a);
554 attr.href = item.url || "#";
555 } else {
556 attr = _.omit(attr, a);
557 attr['data-name'] = item.name;
558 if (item.separator) {
559 attr.class = (attr.class ? attr.class + ' ' : '') + 'crm-menu-border-' + item.separator;
560 }
561 }
562 _.each(attr, function(val, name) {
563 ret.push(name + '="' + val + '"');
564 });
565 return ret.join(' ');
566 }
567
568 CRM.menubar.initialize();
569
570 })(CRM.$, CRM._);