diff --git a/css/backoffice/blocks-integrations/_panel-with-tab-container.scss b/css/backoffice/blocks-integrations/_panel-with-tab-container.scss index 9aa0d3b04..d148f837f 100644 --- a/css/backoffice/blocks-integrations/_panel-with-tab-container.scss +++ b/css/backoffice/blocks-integrations/_panel-with-tab-container.scss @@ -13,7 +13,9 @@ $ibo-panel-with-tab-container--padding-top: -1 * ($ibo-panel--body--padding-top $ibo-panel-with-tab-container--margin-x: -1 * $ibo-panel--body--padding-x !default; $ibo-panel-with-tab-container--margin-bottom: -1 * $ibo-panel--body--padding-bottom !default; -// Note: We use the child ">" selector to ensure this applies only the child tab container, not another one that would be nested +$ibo-panel-with-tab-container--tab-toggler--font-size--is-sticking: $ibo-font-size-100 !default; + +// Note: We use the child ">" selector to ensure this applies only to the child tab container, not another one that would be nested .ibo-panel { > .ibo-panel--body { > .ibo-tab-container { @@ -59,4 +61,27 @@ $ibo-panel-with-tab-container--margin-bottom: -1 * $ibo-panel--body--padding-bot } } } -} \ No newline at end of file + + /* Sticky header rules */ + &.ibo-has-sticky-header { + > .ibo-panel--body { + > .ibo-tab-container { + > .ibo-tab-container--tabs-list.ibo-is-sticking { + position: fixed; + z-index: 10; + } + + &:not(.ibo-is-vertical){ + > .ibo-tab-container--tabs-list.ibo-is-sticking { + padding-left: 0; + + .ibo-tab-container--tab-toggler, + .ibo-tab-container--extra-tabs-list-toggler { + font-size: $ibo-panel-with-tab-container--tab-toggler--font-size--is-sticking; + } + } + } + } + } + } +} diff --git a/css/backoffice/components/_panel.scss b/css/backoffice/components/_panel.scss index 667b365da..058fc0c42 100644 --- a/css/backoffice/components/_panel.scss +++ b/css/backoffice/components/_panel.scss @@ -17,8 +17,6 @@ */ /* SCSS variables */ -$ibo-panel--spacing-top: 24px !default; - /* - Base variables for the block */ $ibo-panel--base-border-size: 1px !default; $ibo-panel--base-border-style: solid !default; @@ -35,18 +33,14 @@ $ibo-panel--base-transition: $ibo-panel--base-transition-property $ibo-panel--ba } /* - Specific variables for the block */ -$ibo-panel--header--margin-bottom: 4px !default; - -$ibo-panel--header--background-color--is-sticking: $ibo-color-grey-100 !default; -$ibo-panel--header--border--is-sticking: $ibo-panel--base-border !default; - -$ibo-panel--header--padding-y--is-sticking: 4px !default; +$ibo-panel--spacing-top: 24px !default; $ibo-panel--highlight--width: 100% !default; $ibo-panel--highlight--height: 8px !default; $ibo-panel--highlight--border-radius: $ibo-border-radius-400 $ibo-border-radius-400 0 0 !default; $ibo-panel--highlight--padding-bottom: $ibo-panel--highlight--height !default; +$ibo-panel--body--z-index: 1 !default; $ibo-panel--body--background-color: $ibo-color-white-100 !default; $ibo-panel--body--padding-bottom: 24px !default; $ibo-panel--body--padding-top: $ibo-panel--body--padding-bottom + $ibo-panel--highlight--height !default; @@ -54,6 +48,12 @@ $ibo-panel--body--padding-x: 16px !default; $ibo-panel--body--border-radius: $ibo-border-radius-500 !default; $ibo-panel--body--border: $ibo-panel--base-border !default; +$ibo-panel--header--z-index: $ibo-panel--body--z-index + 1 !default; /* Should always be above the body */ +$ibo-panel--header--margin-bottom: 4px !default; +$ibo-panel--header--background-color--is-sticking: $ibo-color-grey-100 !default; +$ibo-panel--header--border--is-sticking: $ibo-panel--base-border !default; +$ibo-panel--header--padding-y--is-sticking: 4px !default; + $ibo-panel--icon--size: 48px !default; $ibo-panel--icon--spacing: 16px !default; $ibo-panel--icon--size-as-medallion: 72px !default; @@ -163,6 +163,8 @@ $ibo-panel-colors: ( } .ibo-panel--header { + position: relative; + z-index: $ibo-panel--header--z-index; display: flex; justify-content: space-between; align-items: flex-end; @@ -217,6 +219,7 @@ $ibo-panel-colors: ( .ibo-panel--body { position: relative; + z-index: $ibo-panel--body--z-index; padding: $ibo-panel--body--padding-top $ibo-panel--body--padding-x $ibo-panel--body--padding-bottom $ibo-panel--body--padding-x; background-color: $ibo-panel--body--background-color; border: $ibo-panel--body--border; @@ -262,8 +265,8 @@ $ibo-panel-colors: ( // Note: Direct child selector is mandatory, otherwise a panel within a panel could be affected too when it shouldn't (eg. dashboard in an object, n:n panel) > .ibo-panel--header { position: sticky; - z-index: 1; top: 0; /* Default, stick on the top of the panel. Custom integrations should be done in the "blocks-integrations" partials */ + border: transparent; /* To avoid visual glitch during transition, otherwise the border looks black first */ /* All transitions should have a specific duration except for the header's "top" property otherwise it feels laggy */ /* - The header itself */ diff --git a/js/components/panel.js b/js/components/panel.js index 68b05c8ff..6b4a57318 100644 --- a/js/components/panel.js +++ b/js/components/panel.js @@ -20,7 +20,7 @@ $(function () { // the widget definition, where 'itop' is the namespace, // 'panel' the widget name - $.widget('itop.panel', + $.widget('itop.panel', $.itop.ui_block, { // default options options: @@ -33,17 +33,19 @@ $(function () { css_classes: { has_sticky_header: 'ibo-has-sticky-header', - is_sticking: 'ibo-is-sticking', sticky_sentinel: 'ibo-sticky-sentinel', sticky_sentinel_top: 'ibo-sticky-sentinel-top', }, - js_selectors: - { - modal: '.ui-dialog', - modal_content: '.ui-dialog-content', + js_selectors: { + global: {}, + block: { panel_header: '[data-role="ibo-panel--header"]:first', - panel_header_sticky_sentinel_top: '[data-role="ibo-panel--header--sticky-sentinel-top"]', - }, + panel_header_sticky_sentinel_top: '[data-role="ibo-panel--header--sticky-sentinel-top"]:first', + panel_body: '[data-role="ibo-panel--body"]:first', + tab_container: '[data-role="ibo-tab-container"]:first', + tab_container_tabs_list: '[data-role="ibo-tab-container--tabs-list"]:first', + } + }, // {ScrollMagic.Controller} SM controller for the sticky header sticky_header_controller: null, @@ -82,6 +84,14 @@ $(function () { oBodyElem.on('dialogopen', function(){ me._updateStickyHeaderHandler(); }); + + // Observe the panel resizes in order to adjust the tabs list; only necessary when header is sticky for now + if(window.ResizeObserver) { + const oPanelRO = new ResizeObserver(function(){ + me._updateTabsListPosition(); + }); + oPanelRO.observe(this.element[0]); + } } }, @@ -94,10 +104,8 @@ $(function () { _updateStickyHeaderHandler: function () { const me = this; - // Determine in which kind of container the panel is - let oNewViewportElem = this.element.scrollParent()[0]; - // If viewport hasn't changed, there is no need to refresh the SM controller + let oNewViewportElem = this.element.scrollParent()[0]; if (oNewViewportElem === this.options.viewport_elem) { return; } @@ -116,24 +124,74 @@ $(function () { }); let oSMScene = new ScrollMagic.Scene({ - triggerElement: this.element.find(this.js_selectors.panel_header_sticky_sentinel_top)[0], + // Traduction: As soon as the header's top sentinel... + triggerElement: this.element.find(this.js_selectors.block.panel_header_sticky_sentinel_top)[0], + // ... leaves the viewport... triggerHook: 0, duration: this.element.outerHeight(), - offset: this.element.find(this.js_selectors.panel_header_sticky_sentinel_top).outerHeight() + offset: this.element.find(this.js_selectors.block.panel_header_sticky_sentinel_top).outerHeight() }) - .on('enter', function(){ + // ... we consider the header as sticking... + .on('enter', function () { me._onHeaderBecomesSticky(); }) - .on('leave', function(){ + // ... and when it comes back in the viewport, it stops. + .on('leave', function () { me._onHeaderStopsBeingSticky(); }) .addTo(this.sticky_header_controller); }, _onHeaderBecomesSticky: function () { - this.element.find(this.js_selectors.panel_header).addClass(this.css_classes.is_sticking); + this.element.find(this.js_selectors.block.panel_header).addClass(this.css_classes.is_sticking); + if (this._hasTabContainer()) { + this._updateTabsListPosition(false /* Need to wait for the header transition to end */); + } }, _onHeaderStopsBeingSticky: function () { - this.element.find(this.js_selectors.panel_header).removeClass(this.css_classes.is_sticking); + this.element.find(this.js_selectors.block.panel_header).removeClass(this.css_classes.is_sticking); + if (this._hasTabContainer()) { + this._updateTabsListPosition(false /* Need to wait for the header transition to end */); + } + }, + /** + * Update the position of the tabs list so it is consistent with the header, which is important when the header is sticky + * + * @param bImmediate {boolean} Should the position be updated immediatly or delayed (typically if we have to wait for a transition to end) + * @private + */ + _updateTabsListPosition: function(bImmediate = true) { + // Vertical tab container is not supported yet + if(this._isTabContainerVertical()) { + return; + } + + const me = this; + const oTabsListElem = this.element.find(this.js_selectors.block.tab_container_tabs_list); + + if(this._isHeaderSticking()){ + // Unfortunately for now the timeout is hardcoded as we don't know how to get notified when the *CSS* transition is done. + const iTimeout = bImmediate ? 0 : 300; + setTimeout(function(){ + const oHeaderElem = me.element.find(me.js_selectors.block.panel_header); + const oHeaderOffset = oHeaderElem.offset(); + const iHeaderWidth = oHeaderElem.outerWidth(); + const iHeaderHeight = oHeaderElem.outerHeight(); + const iPanelBorderWidth = parseInt(me.element.find(me.js_selectors.block.panel_body).css('border-left-width')); + + oTabsListElem + .css('top', oHeaderOffset.top + iHeaderHeight) + .css('left', oHeaderOffset.left + iPanelBorderWidth) + .css('width', iHeaderWidth - (2 * iPanelBorderWidth)) + .addClass(me.css_classes.is_sticking); + }, iTimeout); + } else { + // Reset to default style + oTabsListElem + .css('top', '') + .css('left', '') + .css('width', '') + .removeClass(me.css_classes.is_sticking); + } }, // Helpers @@ -144,5 +202,29 @@ $(function () { _isHeaderVisibleOnScroll: function () { return this.options.is_header_visible_on_scroll; }, + /** + * @return {boolean} True if the header is currently sticking + * @private + */ + _isHeaderSticking: function () { + return this.element.find(this.js_selectors.block.panel_header).hasClass(this.css_classes.is_sticking); + }, + /** + * @return {boolean} True if the panel has a tab container + * @private + */ + _hasTabContainer: function () { + return this.element.find(this.js_selectors.block.tab_container).length > 0; + }, + /** + * @return {boolean} True if the panel has a tab container and it is vertical, false otherwise + * @private + */ + _isTabContainerVertical: function () { + if(!this._hasTabContainer()) { + return false; + } + return this.element.find(this.js_selectors.block.tab_container).hasClass(this.css_classes.is_vertical); + }, }); }); diff --git a/js/layouts/object/object-details.js b/js/layouts/object/object-details.js index 73baf4960..7d4394902 100644 --- a/js/layouts/object/object-details.js +++ b/js/layouts/object/object-details.js @@ -7,7 +7,7 @@ $(function() { // the widget definition, where 'itop' is the namespace, - // 'panel' the widget name + // 'object_details' the widget name $.widget( 'itop.object_details', $.itop.panel, { // default options @@ -32,5 +32,21 @@ $(function() { this._super(); }, + + _bindEvents: function () + { + this._super(); + + // Keep URL's hash parameters when clicking on a link of the header + this.element.on('click', '[data-role="ibo-panel--header-right"] a', function() { + aMatches = /#(.*)$/.exec(window.location.href); + if (aMatches != null) { + currentHash = aMatches[1]; + if (/#(.*)$/.test(this.href)) { + this.href = this.href.replace(/#(.*)$/, '#'+currentHash); + } + } + }); + }, }); }); diff --git a/js/layouts/tab-container/scrollable-tabs.js b/js/layouts/tab-container/scrollable-tabs.js index 125206055..c205e5a8c 100644 --- a/js/layouts/tab-container/scrollable-tabs.js +++ b/js/layouts/tab-container/scrollable-tabs.js @@ -20,6 +20,8 @@ $.widget( "itop.scrollabletabs", $.ui.tabs, { } }, }, + // Used keep the beginning of the panel visible when scrolling to it + scroll_offset_y: null, controller: null, _create: function() { var me = this; @@ -34,7 +36,9 @@ $.widget( "itop.scrollabletabs", $.ui.tabs, { this.element.on('scrollabletabsload', afterloadajax); this._super(this.options); - + + // Initialize the vertical scroll offset + this.scroll_offset_y = this.element.find('#' + this.tabs.eq(0).attr('data-tab-id')).offset().top; // Add every other tab to the controller $(this.js_selectors.tab_toggler).each(function(){ @@ -47,18 +51,27 @@ $.widget( "itop.scrollabletabs", $.ui.tabs, { // Set active tab, tab-container gives us a tab based on url hash or 0 this.setTab(this._findActive(this.options.active)); - this.controller.scrollTo('#' + this.tabs.eq(this.options.active).attr('data-tab-id')); + // If not on the first tab, we scroll directly to it + // Note: We don't want to scroll if we are on the first one, otherwise it will looks buggy because the page will be a bit scrolled and it doesn't feel right + if(this.options.active > 0) { + const oActiveTab = this.tabs.eq(this.options.active); + const oActivePanel = this.element.find('#' + oActiveTab.attr('data-tab-id')); + + // Remove from scroll length the initial space between the top of the first panel and the top of the screen; this is to avoid scrolling too far + // That being said, as lists are fetched / updated asynchroniously, once they got their responses, the layout will change/shift and the current tab won't be the good one anymore 😕 + this.controller.scrollTo(oActivePanel.offset().top - this.scroll_offset_y); + } }, // Create a new scene to be added to the controller _newScene: function(tab, panel) { var me = this; - var iPanelId = panel.attr('id'); + var sPanelId = panel.attr('id'); return new ScrollMagic.Scene({ - triggerElement: '#' + iPanelId, - triggerHook: 0.03, // show, when scrolled 10% into view + triggerElement: '#' + sPanelId, + triggerHook: 0.2, // show, when scrolled 20% into view duration: function () { - return $('#' + iPanelId).outerHeight(); + return $('#' + sPanelId).outerHeight(); } }) .on("enter", function (event) { diff --git a/js/layouts/tab-container/tab-container.js b/js/layouts/tab-container/tab-container.js index 0d599f59a..cdc76251b 100644 --- a/js/layouts/tab-container/tab-container.js +++ b/js/layouts/tab-container/tab-container.js @@ -15,6 +15,8 @@ $(function() css_classes: { is_hidden: 'ibo-is-hidden', + is_transparent: 'ibo-is-transparent', + is_opaque: 'ibo-is-opaque', is_scrollable: 'ibo-is-scrollable', tab_container: 'ibo-tab-container', }, @@ -106,17 +108,30 @@ $(function() me._onTabTogglerClick($(this)); }); // Resize of the tab container - if(window.ResizeObserver) - { - const oTabsListRO = new ResizeObserver(function(){ - // Note: For a reason I don't understand, when called instantly the sub function CombodoGlobalToolbox.IsElementVisibleToTheUser() won't be able to retrieve an element using the document.elementFromPoint() function - // As it won't return anything, the function always thinks it's invisible... - setTimeout(function(){ - me._onTabContainerResize(); - }, 200); - - }); - oTabsListRO.observe($('.ibo-tab-container--tabs-list')[0]); + if(window.IntersectionObserver) { + const oTabsListIntersectObs = new IntersectionObserver(function(aEntries, oTabsListIntersectObs){ + aEntries.forEach(oEntry => { + let oTabHeaderElem = $(oEntry.target); + let bIsVisible = oEntry.isIntersecting; + if(bIsVisible) { + oTabHeaderElem.removeClass(me.css_classes.is_transparent); + oTabHeaderElem.css('visibility', ''); + } + else { + oTabHeaderElem.removeClass(me.css_classes.is_transparent); + // This is necessary, otherwise link will still be clickable + oTabHeaderElem.css('visibility', 'hidden'); + } + me._updateTabHeaderDisplay(oTabHeaderElem, bIsVisible); + }); + me._updateExtraTabsList(); + }, { + root: $('.ibo-tab-container--tabs-list')[0], + threshold: [1] // Must be completely visible + }); + this.element.find(this.js_selectors.tab_header).each(function(){ + oTabsListIntersectObs.observe(this); + }); } // Click on extra tabs list toggler this.element.find(this.js_selectors.extra_tabs_list_toggler).on('click', function(oEvent){ @@ -233,18 +248,25 @@ $(function() /** * Update tab header display based on its visibility to the user * - * @param oTabHeaderElem jQuery element + * @param oTabHeaderElem {Object} jQuery element + * @param bIsVisible {boolean|null} If null, visibility will be computed automatically. Not that performance might not be great so it's preferable to pass the value when known * @private */ - _updateTabHeaderDisplay(oTabHeaderElem) + _updateTabHeaderDisplay(oTabHeaderElem, bIsVisible = null) { const sTabId = oTabHeaderElem.attr('data-tab-id'); const oMatchingExtraTabElem = this.element.find(this.js_selectors.extra_tab_toggler+'[href="#'+sTabId+'"]'); - if (!CombodoGlobalToolbox.IsElementVisibleToTheUser(oTabHeaderElem[0], true, 2)) { - oMatchingExtraTabElem.removeClass(this.css_classes.is_hidden); - } else { + // Manually check if the tab header is visible if the info isn't passed + if (bIsVisible === null) { + bIsVisible = CombodoGlobalToolbox.IsElementVisibleToTheUser(oTabHeaderElem[0], true, 2); + } + + // Hide/show the corresponding extra tab element + if (bIsVisible) { oMatchingExtraTabElem.addClass(this.css_classes.is_hidden); + } else { + oMatchingExtraTabElem.removeClass(this.css_classes.is_hidden); } }, // - Update extra tabs list diff --git a/js/ui-block.js b/js/ui-block.js index 85493e8cc..aaf0f9f06 100644 --- a/js/ui-block.js +++ b/js/ui-block.js @@ -26,6 +26,7 @@ $(function () { options: {}, css_classes: { is_sticking: 'ibo-is-sticking', + is_vertical: 'ibo-is-vertical', }, js_selectors: { // Selectors that target any elements in the DOM diff --git a/templates/base/layouts/object/object-details/layout.js.twig b/templates/base/layouts/object/object-details/layout.js.twig index 9d8f70890..77e47d719 100644 --- a/templates/base/layouts/object/object-details/layout.js.twig +++ b/templates/base/layouts/object/object-details/layout.js.twig @@ -25,19 +25,6 @@ {% endif %} {% endblock %} -{% block iboPanelHeaderRightActionsHandlers %} - // Keep URL's hash parameters when clicking on a link of the header - $('#{{ oUIBlock.GetId() }}').on('click', '[data-role="ibo-panel--header-right"] a', function() { - aMatches = /#(.*)$/.exec(window.location.href); - if (aMatches != null) { - currentHash = aMatches[1]; - if (/#(.*)$/.test(this.href)) { - this.href = this.href.replace(/#(.*)$/, '#'+currentHash); - } - } -}); -{% endblock %} - {% block iboWidgetInstantiation %} $('#{{ oUIBlock.GetId() }}').object_details({ is_header_visible_on_scroll: {{ oUIBlock.IsHeaderVisibleOnScroll|var_export }}