@atlassian/aui
Version:
Atlassian User Interface library
450 lines (394 loc) • 13.8 kB
JavaScript
import $ from './jquery';
import Alignment from './internal/alignment';
import amdify from './internal/amdify';
import attributes, { setBooleanAttribute } from './internal/attributes';
import enforce from './internal/enforcer';
import globalize from './internal/globalize';
import layer, { EVENT_PREFIX } from './layer';
import skate from './internal/skate';
import state from './internal/state';
import { doIfTrigger, forEachTrigger, getTrigger, setTrigger } from './trigger';
import { ifGone } from './internal/elements';
import getFocusManager from './focus-manager';
const DEFAULT_HOVEROUT_DELAY = 1000;
function changeTrigger(element, newTrigger) {
if (isPopupMenu(element)) {
doIfTrigger(element, function (oldTrigger) {
oldTrigger.setAttribute('aria-expanded', 'false');
newTrigger.setAttribute('aria-expanded', element.open);
});
}
setTrigger(element, newTrigger);
}
function enableAlignment(element, trigger) {
if (element._auiAlignment) {
element._auiAlignment.changeTarget(trigger);
element._auiAlignment.enable();
} else {
let alignmentOptions = {
overflowContainer:
element.getAttribute('contained-by') === 'viewport' ? 'viewport' : 'window',
positionFixed: false,
eventsEnabled: true,
};
element._auiAlignment = new Alignment(element, trigger, alignmentOptions);
}
}
function disableAlignment(element) {
if (element._auiAlignment) {
element._auiAlignment.disable();
}
}
function destroyAlignment(element) {
if (element._auiAlignment) {
element._auiAlignment.destroy();
delete element._auiAlignment;
}
}
function userInteractingWith(element) {
return state(element).get('mouse-inside') || element.contains(document.activeElement);
}
function showOnEnter(element, e) {
var newTrigger = e.currentTarget;
if (newTrigger) {
changeTrigger(element, newTrigger);
enableAlignment(element, newTrigger);
}
if (!element.open) {
element.open = true;
}
clearTimeout(element._closingTimeout);
}
function closeAfter(delay = 0) {
return function closing(element) {
if (!element.open || layer(element).isPersistent()) {
return;
}
clearTimeout(element._closingTimeout);
element._closingTimeout = setTimeout(function () {
if (!userInteractingWith(element)) {
element.open = false;
}
element._closingTimeout = null;
}, delay);
};
}
const messageHandler = {
click(element, e) {
if (element.open && !layer(element).isPersistent()) {
element.open = false;
} else {
changeTrigger(element, e.currentTarget);
element.open = true;
}
clearTimeout(element._closingTimeout);
},
mouseenter: showOnEnter,
mouseleave: closeAfter(DEFAULT_HOVEROUT_DELAY),
focus: showOnEnter,
blur: closeAfter(0),
};
function handleMessage(element, message) {
var messageTypeMap = {
toggle: ['click'],
hover: ['mouseenter', 'mouseleave', 'focus', 'blur'],
};
var messageList = messageTypeMap[element.respondsTo];
if (messageList && messageList.indexOf(message.type) > -1) {
messageHandler[message.type](element, message);
}
}
function onMouseEnter(e) {
var element = e.currentTarget;
state(element).set('mouse-inside', true);
element.message({
type: 'mouseenter',
});
}
function onMouseLeave(e) {
var element = e.currentTarget;
state(element).set('mouse-inside', false);
element.message({
type: 'mouseleave',
});
}
function onBlur(e) {
var element = e.currentTarget;
if (element.respondsTo === 'hover') {
closeAfter(DEFAULT_HOVEROUT_DELAY)(element);
}
}
function rebindMouseEvents(el) {
state(el).set('mouse-inside', undefined);
el.removeEventListener('mouseenter', onMouseEnter);
el.removeEventListener('mouseleave', onMouseLeave);
el.removeEventListener('blur', onBlur);
if (el.respondsTo === 'hover') {
state(el).set('mouse-inside', false);
el.addEventListener('mouseenter', onMouseEnter);
el.addEventListener('mouseleave', onMouseLeave);
el.addEventListener('blur', onBlur);
}
}
function namespaceEvent(eventName, elId) {
return `${eventName}.nested-layer-${elId}`;
}
function setupNestedLayerHandlers(el) {
let $el = $(el);
const elId = el.id;
const noNestedTriggers = (e) => {
return $el.find(getTrigger(e.target)).length < 1;
};
// Temporary timeout variable to resolve AUI-5025 issue
// as described in further detail below.
const selectCloseTimeout = 150;
$(document)
.on(namespaceEvent('aui-layer-show', elId), (e) => {
if (noNestedTriggers(e)) {
return;
}
$el.attr('persistent', '');
})
.on(namespaceEvent('aui-layer-hide', elId), (e) => {
if (noNestedTriggers(e)) {
return;
}
$el.removeAttr('persistent');
})
.on(namespaceEvent('select2-opening', elId), () => {
$el.attr('persistent', '');
})
// Relates to solving AUI-5025
// Temporary solution to race condition with select2,
// where this runs before select2's dropdown is closed.
.on(namespaceEvent('select2-close', elId), () => {
setTimeout(() => {
$el.removeAttr('persistent');
}, selectCloseTimeout);
});
}
function teardownNestedLayerHandlers(elId) {
$(document)
.off(namespaceEvent('aui-layer-hide', elId))
.off(namespaceEvent('aui-layer-show', elId))
.off(namespaceEvent('select2-opening', elId))
.off(namespaceEvent('select2-close', elId));
}
/**
* @param element the inline dialog to show
* @returns {boolean} true if the show was successful, false if it was prevented.
*/
function maybeShow(element) {
layer(element).show();
return layer(element).isVisible() === true;
}
/**
* @param element the inline dialog to hide
* @returns {boolean} true if the hide was successful, false if it was prevented.
*/
function maybeHide(element) {
layer(element).hide();
return layer(element).isVisible() === false;
}
function shouldFocus(element) {
return element.respondsTo !== 'hover';
}
function isPopupMenu(element) {
return element.getAttribute('role') !== 'dialog';
}
/**
* Abstracted as skate fires custom attributes handlers before the component creation if they are pre-populated.
*
* @param element the inline dialog to initialise
* @returns {undefined}
*/
function maybeInitialise(element) {
// One to rule them all
if (element.__initialised) {
return;
}
layer(element);
$(element).on({
// fired only after the layer is shown
[`${EVENT_PREFIX}show`]: function (e) {
const el = this;
// This handler can be fired by nested layer/component.
// We need to be sure that the event was triggered by the inline dialog;
if (e.target !== el) {
// otherwise skip.
return;
}
setupNestedLayerHandlers(el);
doIfTrigger(el, function (trigger) {
if (shouldFocus(el)) {
// Focus manager will focus the popup and link popup to the trigger.
getFocusManager().enter($(el), $(trigger));
}
enableAlignment(el, trigger);
if (isPopupMenu(el)) {
trigger.setAttribute('aria-expanded', 'true');
}
});
},
// fired only after the layer is hidden
[`${EVENT_PREFIX}hide`]: function (e) {
const el = this;
// This handler can be fired by nested layer/component.
// We need to be sure that the event was triggered by the inline dialog;
if (e.target !== el) {
// otherwise skip.
return;
}
teardownNestedLayerHandlers(el.id);
// in case the element has been removed from DOM already
// disablingAlignment may fail if trigger is an anchor due to Popper's logic
// wrongly recognising it as ShadowRoot
if (el.ownerDocument.body.contains(el)) {
disableAlignment(el);
} else {
destroyAlignment(el);
}
doIfTrigger(el, function (trigger) {
if (shouldFocus(el)) {
// Focus manager will focus the trigger that is linked with the popup element.
getFocusManager().exit($(el));
}
if (isPopupMenu(el)) {
trigger.setAttribute('aria-expanded', 'false');
}
});
setTrigger(el, null);
},
});
element.__initialised = true;
}
const RESPONDS_TO_ATTRIBUTE_ENUM = {
attribute: 'responds-to',
values: ['toggle', 'hover'],
missingDefault: 'toggle',
invalidDefault: 'toggle',
};
const InlineDialogEl = skate('aui-inline-dialog', {
prototype: {
/**
* Returns whether the inline dialog is open.
*/
get open() {
return layer(this).isVisible();
},
/**
* Opens or closes the inline dialog, returning whether the dialog is
* open or closed as a result (since event handlers can prevent either
* action).
*
* You should check the value of open after setting this
* value since the before show/hide events may have prevented it.
*/
set open(value) {
// a flag to help avoid things getting called a second time via the attribute mutation handler
this.__propUpdate = true;
if (value) {
maybeShow(this);
} else {
maybeHide(this);
}
},
get persistent() {
return this.hasAttribute('persistent');
},
set persistent(value) {
attributes.setBooleanAttribute(this, 'persistent', value);
},
get respondsTo() {
var attr = RESPONDS_TO_ATTRIBUTE_ENUM.attribute;
return attributes.computeEnumValue(RESPONDS_TO_ATTRIBUTE_ENUM, this.getAttribute(attr));
},
set respondsTo(value) {
const oldComputedValue = this.respondsTo;
attributes.setEnumAttribute(this, RESPONDS_TO_ATTRIBUTE_ENUM, value);
if (oldComputedValue !== this.respondsTo) {
rebindMouseEvents(this);
}
},
/**
* Handles the receiving of a message from another component.
*
* @param {Object} msg The message to act on.
*
* @returns {HTMLElement}
*/
message: function (msg) {
handleMessage(this, msg);
return this;
},
},
attributes: {
'open': function (element, change) {
maybeInitialise(element);
if (element.__propUpdate) {
// prevent property updates cascading in to sync or async attribute updates
delete element.__propUpdate;
return;
}
if (change.type === 'created') {
const success = maybeShow(element);
if (!success) {
setBooleanAttribute(element, 'open', false);
}
}
if (change.type === 'removed') {
const success = maybeHide(element);
if (!success) {
setBooleanAttribute(element, 'open', true);
}
}
},
'responds-to': function (element, change) {
const oldComputedValue = attributes.computeEnumValue(
RESPONDS_TO_ATTRIBUTE_ENUM,
change.oldValue
);
const newComputedValue = attributes.computeEnumValue(
RESPONDS_TO_ATTRIBUTE_ENUM,
change.newValue
);
if (oldComputedValue !== newComputedValue) {
rebindMouseEvents(element);
}
},
},
created: maybeInitialise,
attached: function (element) {
enforce(element).attributeExists('id');
element.setAttribute('tabindex', 0);
if (isPopupMenu(element)) {
element.setAttribute('role', 'group');
doIfTrigger(element, function (trigger) {
trigger.setAttribute('aria-expanded', element.open);
});
forEachTrigger(element, function (trigger) {
trigger.setAttribute('aria-haspopup', 'true');
});
}
rebindMouseEvents(element);
},
detached: function (element) {
ifGone(element).then(() => {
destroyAlignment(element);
if (isPopupMenu(element)) {
forEachTrigger(element, function (trigger) {
trigger.removeAttribute('aria-haspopup');
trigger.removeAttribute('aria-expanded');
});
}
});
},
template: function (element) {
$('<div class="aui-inline-dialog-contents"></div>')
.append(element.childNodes)
.appendTo(element);
},
});
amdify('aui/inline-dialog2', InlineDialogEl);
globalize('InlineDialog2', InlineDialogEl);
export default InlineDialogEl;