UNPKG

23.1 kBJavaScriptView Raw
1import $ from './jquery';
2import { dim, undim } from './blanket';
3import FocusManager from './focus-manager';
4import {getTrigger,hasTrigger} from './trigger';
5import globalize from './internal/globalize';
6import keyCode from './key-code';
7import widget from './internal/widget';
8import CustomEvent from './polyfills/custom-event';
9
10export const EVENT_PREFIX = '_aui-internal-layer-';
11const GLOBAL_EVENT_PREFIX = '_aui-internal-layer-global-';
12const LAYER_EVENT_PREFIX = 'aui-layer-';
13const AUI_EVENT_PREFIX = 'aui-';
14const ATTR_MODAL = 'modal';
15const ATTR_DOM_CONTAINER = 'dom-container';
16const ZINDEX_AUI_LAYER_MIN = 3000;
17
18var $doc = $(document);
19
20// AUI-3708 - Abstracted to reflect code implemented upstream.
21function isTransitioning (el, prop) {
22 var transition = window.getComputedStyle(el).transitionProperty;
23 return transition ? transition.indexOf(prop) > -1 : false;
24}
25
26function onTransitionEnd (el, prop, func, once) {
27 function handler (e) {
28 if (prop !== e.propertyName) {
29 return;
30 }
31
32 func.call(el);
33
34 if (once) {
35 el.removeEventListener('transitionend', handler);
36 }
37 }
38
39 if (isTransitioning(el, prop)) {
40 el.addEventListener('transitionend', handler);
41 } else {
42 func.call(el);
43 }
44}
45
46function oneTransitionEnd (el, prop, func) {
47 onTransitionEnd(el, prop, func, true);
48}
49// end AUI-3708
50
51/**
52* @return {bool} Returns false if at least one of the event handlers called .preventDefault(). Returns true otherwise.
53*/
54function triggerEvent ($el, deprecatedName, newNativeName) {
55 var e1 = $.Event(EVENT_PREFIX + deprecatedName);
56 var e2 = $.Event(GLOBAL_EVENT_PREFIX + deprecatedName);
57 // TODO: Remove this 'aui-layer-' prefixed event once it is no longer used by inline dialog and dialog2.
58 var nativeEvent = new CustomEvent(LAYER_EVENT_PREFIX + newNativeName, {
59 bubbles: true,
60 cancelable: true
61 });
62 var nativeEvent2 = new CustomEvent(AUI_EVENT_PREFIX + newNativeName, {
63 bubbles: true,
64 cancelable: true
65 });
66
67 $el.trigger(e1);
68 $el.trigger(e2, [$el]);
69 $el[0].dispatchEvent(nativeEvent);
70 $el[0].dispatchEvent(nativeEvent2);
71
72 return !e1.isDefaultPrevented() &&
73 !e2.isDefaultPrevented() &&
74 !nativeEvent.defaultPrevented &&
75 !nativeEvent2.defaultPrevented;
76}
77
78function Layer (selector) {
79 this.$el = $(selector || '<div class="aui-layer"></div>');
80 this.el = this.$el[0];
81 this.$el.addClass('aui-layer');
82}
83
84function getAttribute (el, name) {
85 return el.getAttribute(name) || el.getAttribute('data-aui-' + name);
86}
87
88Layer.prototype = {
89 /**
90 * Returns the layer below the current layer if it exists.
91 *
92 * @returns {jQuery | undefined}
93 */
94 below: function () {
95 return LayerManager.global.item(LayerManager.global.indexOf(this.$el) - 1);
96 },
97
98 /**
99 * Returns the layer above the current layer if it exists.
100 *
101 * @returns {jQuery | undefined}
102 */
103 above: function () {
104 return LayerManager.global.item(LayerManager.global.indexOf(this.$el) + 1);
105 },
106
107 /**
108 * Sets the width and height of the layer.
109 *
110 * @param {Integer} width The width to set.
111 * @param {Integer} height The height to set.
112 *
113 * @returns {Layer}
114 */
115 changeSize: function (width, height) {
116 this.$el.css('width', width);
117 this.$el.css('height', height === 'content' ? '' : height);
118 return this;
119 },
120
121 /**
122 * Binds a layer event.
123 *
124 * @param {String} event The event name to listen to.
125 * @param {Function} fn The event handler.
126 *
127 * @returns {Layer}
128 */
129 on: function (event, fn) {
130 this.$el.on(EVENT_PREFIX + event, fn);
131 return this;
132 },
133
134
135 /**
136 * Unbinds a layer event.
137 *
138 * @param {String} event The event name to unbind=.
139 * @param {Function} fn Optional. The event handler.
140 *
141 * @returns {Layer}
142 */
143 off: function (event, fn) {
144 this.$el.off(EVENT_PREFIX + event, fn);
145 return this;
146 },
147
148 /**
149 * Shows the layer.
150 *
151 * @returns {Layer}
152 */
153 show: function () {
154 if (this.isVisible() || LayerManager.global.indexOf(this.$el) > -1) {
155 // do nothing if already shown
156 return this;
157 }
158
159 if (!triggerEvent(this.$el, 'beforeShow', 'show')) {
160 return this;
161 }
162
163 // AUI-3708
164 // Ensures that the display property is removed if it's been added
165 // during hiding.
166 if (this.$el.css('display') === 'none') {
167 this.$el.css('display', '');
168 }
169
170 LayerManager.global.push(this.$el);
171
172 return this;
173 },
174
175 /**
176 * Hides the layer.
177 *
178 * @returns {Layer}
179 */
180 hide: function () {
181 if (!this.isVisible()) {
182 // do nothing if already hidden
183 return this;
184 }
185
186 // AUI-3708
187 const thisLayer = this;
188 oneTransitionEnd(this.$el.get(0), 'opacity', function () {
189 if (!thisLayer.isVisible()) {
190 this.style.display = 'none';
191 }
192 });
193
194 LayerManager.global.popUntil(this.$el, true);
195
196 return this;
197 },
198
199 /**
200 * Checks to see if the layer is visible.
201 *
202 * @returns {Boolean}
203 */
204 isVisible: function () {
205 return this.$el.data('_aui-layer-shown') === true;
206 },
207
208 /**
209 * Removes the layer and cleans up internal state.
210 *
211 * @returns {undefined}
212 */
213 remove: function () {
214 this.hide();
215 this.$el.remove();
216 this.$el = null;
217 this.el = null;
218 },
219
220 /**
221 * Returns whether or not the layer is blanketed.
222 *
223 * @returns {Boolean}
224 */
225 isBlanketed: function () {
226 return this.el.dataset.auiBlanketed === 'true';
227 },
228
229 /**
230 * Returns whether or not the layer is persistent.
231 *
232 * @returns {Boolean}
233 */
234 isPersistent: function () {
235 var modal = getAttribute(this.el, ATTR_MODAL);
236 var isPersistent = this.el.hasAttribute('persistent');
237
238 return modal === 'true' || isPersistent;
239 },
240 /**
241 * Returns element used to attach the component to onto render.
242 *
243 * Looks for a selector in specified attribute and returns Element matching that selector.
244 * If attribute is set but the selector matches multiple elements - it will default to first available match.
245 * If attribute is set but the selector does not match to any existing elements it will default to document.body
246 * If the attribute is not set it will return null
247 *
248 * @returns {(Element|null)}
249 */
250 getDOMContainer: function () {
251 let container = getAttribute(this.el, ATTR_DOM_CONTAINER);
252 if (container) {
253 container = document.querySelector(container) || document.body;
254 }
255 return container;
256 },
257
258 _hideLayer: function (triggerBeforeEvents) {
259 if (triggerBeforeEvents) {
260 if (!triggerEvent(this.$el, 'beforeHide', 'hide')) {
261 return false;
262 }
263 }
264
265 if (this.isPersistent() || this.isBlanketed()) {
266 FocusManager.global.exit(this.$el);
267 }
268
269 // don't remove via jquery; that would cause this method to get re-called once or twice more :\
270 this.el.removeAttribute('open');
271 this.$el.removeData('_aui-layer-shown');
272 this.$el.css('z-index', this.$el.data('_aui-layer-cached-z-index') || '');
273 this.$el.data('_aui-layer-cached-z-index', '');
274 this.$el.trigger(EVENT_PREFIX + 'hide');
275 this.$el.trigger(GLOBAL_EVENT_PREFIX + 'hide', [this.$el]);
276 return true;
277 },
278
279 _showLayer: function (zIndex) {
280 let domContainer = this.getDOMContainer();
281 if (this.isBlanketed() || !!domContainer) {
282 let parent = domContainer || 'body';
283
284 if (!this.$el.parent().is(parent)) {
285 this.$el.appendTo(parent);
286 }
287 }
288
289 this.$el.data('_aui-layer-shown', true);
290 this.$el.data('_aui-layer-cached-z-index', this.$el.css('z-index'));
291 this.$el.css('z-index', zIndex);
292 this.el.removeAttribute('hidden');
293 this.el.setAttribute('open', '');
294
295 if (this.isBlanketed()) {
296 FocusManager.global.enter(this.$el);
297 }
298
299 this.$el.trigger(EVENT_PREFIX + 'show');
300 this.$el.trigger(GLOBAL_EVENT_PREFIX + 'show', [this.$el]);
301 }
302};
303
304var createLayer = widget('layer', Layer);
305
306createLayer.on = function (eventName, selector, fn) {
307 $doc.on(GLOBAL_EVENT_PREFIX + eventName, selector, fn);
308 return this;
309};
310
311createLayer.off = function (eventName, selector, fn) {
312 $doc.off(GLOBAL_EVENT_PREFIX + eventName, selector, fn);
313 return this;
314};
315
316
317
318// Layer Manager
319// -------------
320
321/**
322 * Manages layers.
323 *
324 * There is a single global layer manager.
325 * Additional instances can be created however this should generally only be used in tests.
326 *
327 * Layers are added by the push($el) method. Layers are removed by the
328 * popUntil($el) method.
329 *
330 * popUntil's contract is that it pops all layers above & including the given
331 * layer. This is used to support popping multiple layers.
332 * Say we were showing a dropdown inside an inline dialog inside a dialog - we
333 * have a stack of dialog layer, inline dialog layer, then dropdown layer. Calling
334 * popUntil(dialog.$el) would hide all layers above & including the dialog.
335 */
336
337function topIndexWhere (layerArr, fn) {
338 var i = layerArr.length;
339
340 while (i--) {
341 if (fn(layerArr[i])) {
342 return i;
343 }
344 }
345
346 return -1;
347}
348
349function layerIndex (layerArr, $el) {
350 return topIndexWhere(layerArr, function ($layer) {
351 return $layer[0] === $el[0];
352 });
353}
354
355function topBlanketedIndex (layerArr) {
356 return topIndexWhere(layerArr, function ($layer) {
357 return createLayer($layer).isBlanketed();
358 });
359}
360
361function nextZIndex (layerArr) {
362 var _nextZIndex;
363
364 if (layerArr.length) {
365 var $topEl = layerArr[layerArr.length - 1];
366 var zIndex = parseInt($topEl.css('z-index'), 10);
367 _nextZIndex = (isNaN(zIndex) ? 0 : zIndex) + 100;
368 } else {
369 _nextZIndex = 0;
370 }
371
372 return Math.max(ZINDEX_AUI_LAYER_MIN, _nextZIndex);
373}
374
375function updateBlanket (stack, oldBlanketIndex) {
376 var newTopBlanketedIndex = topBlanketedIndex(stack);
377
378 if (oldBlanketIndex !== newTopBlanketedIndex) {
379 if (newTopBlanketedIndex > -1) {
380 dim(false, stack[newTopBlanketedIndex].css('z-index') - 20);
381 } else {
382 undim();
383 }
384 }
385}
386
387function popLayers (stack, stopIndex, forceClosePersistent, triggerBeforeEvents = true) {
388 if (stopIndex < 0) {
389 return [false, null];
390 }
391
392 var $layer;
393 for (var a = stack.length - 1; a >= stopIndex; a--) {
394 $layer = stack[a];
395 var layer = createLayer($layer);
396
397 if (forceClosePersistent || !layer.isPersistent()) {
398 if (!layer._hideLayer(triggerBeforeEvents)) {
399 return [false, $layer];
400 }
401 stack.splice(a, 1);
402 }
403 }
404 return [true, $layer];
405}
406
407function getParentLayer (layer) {
408 var trigger = getTrigger(layer);
409
410 if (trigger) {
411 return $(trigger).closest('.aui-layer').get(0);
412 }
413}
414
415function LayerManager () {
416 this._stack = [];
417}
418
419LayerManager.prototype = {
420 /**
421 * Pushes a layer onto the stack. The same element cannot be opened as a layer multiple times - if the given
422 * element is already an open layer, this method throws an exception.
423 *
424 * @param {HTMLElement | String | jQuery} element The element to push onto the stack.
425 *
426 * @returns {LayerManager}
427 */
428 push: function (element) {
429 var $el = (element instanceof $) ? element : $(element);
430 if (layerIndex(this._stack, $el) >= 0) {
431 throw new Error('The given element is already an active layer.');
432 }
433
434 this.popLayersBeside($el);
435
436 var layer = createLayer($el);
437 var zIndex = nextZIndex(this._stack);
438
439 layer._showLayer(zIndex);
440
441 if (layer.isBlanketed()) {
442 dim(false, zIndex - 20);
443 }
444
445 this._stack.push($el);
446
447 return this;
448 },
449
450 popLayersBeside: function (element) {
451 const layer = $(element).get(0);
452 if (!hasTrigger(layer)) {
453 // We can't find this layer's trigger, we will pop all non-persistent until a blanket or the document
454 var blanketedIndex = topBlanketedIndex(this._stack);
455 popLayers(this._stack, ++blanketedIndex, false);
456 return;
457 }
458
459 const parentLayer = getParentLayer(layer);
460 if (parentLayer) {
461 let parentIndex = this.indexOf(parentLayer);
462 popLayers(this._stack, ++parentIndex, false);
463 } else {
464 popLayers(this._stack, 0, false);
465 }
466 },
467
468 /**
469 * Returns the index of the specified layer in the layer stack.
470 *
471 * @param {HTMLElement | String | jQuery} element The element to find in the stack.
472 *
473 * @returns {Number} the (zero-based) index of the element, or -1 if not in the stack.
474 */
475 indexOf: function (element) {
476 return layerIndex(this._stack, $(element));
477 },
478
479 /**
480 * Returns the item at the particular index or false.
481 *
482 * @param {Number} index The index of the element to get.
483 *
484 * @returns {jQuery | Boolean}
485 */
486 item: function (index) {
487 return this._stack[index];
488 },
489
490 /**
491 * Hides all layers in the stack.
492 *
493 * @returns {LayerManager}
494 */
495 hideAll: function () {
496 this._stack.slice().reverse().forEach(function (element) {
497 let layer = createLayer(element);
498 if (layer.isBlanketed() || layer.isPersistent()) {
499 return;
500 }
501 layer.hide();
502 });
503
504 return this;
505 },
506
507 /**
508 * Gets the previous layer below the given layer, which is non modal and non persistent. If it finds a blanketed layer on the way
509 * it returns it regardless if it is modal or not
510 *
511 * @param {HTMLElement | String | jQuery} element layer to start the search from.
512 *
513 * @returns {jQuery | null} the next matching layer or null if none found.
514 */
515 getNextLowerNonPersistentOrBlanketedLayer: function (element) {
516 var $el = (element instanceof $) ? element : $(element);
517 var index = layerIndex(this._stack, $el);
518
519 if (index < 0) {
520 return null;
521 }
522
523 var $nextEl;
524 index--;
525 while (index >= 0) {
526 $nextEl = this._stack[index];
527 var layer = createLayer($nextEl);
528
529 if (!layer.isPersistent() || layer.isBlanketed()) {
530 return $nextEl;
531 }
532 index--;
533 }
534
535 return null;
536 },
537
538 /**
539 * Gets the next layer which is neither modal or blanketed, from the given layer.
540 *
541 * @param {HTMLElement | String | jQuery} element layer to start the search from.
542 *
543 * @returns {jQuery | null} the next non modal non blanketed layer or null if none found.
544 */
545 getNextHigherNonPeristentAndNonBlanketedLayer: function (element) {
546 var $el = (element instanceof $) ? element : $(element);
547 var index = layerIndex(this._stack, $el);
548
549 if (index < 0) {
550 return null;
551 }
552
553 var $nextEl;
554 index++;
555 while (index < this._stack.length) {
556 $nextEl = this._stack[index];
557 var layer = createLayer($nextEl);
558
559 if (!(layer.isPersistent() || layer.isBlanketed())) {
560 return $nextEl;
561 }
562 index++;
563 }
564
565 return null;
566 },
567
568 /**
569 * Gets the top layer, if it exists.
570 *
571 * @returns The layer on top of the stack, if it exists, otherwise null.
572 */
573 getTopLayer: function () {
574 return this._stack[this._stack.length - 1] || null;
575 },
576
577 /**
578 * Removes all non-modal layers above & including the given element. If the given element is not an active layer, this method
579 * is a no-op. The given element will be removed regardless of whether or not it is modal.
580 *
581 * @param {HTMLElement | String | jQuery} element layer to pop.
582 * @param {boolean} [triggerBeforeEvents=false]
583 *
584 * @returns {jQuery} The last layer that was popped, or null if no layer matching the given $el was found.
585 */
586 popUntil: function (element, triggerBeforeEvents = false) {
587 var $el = (element instanceof $) ? element : $(element);
588 var index = layerIndex(this._stack, $el);
589
590 if (index === -1) {
591 return null;
592 }
593
594 const oldTopBlanketedIndex = topBlanketedIndex(this._stack);
595
596 // Removes all layers above the current one.
597 const layer = createLayer($el);
598 const [success, $lastPopped] = popLayers(this._stack, index + 1, layer.isBlanketed(), triggerBeforeEvents)
599 if (!success) {
600 return $lastPopped;
601 }
602
603 // Removes the current layer.
604 if (!layer._hideLayer(triggerBeforeEvents)) {
605 return $lastPopped;
606 }
607 this._stack.splice(index, 1);
608 updateBlanket(this._stack, oldTopBlanketedIndex);
609
610 return $el;
611 },
612
613 /**
614 * Pops the top layer, if it exists and it is non modal and non persistent.
615 *
616 * @returns The layer that was popped, if it was popped.
617 */
618 popTopIfNonPersistent: function (triggerBeforeEvents = false) {
619 var $topLayer = this.getTopLayer();
620 var layer = createLayer($topLayer);
621
622 if (!$topLayer || layer.isPersistent()) {
623 return null;
624 }
625
626 return this.popUntil($topLayer, triggerBeforeEvents);
627 },
628
629 /**
630 * Pops all layers above and including the top blanketed layer. If layers exist but none are blanketed, this method
631 * does nothing.
632 *
633 * @returns The blanketed layer that was popped, if it exists, otherwise null.
634 */
635 popUntilTopBlanketed: function (triggerBeforeEvents = false) {
636 var i = topBlanketedIndex(this._stack);
637
638 if (i < 0) {
639 return null;
640 }
641
642 var $topBlanketedLayer = this._stack[i];
643 var layer = createLayer($topBlanketedLayer);
644
645 if (layer.isPersistent()) {
646 // We can't pop the blanketed layer, only the things ontop
647 var $next = this.getNextHigherNonPeristentAndNonBlanketedLayer($topBlanketedLayer);
648 if ($next) {
649 var stopIndex = layerIndex(this._stack, $next);
650 popLayers(this._stack, stopIndex, true, triggerBeforeEvents);
651 return $next;
652 }
653 return null;
654 }
655
656 popLayers(this._stack, i, true);
657 updateBlanket(this._stack, i);
658 return $topBlanketedLayer;
659 },
660
661 /**
662 * Pops all layers above and including the top persistent layer. If layers exist but none are persistent, this method
663 * does nothing.
664 */
665 popUntilTopPersistent: function (triggerBeforeEvents = false) {
666 var $toPop = LayerManager.global.getTopLayer();
667 if (!$toPop) {
668 return;
669 }
670
671 var stopIndex;
672 var oldTopBlanketedIndex = topBlanketedIndex(this._stack);
673
674 var toPop = createLayer($toPop);
675 if (toPop.isPersistent()) {
676 if (toPop.isBlanketed()) {
677 return;
678 } else {
679 // Get the closest non modal layer below, stop at the first blanketed layer though, we don't want to pop below that
680 $toPop = LayerManager.global.getNextLowerNonPersistentOrBlanketedLayer($toPop);
681 toPop = createLayer($toPop);
682
683 if ($toPop && !toPop.isPersistent()) {
684 stopIndex = layerIndex(this._stack, $toPop);
685 popLayers(this._stack, stopIndex, true, triggerBeforeEvents);
686 updateBlanket(this._stack, oldTopBlanketedIndex);
687 } else {
688 // Here we have a blanketed persistent layer
689 return;
690 }
691 }
692 } else {
693 stopIndex = layerIndex(this._stack, $toPop);
694 popLayers(this._stack, stopIndex, true, triggerBeforeEvents);
695 updateBlanket(this._stack, oldTopBlanketedIndex);
696 }
697 }
698};
699
700// LayerManager.global
701// -------------------
702
703function initCloseLayerOnEscPress() {
704 $doc.on('keydown', function (e) {
705 if (e.keyCode === keyCode.ESCAPE) {
706 LayerManager.global.popUntilTopPersistent(true);
707 e.preventDefault();
708 }
709 });
710}
711
712function initCloseLayerOnBlanketClick() {
713 $doc.on('click', '.aui-blanket', function (e) {
714 if (LayerManager.global.popUntilTopBlanketed(true)) {
715 e.preventDefault();
716 }
717 });
718}
719
720function hasLayer($trigger) {
721 if (!$trigger.length) {
722 return false;
723 }
724
725 var layer = document.getElementById($trigger.attr('aria-controls'));
726 return LayerManager.global.indexOf(layer) > -1;
727}
728
729// If it's a click on a trigger, do nothing.
730// If it's a click on a layer, close all layers above.
731// Otherwise, close all layers.
732function initCloseLayerOnOuterClick () {
733 $doc.on('click', function (e) {
734 var $target = $(e.target);
735 if ($target.closest('.aui-blanket').length) {
736 return;
737 }
738
739 var $trigger = $target.closest('[aria-controls]');
740 var $layer = $target.closest('.aui-layer');
741 if (!$layer.length && !hasLayer($trigger)) {
742 const customEvent = $.Event('aui-close-layers-on-outer-click');
743 $doc.trigger(customEvent);
744 if (customEvent.isDefaultPrevented()) {
745 e.preventDefault();
746 return;
747 }
748 LayerManager.global.hideAll();
749 return;
750 }
751
752 // Triggers take precedence over layers
753 if (hasLayer($trigger)) {
754 return;
755 }
756
757 if ($layer.length) {
758 // We dont want to explicitly call close on a modal dialog if it happens to be next.
759 // All blanketed layers should be below us, as otherwise the blanket should have caught the click.
760 // We make sure we dont close a blanketed one explicitly as a hack, this is to fix the problem arising
761 // from dialog2 triggers inside dialog2's having no aria controls, where the dialog2 that was just
762 // opened would be closed instantly
763 var $next = LayerManager.global.getNextHigherNonPeristentAndNonBlanketedLayer($layer);
764
765 if ($next) {
766 createLayer($next).hide();
767 }
768 }
769 });
770}
771
772initCloseLayerOnEscPress();
773initCloseLayerOnBlanketClick();
774initCloseLayerOnOuterClick();
775
776LayerManager.global = new LayerManager();
777createLayer.Manager = LayerManager;
778
779globalize('layer', createLayer);
780
781export default createLayer;