UNPKG

@atlassian/aui

Version:

Atlassian User Interface library

584 lines (474 loc) 18.8 kB
import 'skatejs-template-html'; import * as logger from './internal/log'; import { debounce } from 'underscore'; import $ from './jquery'; import addId from './internal/add-id'; import globalize from './internal/globalize'; import isClipped from './is-clipped'; import skate from './internal/skate'; var template = window.skateTemplateHtml; var STORAGE_PREFIX = '_internal-aui-tabs-'; var RESPONSIVE_OPT_IN_SELECTOR = '.aui-tabs.horizontal-tabs[data-aui-responsive]:not([data-aui-responsive="false"]), aui-tabs[responsive]:not([responsive="false"])'; function getPaneIdFromTabLink(el) { let $el = $(el); let maybeId = String($el.attr('href') || '').trim(); return maybeId.indexOf('#') === 0 ? maybeId.substr(1) : null; } /** * Locate what is assumed to be a tab panel within the DOM, based on the link element passed in * @param {HTMLElement} el a tab link element with an href attribute pointing to a tab panel * @returns {HTMLElement|null} the element with matching ID if it exists, otherwise null */ function getPaneFromTabLink(el) { let maybeId = getPaneIdFromTabLink(el); return maybeId ? document.getElementById(maybeId) : null; } function enhanceTabLink(link) { var $thisLink = $(link); var targetPane = getPaneFromTabLink($thisLink); if (!getPaneIdFromTabLink(link)) { logger.error( 'A tab link must use an anchor link (e.g., <a href="#a-valid-id"></a>) to work correctly.', link ); } if (targetPane) { addId($thisLink); $thisLink.attr('role', 'tab'); $(targetPane).attr('aria-labelledby', $thisLink.attr('id')); if ($thisLink.parent().hasClass('active-tab')) { $thisLink.attr('aria-selected', 'true'); } else { $thisLink.attr('aria-selected', 'false'); } } else { logger.warn( "A tab panel could not be found with the tab link's configured href." + ' Check whether the tab link href is correct.', link ); } } var ResponsiveAdapter = { totalTabsWidth: function ($visibleTabs, $dropdown) { var totalVisibleTabsWidth = this.totalVisibleTabWidth($visibleTabs); var totalDropdownTabsWidth = 0; $dropdown.find('li').each(function (index, tab) { totalDropdownTabsWidth += parseInt(tab.getAttribute('data-aui-tab-width')); }); return totalVisibleTabsWidth + totalDropdownTabsWidth; }, totalVisibleTabWidth: function ($tabs) { var totalWidth = 0; $tabs.each(function (index, tab) { totalWidth += $(tab).outerWidth(); }); return totalWidth; }, removeResponsiveDropdown: function ($dropdown, $dropdownTriggerTab) { $dropdown.remove(); $dropdownTriggerTab.remove(); }, createResponsiveDropdownTrigger: function ($tabsMenu, id) { var triggerMarkup = `<li class="menu-item aui-tabs-responsive-trigger-item"> <a class="aui-dropdown2-trigger aui-tabs-responsive-trigger aui-dropdown2-trigger-arrowless" id="aui-tabs-responsive-trigger-${id}" aria-haspopup="true" aria-controls="aui-tabs-responsive-dropdown-${id}" href="#aui-tabs-responsive-dropdown-${id}">...</a> </li>`; $tabsMenu.append(triggerMarkup); return $tabsMenu.find('.aui-tabs-responsive-trigger-item'); }, createResponsiveDropdown: function ($tabsContainer, id) { var dropdownMarkup = '<div class="aui-dropdown2 aui-tabs-responsive-dropdown" id="aui-tabs-responsive-dropdown-' + id + '">' + '<ul>' + '</ul>' + '</div>'; $tabsContainer.append(dropdownMarkup); var $dropdown = $tabsContainer.find('#aui-tabs-responsive-dropdown-' + id); return $dropdown; }, findNewVisibleTabs: function (tabs, parentWidth, dropdownTriggerTabWidth) { function hasMoreSpace(currentTotalTabWidth, dropdownTriggerTabWidth, parentWidth) { return currentTotalTabWidth + dropdownTriggerTabWidth <= parentWidth; } var currentTotalTabWidth = 0; for ( var i = 0; hasMoreSpace(currentTotalTabWidth, dropdownTriggerTabWidth, parentWidth) && i < tabs.length; i++ ) { var $tab = $(tabs[i]); var tabWidth = $tab.outerWidth(true); currentTotalTabWidth += tabWidth; } // i should now be at the tab index after the last visible tab because of the loop so we minus 1 to get the new visible tabs return tabs.slice(0, i - 1); }, moveVisibleTabs: function (oldVisibleTabs, $tabsParent, $dropdownTriggerTab) { var dropdownId = $dropdownTriggerTab.find('a').attr('aria-controls'); var $dropdown = $('#' + dropdownId); var newVisibleTabs = this.findNewVisibleTabs( oldVisibleTabs, $tabsParent.outerWidth(), $dropdownTriggerTab.parent().outerWidth(true) ); var lastTabIndex = newVisibleTabs.length - 1; for (var j = oldVisibleTabs.length - 1; j >= lastTabIndex; j--) { var $tab = $(oldVisibleTabs[j]); this.moveTabToResponsiveDropdown($tab, $dropdown, $dropdownTriggerTab); } return $(newVisibleTabs); }, moveTabToResponsiveDropdown: function ($tab, $dropdown, $dropdownTriggerTab) { var $tabLink = $tab.find('a'); $tab.attr('data-aui-tab-width', $tab.outerWidth(true)); $tabLink.addClass('aui-dropdown2-radio aui-tabs-responsive-item'); if ($tab.hasClass('active-tab')) { $tabLink.addClass('aui-dropdown2-checked'); $dropdownTriggerTab.addClass('active-tab'); } $dropdown.find('ul').prepend($tab); }, moveInvisibleTabs: function (tabsInDropdown, remainingSpace, $dropdownTriggerTab) { function hasMoreSpace(remainingSpace) { return remainingSpace > 0; } for (var i = 0; hasMoreSpace(remainingSpace) && i < tabsInDropdown.length; i++) { var $tab = $(tabsInDropdown[i]); var tabInDropdownWidth = parseInt($tab.attr('data-aui-tab-width'), 10); var shouldMoveTabOut = tabInDropdownWidth < remainingSpace; if (shouldMoveTabOut) { this.moveTabOutOfDropdown($tab, $dropdownTriggerTab); } remainingSpace -= tabInDropdownWidth; } }, moveTabOutOfDropdown: function ($tab, $dropdownTriggerTab) { var isTabInDropdownActive = $tab.find('a').hasClass('aui-dropdown2-checked'); if (isTabInDropdownActive) { $tab.addClass('active-tab'); $dropdownTriggerTab.removeClass('active-tab'); } $tab.children('a').removeClass( 'aui-dropdown2-radio aui-tabs-responsive-item aui-dropdown2-checked' ); $dropdownTriggerTab.before($tab); }, }; // this function is run by jquery .each() where 'this' is the current tabs container function calculateResponsiveTabs(tabsContainer, index) { var $tabsContainer = $(tabsContainer); var $tabsMenu = $tabsContainer.find('.tabs-menu').first(); var $visibleTabs = $tabsMenu.find('li:not(.aui-tabs-responsive-trigger-item)'); var $dropdownTriggerTab = $tabsMenu.find('.aui-tabs-responsive-trigger').parent(); var $dropdownTrigger = $dropdownTriggerTab.find('a'); var dropdownId = $dropdownTrigger.attr('aria-controls'); var $dropdown = $(document.getElementById(dropdownId)).attr('aria-checked', false); var isResponsive = $dropdown.length > 0; var totalTabsWidth = ResponsiveAdapter.totalTabsWidth($visibleTabs, $dropdown); var needsResponsive = totalTabsWidth > $tabsContainer.outerWidth(); if (!isResponsive && needsResponsive) { $dropdownTriggerTab = ResponsiveAdapter.createResponsiveDropdownTrigger($tabsMenu, index); $dropdown = ResponsiveAdapter.createResponsiveDropdown($tabsContainer, index); } // reset id's in case tabs have changed DOM order $dropdownTrigger.attr('aria-controls', 'aui-tabs-responsive-dropdown-' + index); $dropdownTrigger.attr('id', 'aui-tabs-responsive-trigger-' + index); $dropdownTrigger.attr('href', '#aui-tabs-responsive-trigger-' + index); $dropdown.attr('id', 'aui-tabs-responsive-dropdown-' + index); if (needsResponsive) { var $newVisibleTabs = ResponsiveAdapter.moveVisibleTabs( $visibleTabs.toArray(), $tabsContainer, $dropdownTriggerTab ); var visibleTabWidth = ResponsiveAdapter.totalVisibleTabWidth($newVisibleTabs); var remainingSpace = $tabsContainer.outerWidth() - visibleTabWidth - $dropdownTriggerTab.outerWidth(true); var hasSpace = remainingSpace > 0; if (hasSpace) { var $tabsInDropdown = $dropdown.find('li'); ResponsiveAdapter.moveInvisibleTabs( $tabsInDropdown.toArray(), remainingSpace, $dropdownTriggerTab ); } if (!$tabsContainer.hasClass('aui-tabs-disabled')) { $dropdown.on('click.aui-tabs', 'a', handleTabClick); } /* Workaround for bug in Edge where the dom is just not being re-rendered properly It is only triggered for certain widths. Simply taking the element out of the DOM and placing it back in causes the browser to re-render, hiding the issue. added from AUI-4098 and to be revisited in AUI-4117*/ if ($tabsMenu.is(':visible')) { $tabsMenu.hide().show(); } } if (isResponsive && !needsResponsive) { $dropdown.find('li').each(function () { ResponsiveAdapter.moveTabOutOfDropdown($(this), $dropdownTriggerTab); }); ResponsiveAdapter.removeResponsiveDropdown($dropdown, $dropdownTriggerTab); } } function switchToTab(tab) { var $tab = $(tab); // This probably isn't needed anymore. Remove once confirmed. if ($tab.hasClass('aui-tabs-responsive-trigger')) { return; } var pane = getPaneFromTabLink($tab); if (!pane) { logger.error( 'Cannot switch to tab panel because it does not exist.' + ' Check whether the tab link href is correct.', tab ); return; } var $pane = $(pane); $pane.addClass('active-pane').siblings('.tabs-pane').removeClass('active-pane'); var $dropdownTriggerTab = $tab.parents('.aui-tabs').find('.aui-tabs-responsive-trigger-item a'); var dropdownId = $dropdownTriggerTab.attr('aria-controls'); var $dropdown = $(document.getElementById(dropdownId)); $dropdown.find('li a').attr('aria-checked', false).removeClass('checked aui-dropdown2-checked'); $dropdown.find('li').removeClass('active-tab'); $tab.parent('li.menu-item') .addClass('active-tab') .siblings('.menu-item') .removeClass('active-tab'); if ($tab.hasClass('aui-tabs-responsive-item')) { var $visibleTabs = $pane .parent('.aui-tabs') .find('li.menu-item:not(.aui-tabs-responsive-trigger-item)'); $visibleTabs.removeClass('active-tab'); $visibleTabs.find('a').removeClass('checked').removeAttr('aria-checked'); } if ($tab.hasClass('aui-tabs-responsive-item')) { $pane .parent('.aui-tabs') .find('li.menu-item.aui-tabs-responsive-trigger-item') .addClass('active-tab'); } $tab.closest('.tabs-menu').find('a').attr('aria-selected', 'false'); $tab.attr('aria-selected', 'true'); $tab.trigger('tabSelect', { tab: $tab, pane: $pane, }); } function isPersistentTabGroup($tabGroup) { // Tab group persistent attribute exists and is not false return ( $tabGroup.attr('data-aui-persist') !== undefined && $tabGroup.attr('data-aui-persist') !== 'false' ); } function createPersistentKey($tabGroup) { var tabGroupId = $tabGroup.attr('id'); var value = $tabGroup.attr('data-aui-persist'); return ( STORAGE_PREFIX + (tabGroupId ? tabGroupId : '') + (value && value !== 'true' ? '-' + value : '') ); } /* eslint max-depth: 1 */ function updateTabsFromLocalStorage($tabGroups) { for (var i = 0, ii = $tabGroups.length; i < ii; i++) { var $tabGroup = $tabGroups.eq(i); var tabs = $tabGroups.get(i); if (isPersistentTabGroup($tabGroup) && window.localStorage) { var tabGroupId = $tabGroup.attr('id'); if (tabGroupId) { var persistentTabId = window.localStorage.getItem(createPersistentKey($tabGroup)); if (persistentTabId) { var anchor = tabs.querySelector(`a[href$="${persistentTabId}"]`); // eslint-disable-next-line max-depth if (anchor) { switchToTab(anchor); } } } else { logger.warn( 'A tab group must specify an id attribute if it specifies data-aui-persist.' ); } } } } function updateLocalStorageEntry($tab) { var $tabGroup = $tab.closest('.aui-tabs'); var tabGroupId = $tabGroup.attr('id'); if (tabGroupId) { var tabId = getPaneIdFromTabLink($tab); if (tabId) { window.localStorage.setItem(createPersistentKey($tabGroup), '#' + tabId); } } else { logger.warn('A tab group must specify an id attribute if it specifies data-aui-persist.'); } } function handleTabClick(e) { tabs.change($(e.target).closest('a')); if (e) { e.preventDefault(); } } function responsiveResizeHandler(tabs) { tabs.forEach(function (tab, index) { calculateResponsiveTabs(tab, index); }); } // Initialisation // -------------- function getTabs() { return $('.aui-tabs:not(.aui-tabs-disabled)'); } function getResponsiveTabs() { return $(RESPONSIVE_OPT_IN_SELECTOR).toArray(); } function initWindow() { const handler = debounce(responsiveResizeHandler, 200); handler(getResponsiveTabs()); $(window).on('resize.aui-tabs', () => handler(getResponsiveTabs())); } function initTab(tab) { var $tab = $(tab); tab.setAttribute('role', 'application'); if (!$tab.data('aui-tab-events-bound')) { var $tabMenu = $tab.children('ul.tabs-menu'); // ARIA setup $tabMenu.attr('role', 'tablist'); // ignore the LIs so tab count is announced correctly $tabMenu.children('li').attr('role', 'presentation'); $tabMenu.find('> .menu-item a').each(function () { enhanceTabLink(this); }); // Set up click event for tabs $tabMenu.on('click.aui-tabs', 'a', handleTabClick); $tab.data('aui-tab-events-bound', true); initPanes(tab); } } function initTabs() { var tabs = getTabs(); tabs.each(function () { initTab(this); }); updateTabsFromLocalStorage(tabs); } function initPane(pane) { pane.setAttribute('role', 'tabpanel'); } function initPanes(tab) { [].slice.call(tab.querySelectorAll('.tabs-pane')).forEach(initPane); } function initVerticalTabs() { // Vertical tab truncation setup (adds title if clipped) $('.aui-tabs.vertical-tabs') .find('a') .each(function () { var thisTab = $(this); // don't override existing titles if (!thisTab.attr('title')) { // if text has been truncated, add title if (isClipped(thisTab)) { thisTab.attr('title', thisTab.text()); } } }); } var tabs = { setup: function () { initWindow(); initTabs(); initVerticalTabs(); }, change: function (a) { var collection = a instanceof HTMLElement || a instanceof $ ? a : document.querySelector(a); var $a = $(collection).first(); var $tabGroup = $a.closest('.aui-tabs'); switchToTab($a); if (isPersistentTabGroup($tabGroup) && window.localStorage) { updateLocalStorageEntry($a); } }, }; $(tabs.setup); // Web Components // -------------- function findComponent(element) { return $(element).closest('aui-tabs').get(0); } function findPanes(tabs) { return tabs.querySelectorAll('aui-tabs-pane'); } function findTabs(tabs) { return tabs.querySelectorAll('li[is=aui-tabs-tab]'); } const TabContainerEl = skate('aui-tabs', { created: function (element) { $(element).addClass('aui-tabs horizontal-tabs'); // We must initialise here so that the old code still works since // the lifecycle of the sub-components setup the markup so that it // can be processed by the old logic. skate.init(element); // Use the old logic to initialise the tabs. initTab(element); }, template: template( '<ul class="tabs-menu">', '<content select="li[is=aui-tabs-tab]"></content>', '</ul>', '<content select="aui-tabs-pane"></content>' ), prototype: { select: function (element) { var index = $(findPanes(this)).index(element); if (index > -1) { tabs.change(findTabs(this)[index].children[0]); } return this; }, }, }); const TabItemEl = skate('aui-tabs-tab', { extends: 'li', created: function (element) { $(element).addClass('menu-item'); }, template: template('<a href="#">', '<strong>', '<content></content>', '</strong>', '</a>'), }); const TabPaneEl = skate('aui-tabs-pane', { attached: function (element) { var $component = $(findComponent(element)); var $element = $(element); var index = $component.find('aui-tabs-pane').index($element); var tab = new TabItemEl(); var $tab = $(tab); $element.addClass('tabs-pane'); tab.firstChild.setAttribute('href', '#' + element.id); template.wrap(tab).textContent = $element.attr('title'); if (index === 0) { $element.addClass('active-pane'); } if ($element.hasClass('active-pane')) { $tab.addClass('active-tab'); } $element.siblings('ul').append(tab); }, template: template('<content></content>'), }); globalize('tabs', tabs); export default tabs; export { TabContainerEl, TabPaneEl, TabItemEl };