@atlassian/aui
Version:
Atlassian User Interface library
1,205 lines (1,039 loc) • 39.3 kB
JavaScript
import { I18n } from './i18n';
import './spinner';
import $ from './jquery';
import template from 'skatejs-template-html';
import * as deprecate from './internal/deprecation';
import * as logger from './internal/log';
import { debounce } from 'underscore';
import { supportsVoiceOver } from './internal/browser';
import Alignment from './internal/alignment';
import CustomEvent from './polyfills/custom-event';
import keyCode from './key-code';
import layer from './layer';
import state from './internal/state';
import skate from './internal/skate';
import escapeHtml from './escape-html';
import { ifGone } from './internal/elements';
import { doIfTrigger, setTrigger } from './trigger';
import generateUniqueId from './unique-id';
function isHidden(el) {
return el.hasAttribute('hidden') || el.classList.contains('hidden');
}
function setDropdownTriggerActiveState(trigger, isActive) {
trigger.setAttribute('aria-expanded', !!isActive);
trigger.classList[isActive ? 'add' : 'remove']('active', 'aui-dropdown2-active');
}
/**
* Focus the appropriate thing when a dropdown is shown.
* The element focussed will vary depending on the type of dropdown and
* the input modality.
*
* @param {HTMLElement} dropdown - the dropdown being shown
* @param {Event} [e] - the event that triggered the dropdown being shown
*/
function handleFocus(dropdown, e = {}) {
if (e && e.type && e.type.indexOf('mouse') === -1) {
const isKeyDownEvent = e.type.indexOf('key') > -1;
if (dropdown.isSubmenu) {
dropdown.focusItem(0);
} else if (isKeyDownEvent) {
const isUpArrow = e.keyCode === keyCode.UP;
// set focus to last item in the menu to navigate bottom -> up
if (isUpArrow) {
const visibleItems = getVisibleDropdownItems(dropdown);
if (visibleItems && visibleItems.length) {
dropdown.focusItem(visibleItems.length - 1);
}
} else {
// if enter/space/downArrow than focus goes to the first item
dropdown.focusItem(0);
}
} else {
dropdown.focus();
}
}
}
// LOADING STATES
var UNLOADED = 'unloaded';
var LOADING = 'loading';
var ERROR = 'error';
var SUCCESS = 'success';
// ASYNC DROPDOWN FUNCTIONS
function makeAsyncDropdownContents(json) {
var dropdownContents = json
.map(function makeSection(sectionData) {
var sectionItemsHtml = sectionData.items
.map(function makeSectionItem(itemData) {
function makeBooleanAttribute(attr) {
return itemData[attr] ? `${attr} ="true"` : '';
}
function makeAttribute(attr) {
return itemData[attr] ? `${attr}="${itemData[attr]}"` : '';
}
var tagName = 'aui-item-' + itemData.type;
var itemHtml = `
<${tagName} ${makeAttribute('for')} ${makeAttribute('href')} ${makeBooleanAttribute('interactive')}
${makeBooleanAttribute('checked')} ${makeBooleanAttribute('disabled')} ${makeBooleanAttribute('hidden')}>
${escapeHtml(itemData.content)}
</${tagName}>`;
return itemHtml;
})
.join('');
var sectionAttributes = sectionData.label ? `label="${sectionData.label}"` : '';
var sectionHtml = `
<aui-section ${sectionAttributes}>
${sectionItemsHtml}
</aui-section>`;
return sectionHtml;
})
.join('\n');
return dropdownContents;
}
function setDropdownContents(dropdown, json) {
state(dropdown).set('loading-state', SUCCESS);
dropdown.innerHTML = makeAsyncDropdownContents(json);
skate.init(dropdown);
}
function setDropdownErrorState(dropdown) {
state(dropdown).set('loading-state', ERROR);
state(dropdown).set('hasErrorBeenShown', dropdown.isVisible());
dropdown.innerHTML = `
<div class="aui-message aui-message-error aui-dropdown-error">
<p>${I18n.getText('aui.dropdown.async.error')}</p>
</div>
`;
}
function setDropdownLoadingState(dropdown) {
state(dropdown).set('loading-state', LOADING);
state(dropdown).set('hasErrorBeenShown', false);
doIfTrigger(dropdown, function (trigger) {
trigger.setAttribute('aria-busy', 'true');
});
dropdown.innerHTML = `
<div class="aui-dropdown-loading">
<aui-spinner size="small"></aui-spinner> ${I18n.getText('aui.dropdown.async.loading')}
</div>
`;
}
function setDropdownLoaded(dropdown) {
doIfTrigger(dropdown, function (trigger) {
trigger.setAttribute('aria-busy', 'false');
});
}
function loadContentsIfAsync(dropdown) {
if (!dropdown.src || state(dropdown).get('loading-state') === LOADING) {
return;
}
setDropdownLoadingState(dropdown);
$.ajax(dropdown.src)
.done(function (json, status, xhr) {
var isValidStatus = xhr.status === 200;
if (isValidStatus) {
setDropdownContents(dropdown, json);
} else {
setDropdownErrorState(dropdown);
}
})
.fail(function () {
setDropdownErrorState(dropdown);
})
.always(function () {
setDropdownLoaded(dropdown);
});
}
function loadContentWhenMouseEnterTrigger(dropdown) {
var isDropdownUnloaded = state(dropdown).get('loading-state') === UNLOADED;
var hasCurrentErrorBeenShown = state(dropdown).get('hasErrorBeenShown');
if (isDropdownUnloaded || (hasCurrentErrorBeenShown && !dropdown.isVisible())) {
loadContentsIfAsync(dropdown);
}
}
function loadContentWhenMenuShown(dropdown) {
var isDropdownUnloaded = state(dropdown).get('loading-state') === UNLOADED;
var hasCurrentErrorBeenShown = state(dropdown).get('hasErrorBeenShown');
if (isDropdownUnloaded || hasCurrentErrorBeenShown) {
loadContentsIfAsync(dropdown);
}
if (state(dropdown).get('loading-state') === ERROR) {
state(dropdown).set('hasErrorBeenShown', true);
}
}
// The dropdown's trigger
// ----------------------
function triggerCreated(trigger) {
let dropdownID = trigger.getAttribute('aria-controls');
if (!dropdownID) {
dropdownID = trigger.getAttribute('aria-owns');
if (!dropdownID) {
logger.error(
'Dropdown triggers need either a "aria-owns" or "aria-controls" attribute'
);
} else {
trigger.removeAttribute('aria-owns');
trigger.setAttribute('aria-controls', dropdownID);
}
}
trigger.setAttribute('aria-haspopup', true);
trigger.setAttribute('aria-expanded', false);
const shouldSetHref = trigger.nodeName === 'A' && !trigger.href;
if (shouldSetHref) {
trigger.setAttribute('href', `#${dropdownID}`);
}
function handleIt(e, forceShow = false) {
if (e.currentTarget !== trigger) {
return;
}
e.preventDefault();
if (!trigger.isEnabled()) {
return;
}
const dropdown = document.getElementById(dropdownID);
if (!dropdown) {
logger.error('Could not find a dropdown with id "' + dropdownID + '" in the DOM.');
return;
}
// AUI-4271 - Maintains legacy integration with parent elements.
const $trigger = $(trigger);
if ($trigger.parent().hasClass('aui-buttons')) {
dropdown.classList.add('aui-dropdown2-in-buttons');
}
if ($trigger.parents().hasClass('aui-header')) {
dropdown.classList.add('aui-dropdown2-in-header');
}
if (forceShow) {
dropdown.show(e);
} else {
dropdown.toggle(e);
}
dropdown.isSubmenu = trigger.hasSubmenu();
return dropdown;
}
function handleMouseEnter(e) {
if (e.currentTarget !== trigger) {
return;
}
e.preventDefault();
if (!trigger.isEnabled()) {
return;
}
const dropdown = document.getElementById(dropdownID);
if (!dropdown) {
logger.error('Could not find a dropdown with id "' + dropdownID + '" in the DOM.');
return;
}
loadContentWhenMouseEnterTrigger(dropdown);
if (trigger.hasSubmenu()) {
dropdown.show(e);
dropdown.isSubmenu = trigger.hasSubmenu();
}
return dropdown;
}
function handleKeydown(e) {
if (e.currentTarget !== trigger) {
return;
}
const normalInvoke = e.keyCode === keyCode.ENTER || e.keyCode === keyCode.SPACE;
const submenuInvoke = e.keyCode === keyCode.RIGHT && trigger.hasSubmenu();
const rootMenuInvoke =
(e.keyCode === keyCode.UP || e.keyCode === keyCode.DOWN) && !trigger.hasSubmenu();
if (normalInvoke) {
handleIt(e);
} else if (rootMenuInvoke || submenuInvoke) {
// this could be the first keyboard event after using an AT shortcut to open the dropdown.
handleIt(e, document.activeElement === trigger);
}
}
$(trigger)
.on('aui-button-invoke', handleIt)
.on('click', handleIt)
.on('keydown', handleKeydown)
.on('mouseenter', handleMouseEnter);
}
var triggerPrototype = {
disable: function () {
this.setAttribute('aria-disabled', 'true');
this.classList.add('disabled', 'aui-dropdown2-disabled');
},
enable: function () {
this.setAttribute('aria-disabled', 'false');
this.classList.remove('disabled', 'aui-dropdown2-disabled');
},
isEnabled: function () {
return (
this.getAttribute('aria-disabled') !== 'true' &&
this.classList.contains('aui-dropdown2-disabled') === false
);
},
hasSubmenu: function () {
return this.classList.contains('aui-dropdown2-sub-trigger');
},
};
//To remove at a later date. Some dropdown triggers initialise lazily, so we need to listen for mousedown
//and synchronously init before the click event is fired.
//TODO: delete in AUI 8.0.0, see AUI-2868
function bindLazyTriggerInitialisation() {
$(document).on('mousedown', '.aui-dropdown2-trigger', function () {
var isElementSkated = this.hasAttribute('resolved');
if (!isElementSkated) {
skate.init(this);
var lazyDeprecate = deprecate.getMessageLogger('Dropdown2 lazy initialisation', {
removeInVersion: '10.0.0',
alternativeName: 'initialisation on DOM insertion',
sinceVersion: '5.8.0',
extraInfo:
'Dropdown2 triggers should have all necessary attributes on DOM insertion',
deprecationType: 'JS',
});
lazyDeprecate();
}
});
}
bindLazyTriggerInitialisation();
// Dropdown trigger groups
// -----------------------
$(document).on(
'mouseenter',
'.aui-dropdown2-trigger-group a, .aui-dropdown2-trigger-group button',
function (e) {
const $item = $(e.currentTarget);
if ($item.is('.aui-dropdown2-active')) {
return; // No point doing anything if we're hovering over the already-active item trigger.
}
if ($item.closest('.aui-dropdown2').length) {
return; // We don't want to deal with dropdown items, just the potential triggers in the group.
}
const $triggerGroup = $item.closest('.aui-dropdown2-trigger-group');
const $groupActiveTrigger = $triggerGroup.find('.aui-dropdown2-active');
if ($groupActiveTrigger.length && $item.is('.aui-dropdown2-trigger')) {
$groupActiveTrigger.blur(); // Remove focus from the previously opened menu.
$item.trigger('aui-button-invoke'); // Open this trigger's menu.
e.preventDefault();
}
const $groupFocusedTrigger = $triggerGroup.find(':focus');
if ($groupFocusedTrigger.length && $item.is('.aui-dropdown2-trigger')) {
$groupFocusedTrigger.blur();
}
}
);
// Dropdown items
// --------------
function getDropdownItems(dropdown, filter) {
return $(dropdown)
.find(
[
// Legacy markup.
'> ul > li',
'> .aui-dropdown2-section > ul > li',
'> .aui-dropdown2-section > div[role="group"] > ul > li',
'> div > .aui-dropdown2-section > div[role="group"] > ul > li',
// Web component.
'aui-item-link',
'aui-item-checkbox',
'aui-item-radio',
].join(', ')
)
.filter(filter)
.children(
'a, button, [role="checkbox"], [role="menuitemcheckbox"], [role="radio"], [role="menuitemradio"]'
);
}
function getAllDropdownItems(dropdown) {
return getDropdownItems(dropdown, () => true);
}
function getVisibleDropdownItems(dropdown) {
return getDropdownItems(dropdown, (i, el) => !isHidden(el));
}
function amendDropdownItem(item) {
item.setAttribute('tabindex', '-1');
/**
* Honouring the documentation.
* @link https://aui.atlassian.com/latest/docs/dropdown2.html
*/
if (item.classList.contains('aui-dropdown2-disabled') || isHidden(item.parentElement)) {
item.setAttribute('aria-disabled', 'true');
}
}
function amendDropdownContent(dropdown) {
// Add assistive semantics to each dropdown item
getAllDropdownItems(dropdown).each((i, el) => amendDropdownItem(el));
}
/**
* Honours behaviour for code written using only the legacy class names.
* To maintain old behaviour (i.e., remove the 'hidden' class and the item will become un-hidden)
* whilst allowing our code to only depend on the new classes, we need to
* keep the state of the DOM in sync with legacy classes.
*
* Calling this function will add the new namespaced classes to elements with legacy names.
*/
function migrateAndSyncLegacyClassNames(dropdown) {
var $dropdown = $(dropdown);
// Migrate away from legacy, unprefixed class names
['disabled', 'interactive', 'active', 'checked'].forEach((type) => {
$dropdown.find(`.${type}`).addClass(`aui-dropdown2-${type}`);
});
}
// The Dropdown itself
// -------------------
function destroyAlignment(dropdown) {
if (dropdown._auiAlignment) {
dropdown._auiAlignment.destroy();
delete dropdown._auiAlignment;
}
}
function setLayerAlignment(dropdown, trigger) {
var hasSubmenu = trigger && trigger.hasSubmenu && trigger.hasSubmenu();
var hasSubmenuAlignment = dropdown.getAttribute('data-aui-alignment') === 'submenu auto';
if (!hasSubmenu && hasSubmenuAlignment) {
restorePreviousAlignment(dropdown);
}
var hasAnyAlignment = dropdown.hasAttribute('data-aui-alignment');
if (hasSubmenu && !hasSubmenuAlignment) {
saveCurrentAlignment(dropdown);
dropdown.setAttribute('data-aui-alignment', 'submenu auto');
dropdown.setAttribute('data-aui-alignment-static', true);
} else if (!hasAnyAlignment) {
dropdown.setAttribute('data-aui-alignment', 'bottom auto');
dropdown.setAttribute('data-aui-alignment-static', true);
}
destroyAlignment(dropdown);
dropdown._auiAlignment = new Alignment(dropdown, trigger, {
flip: false,
positionFixed: false,
preventOverflow: false,
// space between a dropdown trigger and the dropdown itself, based on @aui-dropdown-trigger-offset
// only added for dropdowns which are not submenus
offset: trigger.hasSubmenu && trigger.hasSubmenu() ? [-3, 0] : [0, 3],
});
dropdown._auiAlignment.enable();
}
function saveCurrentAlignment(dropdown) {
var $dropdown = $(dropdown);
if (dropdown.hasAttribute('data-aui-alignment')) {
$dropdown.data('previous-data-aui-alignment', dropdown.getAttribute('data-aui-alignment'));
}
$dropdown.data(
'had-data-aui-alignment-static',
dropdown.hasAttribute('data-aui-alignment-static')
);
}
function restorePreviousAlignment(dropdown) {
var $dropdown = $(dropdown);
var previousAlignment = $dropdown.data('previous-data-aui-alignment');
if (previousAlignment) {
dropdown.setAttribute('data-aui-alignment', previousAlignment);
} else {
dropdown.removeAttribute('data-aui-alignment');
}
$dropdown.removeData('previous-data-aui-alignment');
if (!$dropdown.data('had-data-aui-alignment-static')) {
dropdown.removeAttribute('data-aui-alignment-static');
}
$dropdown.removeData('had-data-aui-alignment-static');
}
function getDropdownHideLocation(dropdown, trigger) {
var possibleHome = trigger.getAttribute('data-dropdown2-hide-location');
return document.getElementById(possibleHome) || dropdown.parentNode;
}
var keyboardClose = false;
function keyboardCloseDetected() {
keyboardClose = true;
}
function wasProbablyClosedViaKeyboard() {
var result = keyboardClose === true;
keyboardClose = false;
return result;
}
function bindDropdownBehaviourToLayer(dropdown) {
layer(dropdown);
dropdown.addEventListener('aui-layer-show', function (e) {
if (dropdown !== e.target) {
return;
}
// fix legacy markup patterns
migrateAndSyncLegacyClassNames(dropdown);
amendDropdownContent(dropdown);
// indicate the dropdown is open
$(dropdown).trigger('aui-dropdown2-show');
doIfTrigger(dropdown, function (trigger) {
setDropdownTriggerActiveState(trigger, true);
dropdown._returnTo = getDropdownHideLocation(dropdown, trigger);
});
});
dropdown.addEventListener('aui-layer-hide', function (e) {
if (dropdown !== e.target) {
return;
}
$(dropdown).trigger('aui-dropdown2-hide');
if (dropdown._auiAlignment) {
dropdown._auiAlignment.destroy();
}
if (dropdown._returnTo) {
if (dropdown.parentNode && dropdown.parentNode !== dropdown._returnTo) {
dropdown._returnTo.appendChild(dropdown);
}
}
dropdown.classList.remove('aui-dropdown2-in-header', 'aui-dropdown2-in-buttons');
getAllDropdownItems(dropdown).removeClass('active aui-dropdown2-active');
doIfTrigger(dropdown, function (trigger) {
setDropdownTriggerActiveState(trigger, false);
// Focus the submenu trigger when the submenu is closed
if (wasProbablyClosedViaKeyboard()) {
trigger.focus();
}
});
// Gets set by submenu trigger invocation. Bad coupling point?
delete dropdown.isSubmenu;
});
}
function bindItemInteractionBehaviourToDropdown(dropdown) {
var $dropdown = $(dropdown);
function belongsToDropdown(item) {
const $dd = $(item).closest('.aui-dropdown2, aui-dropdown-menu');
return $dd.get(0) === dropdown;
}
$dropdown.on('keydown', function (e) {
if (!belongsToDropdown(e.target)) {
return;
}
if (e.keyCode === keyCode.DOWN) {
dropdown.focusNext();
e.preventDefault();
} else if (e.keyCode === keyCode.UP) {
dropdown.focusPrevious();
e.preventDefault();
} else if (e.keyCode === keyCode.LEFT) {
if (dropdown.isSubmenu) {
keyboardCloseDetected();
dropdown.hide();
e.preventDefault();
}
} else if (e.keyCode === keyCode.ESCAPE) {
// The closing will be handled by the LayerManager!
keyboardCloseDetected();
} else if (e.keyCode === keyCode.TAB) {
keyboardCloseDetected();
// On TAB we should close the menu and all submenus.
dropdown.hideAll(false);
}
});
const hideIfNotSubmenuAndNotInteractive = function (e) {
const item = e.currentTarget;
if (item.getAttribute('aria-disabled') === 'true') {
e.preventDefault();
return;
}
const isSubmenuTrigger = e.currentTarget.hasSubmenu && e.currentTarget.hasSubmenu();
if (!isSubmenuTrigger && !item.classList.contains('aui-dropdown2-interactive')) {
dropdown.hideAll(true);
}
};
$dropdown.on(
'click keydown',
'a, button, [role="menuitem"], [role="menuitemcheckbox"], [role="checkbox"], [role="menuitemradio"], [role="radio"]',
function (e) {
if (!belongsToDropdown(e.target)) {
return;
}
const item = e.currentTarget;
const eventKeyCode = e.keyCode;
const isEnter = eventKeyCode === keyCode.ENTER;
const isSpace = eventKeyCode === keyCode.SPACE;
// AUI-4283: Accessibility - need to ignore enter on links/buttons so
// that the dropdown remains visible to allow the click event to eventually fire.
const itemIgnoresEnter = isEnter && $(item).is('a[href], button');
if (!itemIgnoresEnter && (e.type === 'click' || isEnter || isSpace)) {
hideIfNotSubmenuAndNotInteractive(e);
}
}
);
// close all submenus when the mouse moves over items other than its trigger
$dropdown.on(
'mouseenter',
'a, button, [role="menuitem"], [role="menuitemcheckbox"], [role="checkbox"], [role="menuitemradio"], [role="radio"]',
function (e) {
if (!belongsToDropdown(e.target)) {
return;
}
// Focus the current element to allow Screen Reader to announce a focused menuitem.
e.currentTarget.focus();
// We should close all submenus above which are not related to the focused trigger.
// For example if we hover over the trigger for submenu for this trigger be shown, when we move
// focus/mouse on the another trigger the previous will be closed.
let layerAbove = $(layer(dropdown).above()).get(0);
while (layerAbove && layerAbove.isDropdown && layerAbove !== dropdown) {
const layerId = layerAbove.getAttribute('id');
const targetDropdownId = e.target.getAttribute('aria-controls');
const nextLayer = layer(layerAbove).above();
if (targetDropdownId !== layerId) {
// We should .hide() only after we get nextLayer,
// otherwise we will get the first visible layer.
layer(layerAbove).hide();
}
layerAbove = $(nextLayer).get(0);
}
}
);
}
$(window).on(
'resize',
debounce(
function () {
$('.aui-dropdown2').each(function (index, dropdown) {
skate.init(dropdown);
if (dropdown.isVisible()) {
dropdown.hide();
}
});
},
1000,
true
)
);
// Dropdowns
// ---------
function dropdownCreated(dropdown) {
dropdown.classList.add('aui-dropdown2');
dropdown.setAttribute('tabindex', '-1');
if (dropdown.hasAttribute('data-container')) {
dropdown.setAttribute(
'data-aui-alignment-container',
dropdown.getAttribute('data-container')
);
dropdown.removeAttribute('data-container');
}
bindDropdownBehaviourToLayer(dropdown);
bindItemInteractionBehaviourToDropdown(dropdown);
$(dropdown).on('click keydown', '.aui-dropdown2-checkbox', function (e) {
if (e.type === 'click' || e.keyCode === keyCode.ENTER || e.keyCode === keyCode.SPACE) {
let checkbox = this;
if (e.isDefaultPrevented()) {
return;
}
if (checkbox.isInteractive()) {
e.preventDefault();
}
if (checkbox.isEnabled()) {
// toggle the checked state
if (checkbox.isChecked()) {
checkbox.uncheck();
} else {
checkbox.check();
}
}
}
});
$(dropdown).on('click keydown', '.aui-dropdown2-radio', function (e) {
if (e.type === 'click' || e.keyCode === keyCode.ENTER || e.keyCode === keyCode.SPACE) {
let radio = this;
if (e.isDefaultPrevented()) {
return;
}
if (radio.isInteractive()) {
e.preventDefault();
}
if (this.isEnabled() && this.isChecked() === false) {
// toggle the checked state
$(radio)
.closest('ul,[role=group]')
.find('.aui-dropdown2-checked')
.not(this)
.each(function () {
this.uncheck();
});
radio.check();
}
}
});
}
/**
* Given a dropdown, find the layer that contains the first dropdown in a
* stack of nested dropdowns.
*
* If a dropdown is nested (`trigger -> dropdownA -> dropdownB`),
* `f(dropdownB) = layer(dropdownA)`.
*
* If a dropdown is non-nested (`trigger -> dropdownA`),
* `f(dropdownA) = layer(dropdownA)`.
*/
function findBottomLayerInDropdownStack(dropdown) {
let currentLayer = layer(dropdown);
while (true) {
const itemInLayerBelow = currentLayer.below();
if (!itemInLayerBelow) {
break;
}
if (!itemInLayerBelow.get(0).isDropdown) {
break;
}
currentLayer = layer(itemInLayerBelow);
}
return currentLayer;
}
var dropdownPrototype = {
/**
* Toggles the visibility of the dropdown menu
*/
toggle: function (e) {
if (this.isVisible()) {
this.hide();
} else {
this.show(e);
}
},
/**
* Explicitly shows the menu
*
* @returns {HTMLElement}
*/
show: function (e) {
const dropdown = this;
// In case we have 2 triggers for the same submenu
// we can have two active/expanded triggers at the same time.
// In order to avoid such behavior we need to reset current active trigger,
// before we update/replace it.
doIfTrigger(dropdown, (trigger) => {
setDropdownTriggerActiveState(trigger, false);
});
if (e && e.currentTarget && e.currentTarget.classList.contains('aui-dropdown2-trigger')) {
setTrigger(dropdown, e.currentTarget);
}
layer(dropdown).show();
doIfTrigger(dropdown, function (trigger) {
setDropdownTriggerActiveState(trigger, true);
setLayerAlignment(dropdown, trigger);
});
// Manage focus on the next tick.
// the setTimeout avoids a full page scroll caused by:
// 1) the layer code moving the element to the bottom of the DOM, and then
// 2) the alignment module asynchronously rendering the element further up the page
// this code should ideally listen and wait for both those things to be done... but
// this assumption is cheaper.
setTimeout(() => handleFocus(dropdown, e), 0);
return this;
},
/**
* Explicitly hides the menu
*
* @returns {HTMLElement}
*/
hide: function () {
layer(this).hide();
return this;
},
/**
* Explicitly hides the whole menu with submenus and focus the initial triggers based on provided param.
*
* @param focusTrigger - Get focus back to the initial trigger when menu is closed. Default value is "true".
* @returns {HTMLElement}
*/
hideAll: function (focusTrigger = true) {
const bottomLayer = findBottomLayerInDropdownStack(this);
// AUI-5474 when something else (e.g. a Dialog) has hidden the
// dropdown's layer, we don't need to do anything.
if (!bottomLayer.isVisible()) {
return this;
}
bottomLayer.hide();
// Get focus back to the dropdown's trigger after
// the dropdown is closed.
if (focusTrigger) {
doIfTrigger(bottomLayer.el, (trigger) => {
// Delay focusing to allow things to settle; otherwise
// Skatejs's MutationObserver kicks in and opens the dropdown
// again.
setTimeout(() => {
trigger.focus();
}, 0);
setTrigger(bottomLayer.el, null);
});
}
return this;
},
/**
* Shifts explicit focus to the next available item in the menu
*
* @returns {undefined}
*/
focusNext: function () {
var $items = getVisibleDropdownItems(this);
var selected = document.activeElement;
var idx;
if ($items.last()[0] !== selected) {
idx = $items.toArray().indexOf(selected);
this.focusItem($items.get(idx + 1));
} else {
this.focusItem(0);
}
},
/**
* Shifts explicit focus to the previous available item in the menu
*
* @returns {undefined}
*/
focusPrevious: function () {
var $items = getVisibleDropdownItems(this);
var selected = document.activeElement;
var idx;
if ($items.first()[0] !== selected) {
idx = $items.toArray().indexOf(selected);
this.focusItem($items.get(idx - 1));
} else {
this.focusItem($items.length - 1);
}
},
/**
* Shifts explicit focus to the menu item matching the index param
*/
focusItem: function (item) {
const $items = getVisibleDropdownItems(this);
if (typeof item === 'number') {
item = $items.get(item);
}
const $item = $(item);
$item.focus();
$items.removeClass('active aui-dropdown2-active');
$item.addClass('active aui-dropdown2-active');
},
/**
* Checks whether or not the menu is currently displayed
*
* @returns {Boolean}
*/
isVisible: function () {
return layer(this).isVisible();
},
/**
* Shows that current element is a dropdown
*/
isDropdown: true,
};
// Web component API for dropdowns
// -------------------------------
var disabledAttributeHandler = {
created: function (element) {
var a = element.children[0];
a.setAttribute('aria-disabled', 'true');
a.classList.add('disabled', 'aui-dropdown2-disabled');
},
removed: function (element) {
var a = element.children[0];
a.setAttribute('aria-disabled', 'false');
a.classList.remove('disabled', 'aui-dropdown2-disabled');
},
};
var interactiveAttributeHandler = {
created: function (element) {
var a = element.children[0];
a.classList.add('interactive', 'aui-dropdown2-interactive');
},
removed: function (element) {
var a = element.children[0];
a.classList.remove('interactive', 'aui-dropdown2-interactive');
},
};
var checkedAttributeHandler = {
created: function (element) {
var a = element.children[0];
a.classList.add('checked', 'aui-dropdown2-checked');
a.setAttribute('aria-checked', true);
element.dispatchEvent(new CustomEvent('change', { bubbles: true }));
},
removed: function (element) {
var a = element.children[0];
a.classList.remove('checked', 'aui-dropdown2-checked');
a.setAttribute('aria-checked', false);
element.dispatchEvent(new CustomEvent('change', { bubbles: true }));
},
};
var hiddenAttributeHandler = {
created: function (element) {
disabledAttributeHandler.created(element);
},
removed: function (element) {
disabledAttributeHandler.removed(element);
},
};
var stringAttributeHandlerGenerator = function (attrName) {
return {
fallback: function (element, change) {
var a = element.children[0];
a.setAttribute(attrName, change.newValue);
},
removed: function (element) {
var a = element.children[0];
a.removeAttribute(attrName);
},
};
};
const convertCssClassesToArray = function (spaceDelimitedClasses = '') {
return spaceDelimitedClasses
.split(' ')
.map((str) => str.trim())
.filter((x) => x);
};
const ItemLinkEl = skate('aui-item-link', {
template: template('<a role="menuitem" tabindex="-1"><content></content></a>'),
attributes: {
'disabled': disabledAttributeHandler,
'interactive': interactiveAttributeHandler,
'hidden': hiddenAttributeHandler,
'href': stringAttributeHandlerGenerator('href'),
'item-id': stringAttributeHandlerGenerator('id'),
'for': {
created: function (element) {
var anchor = element.children[0];
anchor.setAttribute('aria-controls', element.getAttribute('for'));
$(anchor).addClass('aui-dropdown2-sub-trigger');
},
updated: function (element) {
var anchor = element.children[0];
anchor.setAttribute('aria-controls', element.getAttribute('for'));
},
removed: function (element) {
var anchor = element.children[0];
anchor.removeAttribute('aria-controls');
$(anchor).removeClass('aui-dropdown2-sub-trigger');
},
},
'extra-classes': function (element, change) {
const anchor = element.children[0];
if (change.oldValue) {
anchor.classList.remove(...convertCssClassesToArray(change.oldValue));
}
if (change.newValue) {
anchor.classList.add(...convertCssClassesToArray(change.newValue));
}
},
},
});
const [ItemCheckboxEl, ItemRadioEl] = ['checkbox', 'radio'].map((type) => {
return skate(`aui-item-${type}`, {
template: template(
`<span role="menuitem${type}" class="aui-dropdown2-${type}" tabindex="-1"><content></content></span>`
),
attributes: {
'item-id': stringAttributeHandlerGenerator('id'),
'disabled': disabledAttributeHandler,
'interactive': interactiveAttributeHandler,
'checked': checkedAttributeHandler,
'hidden': hiddenAttributeHandler,
},
});
});
const SectionEl = skate('aui-section', {
template: template(`
<span aria-hidden="true" class="aui-dropdown2-heading"></span>
<div class="aui-dropdown2-item-group" role="group">
<content></content>
</div>
`),
attributes: {
label: function (element, data) {
const id = generateUniqueId();
var headingElement = element.children[0];
var groupElement = element.children[1];
headingElement.textContent = data.newValue;
headingElement.id = id;
groupElement.setAttribute('aria-labelledby', id);
},
},
created: function (element) {
element.classList.add('aui-dropdown2-section');
},
});
const DropdownEl = skate('aui-dropdown-menu', {
created: function (dropdown) {
dropdown.setAttribute('role', 'menu');
dropdown.className = 'aui-dropdown2';
layer(dropdown);
state(dropdown).set('loading-state', UNLOADED);
// Now skate the .aui-dropdown2 behaviour.
skate.init(dropdown);
},
detached: function (dropdown) {
ifGone(dropdown).then(() => destroyAlignment(dropdown));
},
attributes: {
src: {},
},
prototype: dropdownPrototype,
events: {
'aui-layer-show': loadContentWhenMenuShown,
},
});
// Legacy dropdown inits
// ---------------------
skate('aui-dropdown2', {
type: skate.type.CLASSNAME,
created: dropdownCreated,
prototype: dropdownPrototype,
});
skate('data-aui-dropdown2', {
type: skate.type.ATTRIBUTE,
created: dropdownCreated,
prototype: dropdownPrototype,
});
skate('aui-dropdown2-trigger', {
type: skate.type.CLASSNAME,
created: triggerCreated,
prototype: triggerPrototype,
});
skate('aui-dropdown2-sub-trigger', {
type: skate.type.CLASSNAME,
created: function (trigger) {
trigger.classList.add('aui-dropdown2-trigger');
skate.init(trigger);
},
});
// Checkboxes and radios
// ---------------------
['checkbox', 'radio'].map((type) => {
return skate(`aui-dropdown2-${type}`, {
type: skate.type.CLASSNAME,
created: function (checkbox) {
// determine checked state based on any one of three attributes, then sync all of them.
const checked =
checkbox.getAttribute('aria-checked') === 'true' ||
checkbox.classList.contains('checked') ||
checkbox.classList.contains('aui-dropdown2-checked');
checkbox.classList[checked ? 'add' : 'remove']('checked', 'aui-dropdown2-checked');
checkbox.setAttribute('aria-checked', checked);
// make the element part of the natural tab order.
checkbox.setAttribute('tabindex', '0');
// swap from the "menuitemX" role to plain role for VoiceOver
if (supportsVoiceOver()) {
checkbox.setAttribute('role', `menuitem${type}`);
}
},
prototype: {
isEnabled: function () {
return this.getAttribute('aria-disabled') !== 'true';
},
isChecked: function () {
return this.getAttribute('aria-checked') === 'true';
},
isInteractive: function () {
return this.classList.contains('aui-dropdown2-interactive');
},
uncheck: function () {
if (this.parentNode.tagName.toLowerCase() === `aui-item-${type}`) {
this.parentNode.removeAttribute('checked');
}
$(this)
.attr('aria-checked', 'false')
.removeClass('checked aui-dropdown2-checked')
.trigger('aui-dropdown2-item-uncheck');
},
check: function () {
if (this.parentNode.tagName.toLowerCase() === `aui-item-${type}`) {
this.parentNode.setAttribute('checked', '');
}
$(this)
.attr('aria-checked', 'true')
.addClass('checked aui-dropdown2-checked')
.trigger('aui-dropdown2-item-check');
},
},
});
});
export { DropdownEl, ItemLinkEl, ItemRadioEl, ItemCheckboxEl, SectionEl };