bring in line with #24176
[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(e) {
257 if (!e.originalEvent.returnValue) {
258 CRM.menubar.spin(true);
259 }
260 if ($mainMenuState[0].checked) {
261 $mainMenuState[0].click();
262 }
263 })
264 .on('resize', function() {
265 if (!isMobile() && $mainMenuState[0].checked) {
266 $mainMenuState[0].click();
267 }
268 handleResize();
269 });
270 $mainMenuState.click(function() {
271 // Use absolute position instead of fixed when open to allow scrolling menu
272 var open = $(this).is(':checked');
273 if (open) {
274 window.scroll({top: 0});
275 }
276 $('#civicrm-menu-nav')
277 .css('position', open ? 'absolute' : '')
278 .parentsUntil('body')
279 .css('position', open ? 'static' : '');
280 });
281 },
282 initializeSearch: function() {
283 $('input[name=qfKey]', '#crm-qsearch').attr('value', CRM.menubar.qfKey);
284 $('#crm-qsearch-input')
285 .autocomplete({
286 source: function(request, response) {
287 //start spinning the civi logo
288 CRM.menubar.spin(true);
289 var
290 option = $('input[name=quickSearchField]:checked'),
291 params = {
292 name: request.term,
293 field_name: option.val()
294 };
295 CRM.api3('contact', 'getquick', params).done(function(result) {
296 var ret = [];
297 if (result.values.length > 0) {
298 $('#crm-qsearch-input').autocomplete('widget').menu('option', 'disabled', false);
299 $.each(result.values, function(k, v) {
300 ret.push({value: v.id, label: v.data});
301 });
302 } else {
303 $('#crm-qsearch-input').autocomplete('widget').menu('option', 'disabled', true);
304 var label = option.closest('label').text();
305 var msg = ts('%1 not found.', {1: label});
306 // Remind user they are not searching by contact name (unless they enter a number)
307 if (params.field_name !== 'sort_name' && !(/[\d].*/.test(params.name))) {
308 msg += ' ' + ts('Did you mean to search by Name/Email instead?');
309 }
310 ret.push({value: '0', label: msg});
311 }
312 response(ret);
313 //stop spinning the civi logo
314 CRM.menubar.spin(false);
315 CRM.menubar.close();
316 });
317 },
318 focus: function (event, ui) {
319 // This is when an item is 'focussed' by keyboard up/down or mouse hover.
320 // It is not the same as actually having focus, i.e. it is not :focus
321 var lis = $(event.currentTarget).find('li[data-cid="' + ui.item.value + '"]');
322 lis.children('div').addClass('ui-state-active');
323 lis.siblings().children('div').removeClass('ui-state-active');
324 // Returning false leaves the user-entered text as it was.
325 return false;
326 },
327 select: function (event, ui) {
328 if (ui.item.value > 0) {
329 document.location = CRM.url('civicrm/contact/view', {reset: 1, cid: ui.item.value});
330 }
331 return false;
332 },
333 create: function() {
334 $(this).autocomplete('widget').addClass('crm-quickSearch-results');
335 }
336 })
337 .on('keyup change', function() {
338 $(this).toggleClass('has-user-input', !!$(this).val());
339 })
340 .keyup(function(e) {
341 CRM.menubar.close();
342 if (e.which === ENTER_KEY) {
343 if (!$(this).val()) {
344 CRM.menubar.open('QuickSearch');
345 }
346 }
347 })
348 .autocomplete( "instance" )._renderItem = function( ul, item ) {
349 var uiMenuItemWrapper = $("<div class='ui-menu-item-uiMenuItemWrapper'>");
350 if (item.value == 0) {
351 // "No results"
352 uiMenuItemWrapper.text(item.label);
353 }
354 else {
355 uiMenuItemWrapper.append($('<a>')
356 .attr('href', CRM.url('civicrm/contact/view', {reset: 1, cid: item.value}))
357 .css({ display: 'block' })
358 .text(item.label)
359 .click(function(e) {
360 if (e.ctrlKey || e.shiftKey || e.altKey) {
361 // Special-clicking lets you open several tabs.
362 e.stopPropagation();
363 }
364 else {
365 // Fall back to original behaviour.
366 e.preventDefault();
367 }
368 }));
369 }
370
371 return $( "<li class='ui-menu-item' data-cid=" + item.value + ">" )
372 .append(uiMenuItemWrapper)
373 .appendTo( ul );
374 };
375 $('#crm-qsearch > a').keyup(function(e) {
376 if ($(e.target).is(this)) {
377 $('#crm-qsearch-input').focus();
378 CRM.menubar.close();
379 }
380 });
381 $('#crm-qsearch form[name=search_block]').on('submit', function() {
382 if (!$('#crm-qsearch-input').val()) {
383 return false;
384 }
385 var $menu = $('#crm-qsearch-input').autocomplete('widget');
386 if ($('li.ui-menu-item', $menu).length === 1) {
387 var cid = $('li.ui-menu-item', $menu).data('ui-autocomplete-item').value;
388 if (cid > 0) {
389 document.location = CRM.url('civicrm/contact/view', {reset: 1, cid: cid});
390 return false;
391 }
392 }
393 });
394 $('#civicrm-menu').on('show.smapi', function(e, menu) {
395 if ($(menu).parent().attr('data-name') === 'QuickSearch') {
396 $('#crm-qsearch-input').focus();
397 }
398 });
399 function setQuickSearchValue() {
400 var $selection = $('.crm-quickSearchField input:checked'),
401 label = $selection.parent().text(),
402 value = $selection.val();
403 $('#crm-qsearch-input').attr({name: value, placeholder: '\uf002 ' + label});
404 }
405 $('.crm-quickSearchField').click(function() {
406 var input = $('input', this);
407 // Wait for event - its default was prevented by our link handler which interferes with checking the radio input
408 window.setTimeout(function() {
409 input.prop('checked', true);
410 CRM.cache.set('quickSearchField', input.val());
411 setQuickSearchValue();
412 $('#crm-qsearch-input').focus().autocomplete("search");
413 }, 1);
414 });
415 var savedDefault = CRM.cache.get('quickSearchField');
416 if (savedDefault) {
417 $('.crm-quickSearchField input[value="' + savedDefault + '"]').prop('checked', true);
418 } else {
419 $('.crm-quickSearchField:first input').prop('checked', true);
420 }
421 setQuickSearchValue();
422 $('#civicrm-menu').on('activate.smapi', function(e, item) {
423 return !$('ul.crm-quickSearch-results').is(':visible:not(.ui-state-disabled)');
424 });
425 },
426 initializeDrill: function() {
427 $('#civicrm-menu').on('keyup', '#crm-menubar-drilldown', function() {
428 var term = $(this).val(),
429 results = term ? CRM.menubar.findItems(term).slice(0, 20) : [];
430 $(this).parent().next('ul').html(getTpl('branch')({items: results, branchTpl: getTpl('branch'), drillTpl: _.noop}));
431 $('#civicrm-menu').smartmenus('refresh').smartmenus('itemActivate', $(this).closest('a'));
432 });
433 },
434 treeTpl:
435 '<nav id="civicrm-menu-nav">' +
436 '<input id="crm-menubar-state" type="checkbox" />' +
437 '<label class="crm-menubar-toggle-btn" for="crm-menubar-state">' +
438 '<span class="crm-menu-logo"></span>' +
439 '<span class="crm-menubar-toggle-btn-icon"></span>' +
440 '<%- ts("Toggle main menu") %>' +
441 '</label>' +
442 '<ul id="civicrm-menu" class="sm sm-civicrm">' +
443 '<%= searchTpl({items: search}) %>' +
444 '<%= branchTpl({items: menu, branchTpl: branchTpl}) %>' +
445 '</ul>' +
446 '</nav>',
447 searchTpl:
448 '<li id="crm-qsearch" data-name="QuickSearch">' +
449 '<a href="#"> ' +
450 '<form action="<%= CRM.url(\'civicrm/contact/search/advanced\') %>" name="search_block" method="post">' +
451 '<div>' +
452 '<input type="text" id="crm-qsearch-input" name="sort_name" placeholder="\uf002" accesskey="q" />' +
453 '<input type="hidden" name="hidden_location" value="1" />' +
454 '<input type="hidden" name="hidden_custom" value="1" />' +
455 '<input type="hidden" name="qfKey" />' +
456 '<input type="hidden" name="_qf_Advanced_refresh" value="Search" />' +
457 '</div>' +
458 '</form>' +
459 '</a>' +
460 '<ul>' +
461 '<% _.forEach(items, function(item) { %>' +
462 '<li><a href="#" class="crm-quickSearchField"><label><input type="radio" value="<%= item.key %>" name="quickSearchField"> <%- item.value %></label></a></li>' +
463 '<% }) %>' +
464 '</ul>' +
465 '</li>',
466 drillTpl:
467 '<li class="crm-menu-border-bottom" data-name="MenubarDrillDown">' +
468 '<a href="#"><input type="text" id="crm-menubar-drilldown" placeholder="' + _.escape(ts('Find menu item...')) + '"></a>' +
469 '<ul></ul>' +
470 '</li>',
471 branchTpl:
472 '<% _.forEach(items, function(item) { %>' +
473 '<li <%= attr("li", item) %>>' +
474 '<a <%= attr("a", item) %>>' +
475 '<% if (item.icon) { %>' +
476 '<i class="<%- item.icon %>"></i>' +
477 '<% } %>' +
478 '<% if (item.label) { %>' +
479 '<span><%- item.label %></span>' +
480 '<% } %>' +
481 '</a>' +
482 '<% if (item.child) { %>' +
483 '<ul>' +
484 '<% if (item.name === "Home") { %><%= drillTpl() %><% } %>' +
485 '<%= branchTpl({items: item.child, branchTpl: branchTpl}) %>' +
486 '</ul>' +
487 '<% } %>' +
488 '</li>' +
489 '<% }) %>'
490 }, CRM.menubar || {});
491
492 function getTpl(name) {
493 if (!templates) {
494 templates = {
495 drill: _.template(CRM.menubar.drillTpl, {}),
496 search: _.template(CRM.menubar.searchTpl, {imports: {_: _, ts: ts, CRM: CRM}})
497 };
498 templates.branch = _.template(CRM.menubar.branchTpl, {imports: {_: _, attr: attr, drillTpl: templates.drill}});
499 templates.tree = _.template(CRM.menubar.treeTpl, {imports: {branchTpl: templates.branch, searchTpl: templates.search, ts: ts}});
500 }
501 return templates[name];
502 }
503
504 function handleResize() {
505 if (!isMobile() && ($('#civicrm-menu').height() >= (2 * $('#civicrm-menu > li').height()))) {
506 $('body').addClass('crm-menubar-wrapped');
507 } else {
508 $('body').removeClass('crm-menubar-wrapped');
509 }
510 }
511
512 // Figure out if we've hit the mobile breakpoint, based on the rule in crm-menubar.css
513 function isMobile() {
514 return $('.crm-menubar-toggle-btn', '#civicrm-menu-nav').css('top') !== '-99999px';
515 }
516
517 function traverse(items, itemName, op) {
518 var found;
519 _.each(items, function(item, index) {
520 if (item.name === itemName) {
521 found = (op === 'parent' ? items : item);
522 if (op === 'delete') {
523 items.splice(index, 1);
524 }
525 return false;
526 }
527 if (item.child) {
528 found = traverse(item.child, itemName, op);
529 if (found) {
530 return false;
531 }
532 }
533 });
534 return found;
535 }
536
537 function findRecursive(collection, searchTerm) {
538 var items = _.filter(collection, function(item) {
539 return item.label && _.includes(item.label.toLowerCase().replace(/ /g, ''), searchTerm);
540 });
541 _.each(collection, function(item) {
542 if (_.isPlainObject(item) && item.child) {
543 var childMatches = findRecursive(item.child, searchTerm);
544 if (childMatches.length) {
545 Array.prototype.push.apply(items, childMatches);
546 }
547 }
548 });
549 return items;
550 }
551
552 function attr(el, item) {
553 var ret = [], attr = _.cloneDeep(item.attr || {}), a = ['rel', 'accesskey', 'target'];
554 if (el === 'a') {
555 attr = _.pick(attr, a);
556 attr.href = item.url || "#";
557 } else {
558 attr = _.omit(attr, a);
559 attr['data-name'] = item.name;
560 if (item.separator) {
561 attr.class = (attr.class ? attr.class + ' ' : '') + 'crm-menu-border-' + item.separator;
562 }
563 }
564 _.each(attr, function(val, name) {
565 ret.push(name + '="' + val + '"');
566 });
567 return ret.join(' ');
568 }
569
570 CRM.menubar.initialize();
571
572 })(CRM.$, CRM._);