@atlassian/aui
Version:
Atlassian User Interface library
807 lines (688 loc) • 23.7 kB
JavaScript
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;