UNPKG

@atlassian/aui

Version:

Atlassian User Interface library

1,205 lines (1,039 loc) 39.3 kB
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 };