UNPKG

@atlassian/aui

Version:

Atlassian User Interface library

450 lines (394 loc) 13.8 kB
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;