@atlassian/aui
Version:
Atlassian User Interface library
743 lines (630 loc) • 24.2 kB
JavaScript
import $ from './jquery';
import './tooltip';
import './navigation';
import { I18n } from './i18n';
import clone from './clone';
import * as deprecate from './internal/deprecation';
import globalize from './internal/globalize';
import hasTouch from './internal/has-touch';
import isInput from './internal/is-input';
import keyCode from './key-code';
import mediaQuery from './internal/mediaQuery';
import skate from './internal/skate';
import uid from './unique-id';
import widget from './internal/widget';
import deduplicateIDs from './internal/deduplicateIDs';
function sidebarOffset(sidebar) {
return sidebar.offset().top;
}
function Sidebar(selector) {
this.$el = $(selector);
if (!this.$el.length) {
return;
}
this.$body = $('body');
this.$wrapper = this.$el.children('.aui-sidebar-wrapper');
// Sidebar users should add class="aui-page-sidebar" to the
// <body> in the rendered markup (to prevent any potential flicker),
// so we add it just in case they forgot.
this.$body.addClass('aui-page-sidebar');
this._previousScrollTop = null;
this._previousViewportHeight = null;
this._previousViewportWidth = null;
this._previousOffsetTop = null;
this.submenus = new SubmenuManager();
initializeHandlers(this);
constructAllSubmenus(this);
}
var FORCE_COLLAPSE_WIDTH = 1240;
var EVENT_PREFIX = '_aui-internal-sidebar-';
function namespaceEvents(events) {
return $.map(events.split(' '), function (event) {
return EVENT_PREFIX + event;
}).join(' ');
}
Sidebar.prototype.on = function () {
var events = arguments[0];
var args = Array.prototype.slice.call(arguments, 1);
var namespacedEvents = namespaceEvents(events);
this.$el.on.apply(this.$el, [namespacedEvents].concat(args));
return this;
};
Sidebar.prototype.off = function () {
var events = arguments[0];
var args = Array.prototype.slice.call(arguments, 1);
var namespacedEvents = namespaceEvents(events);
this.$el.off.apply(this.$el, [namespacedEvents].concat(args));
return this;
};
Sidebar.prototype.setHeight = function (scrollTop, viewportHeight, headerHeight) {
var visibleHeaderHeight = Math.max(0, headerHeight - scrollTop);
this.$wrapper.height(viewportHeight - visibleHeaderHeight);
return this;
};
Sidebar.prototype.setTopPosition = function (scrollTop = window.pageYOffset) {
this.$wrapper.toggleClass('aui-is-docked', scrollTop > sidebarOffset(this.$el));
return this;
};
Sidebar.prototype.setPosition = deprecate.fn(
Sidebar.prototype.setTopPosition,
'Sidebar.setPosition',
{
removeInVersion: '10.0.0',
sinceVersion: '7.6.1',
alternativeName: 'Sidebar.setTopPosition',
}
);
Sidebar.prototype.setLeftPosition = function (scrollLeft = window.pageXOffset) {
if (this.$wrapper.hasClass('aui-is-docked')) {
this.$wrapper.css({ left: -scrollLeft });
}
return this;
};
Sidebar.prototype.setCollapsedState = function (viewportWidth) {
// Reflow behaviour is implemented as a state machine (hence all
// state transitions are enumerated). The rest of the state machine,
// e.g., entering the expanded narrow (fly-out) state, is implemented
// by the toggle() method.
var transition = { collapsed: {}, expanded: {} };
transition.collapsed.narrow = {
narrow: $.noop,
wide: function (s) {
s._expand(viewportWidth, true);
},
};
transition.collapsed.wide = {
narrow: $.noop, // Becomes collapsed narrow (no visual change).
wide: $.noop,
};
transition.expanded.narrow = {
narrow: $.noop,
wide: function (s) {
s.$body.removeClass('aui-sidebar-collapsed');
s.$el.removeClass('aui-sidebar-fly-out');
},
};
transition.expanded.wide = {
narrow: function (s) {
s._collapse(true);
},
wide: $.noop,
};
var collapseState = this.isCollapsed() ? 'collapsed' : 'expanded';
var oldSize = this.isViewportNarrow(this._previousViewportWidth) ? 'narrow' : 'wide';
var newSize = this.isViewportNarrow(viewportWidth) ? 'narrow' : 'wide';
transition[collapseState][oldSize][newSize](this);
return this;
};
Sidebar.prototype._collapse = function (isResponsive) {
if (this.isCollapsed()) {
return this;
}
var startEvent = $.Event(EVENT_PREFIX + 'collapse-start', { isResponsive: isResponsive });
this.$el.trigger(startEvent);
if (startEvent.isDefaultPrevented()) {
return this;
}
this.$body.addClass('aui-sidebar-collapsed');
this.$el.attr('aria-expanded', 'false');
this.$el.removeClass('aui-sidebar-fly-out');
this.$el.find(this.submenuTriggersSelector).attr('tabindex', 0);
$(this.inlineDialogSelector).attr('responds-to', 'hover');
if (!this.isAnimated()) {
this.$el.trigger($.Event(EVENT_PREFIX + 'collapse-end', { isResponsive: isResponsive }));
}
return this;
};
Sidebar.prototype.collapse = function () {
return this._collapse(false);
};
Sidebar.prototype._expand = function (viewportWidth, isResponsive) {
var startEvent = $.Event(EVENT_PREFIX + 'expand-start', { isResponsive: isResponsive });
this.$el.trigger(startEvent);
if (startEvent.isDefaultPrevented()) {
return this;
}
var isViewportNarrow = this.isViewportNarrow(viewportWidth);
this.$el.attr('aria-expanded', 'true');
this.$body.toggleClass('aui-sidebar-collapsed', isViewportNarrow);
this.$el.toggleClass('aui-sidebar-fly-out', isViewportNarrow);
this.$el.find(this.submenuTriggersSelector).removeAttr('tabindex');
$(this.inlineDialogSelector).removeAttr('responds-to');
if (!this.isAnimated()) {
this.$el.trigger($.Event(EVENT_PREFIX + 'expand-end', { isResponsive: isResponsive }));
}
return this;
};
Sidebar.prototype.expand = function () {
if (this.isCollapsed()) {
this._expand(this._previousViewportWidth, false);
}
return this;
};
Sidebar.prototype.isAnimated = function () {
return this.$el.hasClass('aui-is-animated');
};
Sidebar.prototype.isCollapsed = function () {
return this.$el.attr('aria-expanded') === 'false';
};
Sidebar.prototype.isViewportNarrow = function (viewportWidth) {
viewportWidth = viewportWidth === undefined ? this._previousViewportWidth : viewportWidth;
return viewportWidth < FORCE_COLLAPSE_WIDTH;
};
Sidebar.prototype.responsiveReflow = function responsiveReflow(isInitialPageLoad, viewportWidth) {
if (isInitialPageLoad) {
if (!this.isCollapsed() && this.isViewportNarrow(viewportWidth)) {
var isAnimated = this.isAnimated();
if (isAnimated) {
this.$el.removeClass('aui-is-animated');
}
// This will trigger the "collapse" event before non-sidebar
// JS code has a chance to bind listeners; they'll need to
// check isCollapsed() if they care about the value at that
// time.
this.collapse();
if (isAnimated) {
// We must trigger a CSS reflow (by accessing
// offsetHeight) otherwise the transition still runs.
this.$el[0].offsetHeight;
this.$el.addClass('aui-is-animated');
}
}
} else if (viewportWidth !== this._previousViewportWidth) {
this.setCollapsedState(viewportWidth);
}
};
// eslint-disable-next-line complexity
Sidebar.prototype.reflow = function reflow(
scrollTop = window.pageYOffset,
viewportHeight = document.documentElement.clientHeight,
viewportWidth = window.innerWidth,
scrollHeight = document.documentElement.scrollHeight,
scrollLeft = window.pageXOffset
) {
// Header height needs to be checked because in Stash it changes when the CSS "transform: translate3d" is changed.
// If you called reflow() after this change then nothing happened because the scrollTop and viewportHeight hadn't changed.
var offsetTop = sidebarOffset(this.$el);
var isInitialPageLoad = this._previousViewportWidth === null;
if (
!(
scrollTop === this._previousScrollTop &&
viewportHeight === this._previousViewportHeight &&
offsetTop === this._previousOffsetTop
)
) {
var isTouch = this.$body.hasClass('aui-page-sidebar-touch');
var isTrackpadBounce =
scrollTop !== this._previousScrollTop &&
(scrollTop < 0 || scrollTop + viewportHeight > scrollHeight);
if (!isTouch && (isInitialPageLoad || !isTrackpadBounce)) {
this.setHeight(scrollTop, viewportHeight, offsetTop);
this.setTopPosition(scrollTop);
}
}
if (scrollLeft !== this._previousScrollLeft) {
this.setLeftPosition(scrollLeft);
}
var isResponsive = this.$el.attr('data-aui-responsive') !== 'false';
if (isResponsive) {
this.responsiveReflow(isInitialPageLoad, viewportWidth);
} else {
var isFlyOut = !this.isCollapsed() && this.isViewportNarrow(viewportWidth);
this.$el.toggleClass('aui-sidebar-fly-out', isFlyOut);
}
this._previousScrollTop = scrollTop;
this._previousViewportHeight = viewportHeight;
this._previousViewportWidth = viewportWidth;
this._previousOffsetTop = offsetTop;
this._previousScrollLeft = scrollLeft;
return this;
};
Sidebar.prototype.toggle = function () {
if (this.isCollapsed()) {
this.expand();
this.$el
.find('.aui-sidebar-toggle')
.attr('aria-label', I18n.getText('aui.sidebar.collapse.tooltip'));
} else {
this.collapse();
this.$el
.find('.aui-sidebar-toggle')
.attr('aria-label', I18n.getText('aui.sidebar.expand.tooltip'));
}
return this;
};
/**
* Returns a jQuery selector string for the trigger elements when the
* sidebar is in a collapsed state, useful for delegated event binding.
*
* When using this selector in event handlers, the element ("this") will
* either be an <a> (when the trigger was a tier-one menu item) or an
* element with class "aui-sidebar-group" (for non-tier-one items).
*
* For delegated event binding you should bind to $el and check the value
* of isCollapsed(), e.g.,
*
* sidebar.$el.on('click', sidebar.collapsedTriggersSelector, function (e) {
* if (!sidebar.isCollapsed()) {
* return;
* }
* });
*
* @returns string
*/
Sidebar.prototype.submenuTriggersSelector = '.aui-sidebar-group:not(.aui-sidebar-group-tier-one)';
Sidebar.prototype.collapsedTriggersSelector = [
Sidebar.prototype.submenuTriggersSelector,
'.aui-sidebar-group.aui-sidebar-group-tier-one > .aui-nav > li > a',
'.aui-sidebar-footer > .aui-sidebar-settings-button',
].join(', ');
Sidebar.prototype.toggleSelector = '.aui-sidebar-footer > .aui-sidebar-toggle';
Sidebar.prototype.tooltipClassName = 'aui-sidebar-section-tooltip';
Sidebar.prototype.inlineDialogClass = 'aui-sidebar-submenu-dialog';
Sidebar.prototype.inlineDialogSelector = '.' + Sidebar.prototype.inlineDialogClass;
function getAllSubmenuDialogs() {
return document.querySelectorAll(Sidebar.prototype.inlineDialogSelector);
}
function SubmenuManager() {
this.inlineDialog = null;
}
SubmenuManager.prototype.submenu = function ($trigger) {
sidebarSubmenuDeprecationLogger();
return getSubmenu($trigger);
};
SubmenuManager.prototype.hasSubmenu = function ($trigger) {
sidebarSubmenuDeprecationLogger();
return hasSubmenu($trigger);
};
SubmenuManager.prototype.submenuHeadingHeight = function () {
sidebarSubmenuDeprecationLogger();
return 34;
};
SubmenuManager.prototype.isShowing = function () {
sidebarSubmenuDeprecationLogger();
return Sidebar.prototype.isSubmenuVisible();
};
SubmenuManager.prototype.show = function (e, trigger) {
sidebarSubmenuDeprecationLogger();
showSubmenu(trigger);
};
SubmenuManager.prototype.hide = function () {
sidebarSubmenuDeprecationLogger();
hideAllSubmenus();
};
SubmenuManager.prototype.inlineDialogShowHandler = function () {
sidebarSubmenuDeprecationLogger();
};
SubmenuManager.prototype.inlineDialogHideHandler = function () {
sidebarSubmenuDeprecationLogger();
};
SubmenuManager.prototype.moveSubmenuToInlineDialog = function () {
sidebarSubmenuDeprecationLogger();
};
SubmenuManager.prototype.restoreSubmenu = function () {
sidebarSubmenuDeprecationLogger();
};
Sidebar.prototype.getVisibleSubmenus = function () {
return Array.prototype.filter.call(getAllSubmenuDialogs(), function (inlineDialog2) {
return inlineDialog2.open;
});
};
Sidebar.prototype.isSubmenuVisible = function () {
return this.getVisibleSubmenus().length > 0;
};
function getSubmenu($trigger) {
return $trigger.is('a') ? $trigger.next('.aui-nav') : $trigger.children('.aui-nav, hr');
}
function getSubmenuInlineDialog(trigger) {
var inlineDialogId = trigger.getAttribute('aria-controls');
return document.getElementById(inlineDialogId);
}
function hasSubmenu($trigger) {
return getSubmenu($trigger).length !== 0;
}
function hideAllSubmenus() {
var allSubmenuDialogs = getAllSubmenuDialogs();
Array.prototype.forEach.call(allSubmenuDialogs, function (inlineDialog2) {
inlineDialog2.open = false;
});
}
function showSubmenu(trigger) {
getSubmenuInlineDialog(trigger).open = true;
}
function constructSubmenu(sidebar, $trigger) {
if ($trigger.data('_aui-sidebar-submenu-constructed')) {
return;
} else {
$trigger.data('_aui-sidebar-submenu-constructed', true);
}
if (!hasSubmenu($trigger)) {
return;
}
var submenuInlineDialog = document.createElement('aui-inline-dialog');
var uniqueId = uid('sidebar-submenu');
$trigger.attr('aria-controls', uniqueId);
$trigger.attr('data-aui-trigger', '');
skate.init($trigger); //Trigger doesn't listen to attribute modification
submenuInlineDialog.setAttribute('id', uniqueId);
submenuInlineDialog.setAttribute('alignment', 'right top');
submenuInlineDialog.setAttribute('hidden', '');
submenuInlineDialog.setAttribute('contained-by', 'viewport');
if (sidebar.isCollapsed()) {
submenuInlineDialog.setAttribute('responds-to', 'hover');
}
$(submenuInlineDialog).addClass(Sidebar.prototype.inlineDialogClass);
document.body.appendChild(submenuInlineDialog);
skate.init(submenuInlineDialog); //Needed so that sidebar.submenus.isShowing() will work on page load
addHandlersToSubmenuInlineDialog(sidebar, $trigger, submenuInlineDialog);
return submenuInlineDialog;
}
function didOtherLayerOpened(e) {
return e.target.tagName !== 'AUI-INLINE-DIALOG';
}
function didNestedInlineDialogOpened(e) {
return !e.target.classList.contains('aui-sidebar-submenu-dialog');
}
function addHandlersToSubmenuInlineDialog(sidebar, $trigger, submenuInlineDialog) {
submenuInlineDialog.addEventListener('aui-layer-show', function (e) {
if (!sidebar.isCollapsed()) {
e.preventDefault();
return;
}
// we only care if sidebars inline dialog is opening
if (didOtherLayerOpened(e) || didNestedInlineDialogOpened(e)) {
return;
}
/**
* trigger an event on inline dialog trigger and pass a reference to the inline dialog.
* this let's one perform actions and modify content before it is displayed, for example lazy load submenu content
*/
var customEvent = $.Event('aui-sidebar-submenu-before-show');
$trigger.trigger(customEvent, submenuInlineDialog);
if (customEvent.isDefaultPrevented()) {
e.preventDefault();
return;
}
inlineDialogShowHandler($trigger, submenuInlineDialog);
});
submenuInlineDialog.addEventListener('aui-layer-hide', function () {
inlineDialogHideHandler($trigger);
});
}
function inlineDialogShowHandler($trigger, submenuInlineDialog) {
$trigger.addClass('active');
submenuInlineDialog.innerHTML = SUBMENU_INLINE_DIALOG_CONTENTS_HTML;
var title = $trigger.is('a') ? $trigger.text() : $trigger.children('.aui-nav-heading').text();
var $container = $(submenuInlineDialog).find('.aui-navgroup-inner');
$container.children('.aui-nav-heading').attr('title', title).children('strong').text(title);
var $submenu = getSubmenu($trigger);
cloneExpander($submenu).appendTo($container);
/**
* Workaround to show all contents in the expander.
* This function should come from the expander component.
*/
function cloneExpander(element) {
const $clone = clone(element);
deduplicateIDs($clone, uid);
if ($clone.hasClass('aui-expander-content')) {
$clone.find('.aui-expander-cutoff').remove();
$clone.removeClass('aui-expander-content');
}
return $clone;
}
}
const SUBMENU_INLINE_DIALOG_CONTENTS_HTML =
'<div class="aui-inline-dialog-contents">' +
'<div class="aui-sidebar-submenu" >' +
'<div class="aui-navgroup aui-navgroup-vertical">' +
'<div class="aui-navgroup-inner">' +
'<div class="aui-nav-heading"><strong></strong></div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
function inlineDialogHideHandler($trigger) {
$trigger.removeClass('active');
}
function constructAllSubmenus(sidebar) {
$(sidebar.collapsedTriggersSelector).each(function () {
var $trigger = $(this);
constructSubmenu(sidebar, $trigger);
});
}
var tooltipOptions = {
gravity: 'w',
title: function () {
var $item = $(this);
if ($item.is('a') || $item.is('button')) {
return (
$item.attr('title') ||
$item.find('.aui-nav-item-label').text() ||
$item.data('tooltip')
);
} else {
return (
$item.children('.aui-nav').attr('title') ||
$item.children('.aui-nav-heading').text()
);
}
},
};
function lazilyInitializeSubmenus(sidebar) {
sidebar.$el.on(
'mouseenter mouseleave click focus',
sidebar.collapsedTriggersSelector,
function (e) {
const $trigger = $(e.target);
constructSubmenu(sidebar, $trigger);
}
);
}
function initializeHandlers(sidebar) {
var $sidebar = $('.aui-sidebar');
if (!$sidebar.length) {
return;
}
lazilyInitializeSubmenus(sidebar);
// AUI-2542: only enter touch mode on small screen touchable devices
if (hasTouch && mediaQuery('only screen and (max-device-width:1024px)')) {
$('body').addClass('aui-page-sidebar-touch');
}
var pendingReflow = null;
var onScrollResizeReflow = function () {
if (pendingReflow === null) {
pendingReflow = requestAnimationFrame(function () {
sidebar.reflow();
pendingReflow = null;
});
}
};
$(window).on('scroll resize', onScrollResizeReflow);
onScrollResizeReflow();
if (sidebar.isAnimated()) {
sidebar.$el.on('transitionend webkitTransitionEnd', function () {
sidebar.$el.trigger(
$.Event(EVENT_PREFIX + (sidebar.isCollapsed() ? 'collapse-end' : 'expand-end'))
);
});
}
sidebar.$el.on('click', '.aui-sidebar-toggle', function (e) {
e.preventDefault();
sidebar.toggle();
});
$('.aui-page-panel').on('click', function () {
if (!sidebar.isCollapsed() && sidebar.isViewportNarrow()) {
sidebar.collapse();
}
});
var toggleShortcutHandler = function (e) {
if (isNormalSquareBracket(e)) {
sidebar.toggle();
}
};
//We use keypress because it captures the actual character that was typed and not the physical key that was pressed.
//This accounts for other keyboard layouts
$(document).on('keypress', toggleShortcutHandler);
sidebar._remove = function () {
$(this.inlineDialogSelector).remove();
this.$el.off();
this.$el.remove();
$(document).off('keypress', toggleShortcutHandler);
$(window).off('scroll resize', onScrollResizeReflow);
};
sidebar.$el.on('touchend', function (e) {
if (sidebar.isCollapsed()) {
sidebar.expand();
e.preventDefault();
}
});
sidebar.$el.tooltip({
...tooltipOptions,
live: sidebar.collapsedTriggersSelector,
suppress: function () {
const $trigger = $(this);
const sidebarIsExpanded = sidebar.isCollapsed() === false;
const triggerHasSubmenu = hasSubmenu($trigger) === true;
return triggerHasSubmenu || sidebarIsExpanded;
},
});
sidebar.$el.tooltip({
...tooltipOptions,
aria: false,
live: sidebar.toggleSelector,
title: function () {
return sidebar.isCollapsed()
? I18n.getText('aui.sidebar.expand.tooltip')
: I18n.getText('aui.sidebar.collapse.tooltip');
},
});
function isNormalTab(e) {
return e.keyCode === keyCode.TAB && !e.shiftKey && !e.altKey;
}
function isNormalSquareBracket(e) {
return (
e.which === keyCode.LEFT_SQUARE_BRACKET &&
!e.shiftKey &&
!e.ctrlKey &&
!e.metaKey &&
!isInput(e.target)
);
}
function isShiftTab(e) {
return e.keyCode === keyCode.TAB && e.shiftKey;
}
function isFirstSubmenuItem(item, $submenuDialog) {
return item === $submenuDialog.find(':aui-tabbable')[0];
}
function isLastSubmenuItem(item, $submenuDialog) {
return item === $submenuDialog.find(':aui-tabbable').last()[0];
}
/**
* Force to focus on the first tabbable item in inline dialog.
* Reason: inline dialog will be hidden as soon as the trigger is out of focus (onBlur event)
* This function should come directly from inline dialog component.
*/
function focusFirstItemOfInlineDialog($inlineDialog) {
$inlineDialog.attr('persistent', '');
// don't use :aui-tabbable:first as it will select the first tabbable item in EACH nav group
$inlineDialog.find(':aui-tabbable').first().focus();
// workaround on IE:
// delay the persistence of inline dialog to make sure onBlur event was triggered first
setTimeout(function () {
$inlineDialog.removeAttr('persistent');
}, 100);
}
sidebar.$el.on('keydown', sidebar.collapsedTriggersSelector, function (e) {
if (sidebar.isCollapsed()) {
var triggerEl = e.target;
var submenuInlineDialog = getSubmenuInlineDialog(triggerEl);
if (!submenuInlineDialog) {
return;
}
var $submenuInlineDialog = $(submenuInlineDialog);
if (isNormalTab(e) && submenuInlineDialog.open) {
e.preventDefault();
focusFirstItemOfInlineDialog($submenuInlineDialog);
$submenuInlineDialog.on('keydown', function (e) {
if (
(isShiftTab(e) && isFirstSubmenuItem(e.target, $submenuInlineDialog)) ||
(isNormalTab(e) && isLastSubmenuItem(e.target, $submenuInlineDialog))
) {
triggerEl.focus();
// unbind event and close submenu as the focus is out of the submenu
$(this).off('keydown');
hideAllSubmenus();
}
});
}
}
});
}
var sidebar = widget('sidebar', Sidebar);
$(function () {
sidebar('.aui-sidebar');
});
var sidebarSubmenuDeprecationLogger = deprecate.getMessageLogger('Sidebar.submenus', {
removeInVersion: '10.0.0',
sinceVersion: '5.8.0',
});
globalize('sidebar', sidebar);
export default sidebar;