UNPKG

@atlassian/aui

Version:

Atlassian User Interface library

807 lines (688 loc) 23.7 kB
import $ from './jquery'; import { dim, undim } from './blanket'; import FocusManager from './focus-manager'; import { getTrigger, hasTrigger } from './trigger'; import globalize from './internal/globalize'; import keyCode from './key-code'; import widget from './internal/widget'; import CustomEvent from './polyfills/custom-event'; export const EVENT_PREFIX = '_aui-internal-layer-'; const GLOBAL_EVENT_PREFIX = '_aui-internal-layer-global-'; const LAYER_EVENT_PREFIX = 'aui-layer-'; const AUI_EVENT_PREFIX = 'aui-'; const ATTR_MODAL = 'modal'; const ATTR_DOM_CONTAINER = 'dom-container'; const ZINDEX_AUI_LAYER_MIN = 3000; var $doc = $(document); // AUI-3708 - Abstracted to reflect code implemented upstream. function isTransitioning(el, prop) { var transition = window.getComputedStyle(el).transitionProperty; return transition ? transition.indexOf(prop) > -1 : false; } function onTransitionEnd(el, prop, func, once) { function handler(e) { if (prop !== e.propertyName) { return; } func.call(el); if (once) { el.removeEventListener('transitionend', handler); } } if (isTransitioning(el, prop)) { el.addEventListener('transitionend', handler); } else { func.call(el); } } function oneTransitionEnd(el, prop, func) { onTransitionEnd(el, prop, func, true); } // end AUI-3708 /** * @return {bool} Returns false if at least one of the event handlers called .preventDefault(). Returns true otherwise. */ function triggerEvent($el, deprecatedName, newNativeName) { var e1 = $.Event(EVENT_PREFIX + deprecatedName); var e2 = $.Event(GLOBAL_EVENT_PREFIX + deprecatedName); // TODO: Remove this 'aui-layer-' prefixed event once it is no longer used by inline dialog and dialog2. var nativeEvent = new CustomEvent(LAYER_EVENT_PREFIX + newNativeName, { bubbles: true, cancelable: true, }); var nativeEvent2 = new CustomEvent(AUI_EVENT_PREFIX + newNativeName, { bubbles: true, cancelable: true, }); $el.trigger(e1); $el.trigger(e2, [$el]); $el[0].dispatchEvent(nativeEvent); $el[0].dispatchEvent(nativeEvent2); return ( !e1.isDefaultPrevented() && !e2.isDefaultPrevented() && !nativeEvent.defaultPrevented && !nativeEvent2.defaultPrevented ); } function Layer(selector) { this.$el = $(selector || '<div class="aui-layer"></div>'); this.el = this.$el[0]; this.$el.addClass('aui-layer'); } function getAttribute(el, name) { return el.getAttribute(name) || el.getAttribute('data-aui-' + name); } Layer.prototype = { /** * Returns the layer below the current layer if it exists. * * @returns {jQuery | undefined} */ below: function () { return LayerManager.global.item(LayerManager.global.indexOf(this.$el) - 1); }, /** * Returns the layer above the current layer if it exists. * * @returns {jQuery | undefined} */ above: function () { return LayerManager.global.item(LayerManager.global.indexOf(this.$el) + 1); }, /** * Sets the width and height of the layer. * * @param {number} width The width to set. * @param {number} height The height to set. * * @returns {Layer} */ changeSize: function (width, height) { this.$el.css('width', width); this.$el.css('height', height === 'content' ? '' : height); return this; }, /** * Binds a layer event. * * @param {String} event The event name to listen to. * @param {Function} fn The event handler. * * @returns {Layer} */ on: function (event, fn) { this.$el.on(EVENT_PREFIX + event, fn); return this; }, /** * Unbinds a layer event. * * @param {String} event The event name to unbind=. * @param {Function} fn Optional. The event handler. * * @returns {Layer} */ off: function (event, fn) { this.$el.off(EVENT_PREFIX + event, fn); return this; }, /** * Shows the layer. * * The layer is added to LayerManager stack. * * @returns {Layer} */ show: function () { if (this.isVisible() || LayerManager.global.indexOf(this.$el) > -1) { // do nothing if already shown return this; } if (!triggerEvent(this.$el, 'beforeShow', 'show')) { return this; } // AUI-3708 // Ensures that the display property is removed if it's been added // during hiding. if (this.$el.css('display') === 'none') { this.$el.css('display', ''); } LayerManager.global.push(this.$el); return this; }, /** * Hides the layer. * * The layer is removed from LayerManager stack. * * @returns {Layer} */ hide: function () { if (!this.isVisible()) { // do nothing if already hidden return this; } // AUI-3708 const thisLayer = this; oneTransitionEnd(this.$el.get(0), 'opacity', function () { if (!thisLayer.isVisible()) { this.style.display = 'none'; } }); LayerManager.global.popUntil(this.$el, true); return this; }, /** * Checks to see if the layer is visible. * * @returns {Boolean} */ isVisible: function () { return this.$el.data('_aui-layer-shown') === true; }, /** * Removes the layer and cleans up internal state. * * @returns {undefined} */ remove: function () { this.hide(); this.$el.remove(); this.$el = null; this.el = null; }, /** * Returns whether or not the layer is blanketed. * * @returns {Boolean} */ isBlanketed: function () { return this.el.dataset.auiBlanketed === 'true'; }, /** * Returns whether or not the layer is persistent. * * @returns {Boolean} */ isPersistent: function () { var modal = getAttribute(this.el, ATTR_MODAL); var isPersistent = this.el.hasAttribute('persistent'); return modal === 'true' || isPersistent; }, /** * Returns element used to attach the component to onto render. * * Looks for a selector in specified attribute and returns Element matching that selector. * If attribute is set but the selector matches multiple elements - it will default to first available match. * If attribute is set but the selector does not match to any existing elements it will default to document.body * If the attribute is not set it will return null * * @returns {(Element|null)} */ getDOMContainer: function () { let container = getAttribute(this.el, ATTR_DOM_CONTAINER); if (container) { container = document.querySelector(container) || document.body; } return container; }, _hideLayer: function (triggerBeforeEvents) { if (triggerBeforeEvents) { if (!triggerEvent(this.$el, 'beforeHide', 'hide')) { return false; } } if (this.isPersistent() || this.isBlanketed()) { FocusManager.global.exit(this.$el); } // don't remove via jquery; that would cause this method to get re-called once or twice more :\ this.el.removeAttribute('open'); this.$el.removeData('_aui-layer-shown'); this.$el.css('z-index', this.$el.data('_aui-layer-cached-z-index') || ''); this.$el.data('_aui-layer-cached-z-index', ''); this.$el.trigger(EVENT_PREFIX + 'hide'); this.$el.trigger(GLOBAL_EVENT_PREFIX + 'hide', [this.$el]); return true; }, _showLayer: function (zIndex) { let domContainer = this.getDOMContainer(); if (this.isBlanketed() || !!domContainer) { let parent = domContainer || 'body'; if (!this.$el.parent().is(parent)) { this.$el.appendTo(parent); } } this.$el.data('_aui-layer-shown', true); this.$el.data('_aui-layer-cached-z-index', this.$el.css('z-index')); this.$el.css('z-index', zIndex); this.el.removeAttribute('hidden'); this.el.setAttribute('open', ''); if (this.isBlanketed()) { FocusManager.global.enter(this.$el); } this.$el.trigger(EVENT_PREFIX + 'show'); this.$el.trigger(GLOBAL_EVENT_PREFIX + 'show', [this.$el]); }, }; var createLayer = widget('layer', Layer); createLayer.on = function (eventName, selector, fn) { $doc.on(GLOBAL_EVENT_PREFIX + eventName, selector, fn); return this; }; createLayer.off = function (eventName, selector, fn) { $doc.off(GLOBAL_EVENT_PREFIX + eventName, selector, fn); return this; }; // Layer Manager // ------------- /** * Manages layers. * * There is a single global layer manager. * Additional instances can be created however this should generally only be used in tests. * * Layers are added by the push($el) method. Layers are removed by the * popUntil($el) method. * * popUntil's contract is that it pops all layers above & including the given * layer. This is used to support popping multiple layers. * Say we were showing a dropdown inside an inline dialog inside a dialog - we * have a stack of dialog layer, inline dialog layer, then dropdown layer. Calling * popUntil(dialog.$el) would hide all layers above & including the dialog. */ function topIndexWhere(layerArr, fn) { var i = layerArr.length; while (i--) { if (fn(layerArr[i])) { return i; } } return -1; } function layerIndex(layerArr, $el) { return topIndexWhere(layerArr, function ($layer) { return $layer[0] === $el[0]; }); } function topBlanketedIndex(layerArr) { return topIndexWhere(layerArr, function ($layer) { return createLayer($layer).isBlanketed(); }); } function nextZIndex(layerArr) { var _nextZIndex; if (layerArr.length) { var $topEl = layerArr[layerArr.length - 1]; var zIndex = parseInt($topEl.css('z-index'), 10); _nextZIndex = (isNaN(zIndex) ? 0 : zIndex) + 100; } else { _nextZIndex = 0; } return Math.max(ZINDEX_AUI_LAYER_MIN, _nextZIndex); } function updateBlanket(stack, oldBlanketIndex) { var newTopBlanketedIndex = topBlanketedIndex(stack); if (oldBlanketIndex !== newTopBlanketedIndex) { if (newTopBlanketedIndex > -1) { dim(false, stack[newTopBlanketedIndex].css('z-index') - 20); } else { undim(); } } } function popLayers(stack, stopIndex, forceClosePersistent, triggerBeforeEvents = true) { if (stopIndex < 0) { return [false, null]; } var $layer; for (var a = stack.length - 1; a >= stopIndex; a--) { $layer = stack[a]; var layer = createLayer($layer); if (forceClosePersistent || !layer.isPersistent()) { if (!layer._hideLayer(triggerBeforeEvents)) { return [false, $layer]; } stack.splice(a, 1); } } return [true, $layer]; } function getParentLayer(layer) { var trigger = getTrigger(layer); if (trigger) { return $(trigger).closest('.aui-layer').get(0); } } function LayerManager() { this._stack = []; } LayerManager.prototype = { /** * Pushes a layer onto the stack. The same element cannot be opened as a layer multiple times - if the given * element is already an open layer, this method throws an exception. * * @param {HTMLElement | String | jQuery} element The element to push onto the stack. * * @returns {LayerManager} */ push: function (element) { var $el = element instanceof $ ? element : $(element); if (layerIndex(this._stack, $el) >= 0) { throw new Error('The given element is already an active layer.'); } this.popLayersBeside($el); var layer = createLayer($el); var zIndex = nextZIndex(this._stack); layer._showLayer(zIndex); if (layer.isBlanketed()) { dim(false, zIndex - 20); } this._stack.push($el); return this; }, popLayersBeside: function (element) { const layer = $(element).get(0); if (!hasTrigger(layer)) { // We can't find this layer's trigger, we will pop all non-persistent until a blanket or the document var blanketedIndex = topBlanketedIndex(this._stack); popLayers(this._stack, ++blanketedIndex, false); return; } const parentLayer = getParentLayer(layer); if (parentLayer) { let parentIndex = this.indexOf(parentLayer); popLayers(this._stack, ++parentIndex, false); } else { popLayers(this._stack, 0, false); } }, /** * Returns the index of the specified layer in the layer stack. * * @param {HTMLElement | String | jQuery} element The element to find in the stack. * * @returns {Number} the (zero-based) index of the element, or -1 if not in the stack. */ indexOf: function (element) { return layerIndex(this._stack, $(element)); }, /** * Returns the item at the particular index or false. * * @param {Number} index The index of the element to get. * * @returns {jQuery | Boolean} */ item: function (index) { return this._stack[index]; }, /** * Hides all layers in the stack. * * @returns {LayerManager} */ hideAll: function () { this._stack .slice() .reverse() .forEach(function (element) { let layer = createLayer(element); if (layer.isBlanketed() || layer.isPersistent()) { return; } layer.hide(); }); return this; }, /** * Gets the previous layer below the given layer, which is non modal and non persistent. If it finds a blanketed layer on the way * it returns it regardless if it is modal or not * * @param {HTMLElement | String | jQuery} element layer to start the search from. * * @returns {jQuery | null} the next matching layer or null if none found. */ getNextLowerNonPersistentOrBlanketedLayer: function (element) { var $el = element instanceof $ ? element : $(element); var index = layerIndex(this._stack, $el); if (index < 0) { return null; } var $nextEl; index--; while (index >= 0) { $nextEl = this._stack[index]; var layer = createLayer($nextEl); if (!layer.isPersistent() || layer.isBlanketed()) { return $nextEl; } index--; } return null; }, /** * Gets the next layer which is neither modal or blanketed, from the given layer. * * @param {HTMLElement | String | jQuery} element layer to start the search from. * * @returns {jQuery | null} the next non modal non blanketed layer or null if none found. */ getNextHigherNonPeristentAndNonBlanketedLayer: function (element) { var $el = element instanceof $ ? element : $(element); var index = layerIndex(this._stack, $el); if (index < 0) { return null; } var $nextEl; index++; while (index < this._stack.length) { $nextEl = this._stack[index]; var layer = createLayer($nextEl); if (!(layer.isPersistent() || layer.isBlanketed())) { return $nextEl; } index++; } return null; }, /** * Gets the top layer, if it exists. * * @returns The layer on top of the stack, if it exists, otherwise null. */ getTopLayer: function () { return this._stack[this._stack.length - 1] || null; }, /** * Get the top open layer, if it exists. * * @return The first open layer in the stack, if it exists, otherwise null. */ getTopOpenLayer: function () { return ( this._stack .slice() .reverse() .find((layer) => layer.attr('open')) || null ); }, /** * Removes all non-modal layers above & including the given element. If the given element is not an active layer, this method * is a no-op. The given element will be removed regardless of whether or not it is modal. * * @param {HTMLElement | String | jQuery} element layer to pop. * @param {boolean} [triggerBeforeEvents=false] * * @returns {jQuery} The last layer that was popped, or null if no layer matching the given $el was found. */ popUntil: function (element, triggerBeforeEvents = false) { var $el = element instanceof $ ? element : $(element); var index = layerIndex(this._stack, $el); if (index === -1) { return null; } const oldTopBlanketedIndex = topBlanketedIndex(this._stack); // Removes all layers above the current one. const layer = createLayer($el); const [success, $lastPopped] = popLayers( this._stack, index + 1, layer.isBlanketed(), triggerBeforeEvents ); if (!success) { return $lastPopped; } // Removes the current layer. if (!layer._hideLayer(triggerBeforeEvents)) { return $lastPopped; } this._stack.splice(index, 1); updateBlanket(this._stack, oldTopBlanketedIndex); return $el; }, /** * Pops the top layer, if it exists and it is non modal and non persistent. * * @returns The layer that was popped, if it was popped. */ popTopIfNonPersistent: function (triggerBeforeEvents = false) { var $topLayer = this.getTopLayer(); var layer = createLayer($topLayer); if (!$topLayer || layer.isPersistent()) { return null; } return this.popUntil($topLayer, triggerBeforeEvents); }, /** * Pops all layers above and including the top blanketed layer. If layers exist but none are blanketed, this method * does nothing. * * @returns The blanketed layer that was popped, if it exists, otherwise null. */ popUntilTopBlanketed: function (triggerBeforeEvents = false) { var i = topBlanketedIndex(this._stack); if (i < 0) { return null; } var $topBlanketedLayer = this._stack[i]; var layer = createLayer($topBlanketedLayer); if (layer.isPersistent()) { // We can't pop the blanketed layer, only the things ontop var $next = this.getNextHigherNonPeristentAndNonBlanketedLayer($topBlanketedLayer); if ($next) { var stopIndex = layerIndex(this._stack, $next); popLayers(this._stack, stopIndex, true, triggerBeforeEvents); return $next; } return null; } popLayers(this._stack, i, true); updateBlanket(this._stack, i); return $topBlanketedLayer; }, /** * Pops all layers above and including the top persistent layer. If layers exist but none are persistent, this method * does nothing. */ popUntilTopPersistent: function (triggerBeforeEvents = false) { var $toPop = LayerManager.global.getTopLayer(); if (!$toPop) { return; } var stopIndex; var oldTopBlanketedIndex = topBlanketedIndex(this._stack); var toPop = createLayer($toPop); if (toPop.isPersistent()) { if (toPop.isBlanketed()) { return; } else { // Get the closest non modal layer below, stop at the first blanketed layer though, we don't want to pop below that $toPop = LayerManager.global.getNextLowerNonPersistentOrBlanketedLayer($toPop); toPop = createLayer($toPop); if ($toPop && !toPop.isPersistent()) { stopIndex = layerIndex(this._stack, $toPop); popLayers(this._stack, stopIndex, true, triggerBeforeEvents); updateBlanket(this._stack, oldTopBlanketedIndex); } else { // Here we have a blanketed persistent layer return; } } } else { stopIndex = layerIndex(this._stack, $toPop); popLayers(this._stack, stopIndex, true, triggerBeforeEvents); updateBlanket(this._stack, oldTopBlanketedIndex); } }, }; // LayerManager.global // ------------------- function initCloseLayerOnEscPress() { $doc.on('keydown', function (e) { if (e.keyCode === keyCode.ESCAPE) { LayerManager.global.popUntilTopPersistent(true); e.preventDefault(); } }); } function initCloseLayerOnBlanketClick() { $doc.on('click', '.aui-blanket', function (e) { if (LayerManager.global.popUntilTopBlanketed(true)) { e.preventDefault(); } }); } function hasLayer($trigger) { if (!$trigger.length) { return false; } var layer = document.getElementById($trigger.attr('aria-controls')); return LayerManager.global.indexOf(layer) > -1; } // If it's a click on a trigger, do nothing. // If it's a click on a layer, close all layers above. // Otherwise, close all layers. function initCloseLayerOnOuterClick() { $doc.on('click', function (e) { var $target = $(e.target); if ($target.closest('.aui-blanket').length) { return; } var $trigger = $target.closest('[aria-controls]'); var $layer = $target.closest('.aui-layer'); if (!$layer.length && !hasLayer($trigger)) { const customEvent = $.Event('aui-close-layers-on-outer-click'); $doc.trigger(customEvent); if (customEvent.isDefaultPrevented()) { e.preventDefault(); return; } LayerManager.global.hideAll(); return; } // Triggers take precedence over layers if (hasLayer($trigger)) { return; } if ($layer.length) { // We dont want to explicitly call close on a modal dialog if it happens to be next. // All blanketed layers should be below us, as otherwise the blanket should have caught the click. // We make sure we dont close a blanketed one explicitly as a hack, this is to fix the problem arising // from dialog2 triggers inside dialog2's having no aria controls, where the dialog2 that was just // opened would be closed instantly var $next = LayerManager.global.getNextHigherNonPeristentAndNonBlanketedLayer($layer); if ($next) { createLayer($next).hide(); } } }); } initCloseLayerOnEscPress(); initCloseLayerOnBlanketClick(); initCloseLayerOnOuterClick(); LayerManager.global = new LayerManager(); createLayer.Manager = LayerManager; globalize('layer', createLayer); export default createLayer;