@atlassian/aui
Version:
Atlassian User Interface library
584 lines (474 loc) • 18.8 kB
JavaScript
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 };