UNPKG

@atlassian/aui

Version:

Atlassian User Interface Framework

747 lines (623 loc) 25.3 kB
'use strict'; import $ from './jquery'; import '../../js-vendor/jquery/jquery.tipsy'; import '../../js-vendor/raf/raf'; import './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'; var SUPPORTS_TRANSITIONS = (typeof document.documentElement.style.transition !== 'undefined') || (typeof document.documentElement.style.webkitTransition !== 'undefined'); 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: '8.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 SUPPORTS_TRANSITIONS && 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._removeAllTooltips = function () { // tooltips are orphaned when sidebar is expanded, so if there are any visible on the page we remove them all. // Can't scope it to the Sidebar (this) because the tooltip div is a direct child of <body> $(this.tooltipSelector).remove(); }; 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. // eslint-disable-next-line this.$el[0].offsetHeight; this.$el.addClass('aui-is-animated'); } } } else if (viewportWidth !== this._previousViewportWidth) { this.setCollapsedState(viewportWidth); } }; 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)) { if (this.isCollapsed() && !isInitialPageLoad && scrollTop !== this._previousScrollTop) { // hide submenu and tooltips on scroll hideAllSubmenus(); this._removeAllTooltips(); } 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._removeAllTooltips(); } else { this.collapse(); } 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.tooltipSelector = '.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('aria-hidden', 'true'); 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 addHandlersToSubmenuInlineDialog(sidebar, $trigger, submenuInlineDialog) { submenuInlineDialog.addEventListener('aui-layer-show', function (e) { if (!sidebar.isCollapsed()) { 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) { var $clone = clone(element); 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 tipsyOpts = { trigger: 'manual', gravity: 'w', className: 'aui-sidebar-section-tooltip', title: function () { var $item = $(this); if ($item.is('a')) { 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 showTipsy($trigger) { $trigger.tipsy(tipsyOpts).tipsy('show'); var $tip = $trigger.data('tipsy') && $trigger.data('tipsy').$tip; if ($tip) { // if .aui-sidebar-group does not have a title to display // Remove "opacity" inline style from Tipsy to allow the our own styles and transitions to be applied $tip.css({opacity: ''}).addClass('tooltip-shown'); } } function hideTipsy($trigger) { var $tip = $trigger.data('tipsy') && $trigger.data('tipsy').$tip; if ($tip) { var durationStr = $tip.css('transition-duration'); if (durationStr) { // can be denominated in either s or ms var timeoutMs = (durationStr.indexOf('ms') >= 0) ? parseInt(durationStr.substring(0, durationStr.length - 2), 10) : 1000 * parseInt(durationStr.substring(0, durationStr.length - 1), 10); // use a timeout because the transitionend event is not reliable (yet), // more details here: https://bitbucket.atlassian.net/browse/BB-11599 // an example of this at http://labs.silverorange.com/files/webkit-bug/ // further caveats here: https://developer.mozilla.org/en-US/docs/Web/Events/transitionend // "In the case where a transition is removed before completion, // such as if the transition-property is removed, then the event will not fire." setTimeout(function () { $trigger.tipsy('hide'); }, timeoutMs); } $tip.removeClass('tooltip-shown'); } } 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').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._removeAllTooltips(); $(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.on('mouseenter focus', sidebar.collapsedTriggersSelector, function () { if (!sidebar.isCollapsed()) { return; } var $trigger = $(this); if (!hasSubmenu($trigger)) { showTipsy($trigger); } }); sidebar.$el.on('click blur mouseleave', sidebar.collapsedTriggersSelector, function () { if (!sidebar.isCollapsed()) { return; } hideTipsy($(this)); }); sidebar.$el.on('mouseenter focus', sidebar.toggleSelector, function () { var $trigger = $(this); if (sidebar.isCollapsed()) { $trigger.data('tooltip', AJS.I18n.getText('aui.sidebar.expand.tooltip')); } else { $trigger.data('tooltip', AJS.I18n.getText('aui.sidebar.collapse.tooltip')); } showTipsy($trigger); }); sidebar.$el.on('click blur mouseleave', sidebar.toggleSelector, function () { hideTipsy($(this)); }); 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: '8.0', sinceVersion: '5.8' }); globalize('sidebar', sidebar); export default sidebar;