UNPKG

@atlassian/aui

Version:

Atlassian User Interface library

743 lines (630 loc) 24.2 kB
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;