diff --git a/css/backoffice/components/_breadcrumbs.scss b/css/backoffice/components/_breadcrumbs.scss index b2ac2f193..321a2154b 100644 --- a/css/backoffice/components/_breadcrumbs.scss +++ b/css/backoffice/components/_breadcrumbs.scss @@ -16,6 +16,8 @@ * You should have received a copy of the GNU Affero General Public License */ +$ibo-breadcrumbs--margin-right: 16px !default; + $ibo-breadcrumbs--item--text-color: $ibo-color-grey-800 !default; $ibo-breadcrumbs--item-icon--margin-x: 8px !default; @@ -27,9 +29,27 @@ $ibo-breadcrumbs--item-label--max-width: 100px !default; $ibo-breadcrumbs--item-separator--margin-x: 12px !default; $ibo-breadcrumbs--item-separator--text-color: $ibo-color-grey-500 !default; +$ibo-breadcrumbs--previous-items-list-toggler--text-color: $ibo-color-grey-700 !default; +$ibo-breadcrumbs--previous-items-list-toggler--margin-right: 2 * $ibo-breadcrumbs--item-separator--margin-x !default; + +$ibo-breadcrumbs--previous-items-list--top: 37px !default; +$ibo-breadcrumbs--previous-items-list--padding-y: 8px !default; +$ibo-breadcrumbs--previous-items-list--background-color: $ibo-color-white-100 !default; + +$ibo-breadcrumbs--previous-item--text-color: $ibo-breadcrumbs--item--text-color !default; +$ibo-breadcrumbs--previous-item--padding-x: 12px !default; +$ibo-breadcrumbs--previous-item--padding-y: 12px !default; +$ibo-breadcrumbs--previous-item-label--max-width: 200px !default; + .ibo-breadcrumbs{ + position: relative; + margin-right: $ibo-breadcrumbs--margin-right; @extend %ibo-full-height-content; + &.ibo-is-overflowing { + justify-content: right; + } + * { display: flex; align-items: center; @@ -40,13 +60,6 @@ $ibo-breadcrumbs--item-separator--text-color: $ibo-color-grey-500 !default; @extend %ibo-font-ral-med-100; &:not(:last-child){ - &::after{ - content: '\f054'; - margin: 0 $ibo-breadcrumbs--item-separator--margin-x; - color: $ibo-breadcrumbs--item-separator--text-color; - @extend %fa-solid-base; - } - &:hover{ .ibo-breadcrumbs--item-icon{ > *{ @@ -78,3 +91,50 @@ $ibo-breadcrumbs--item-separator--text-color: $ibo-color-grey-500 !default; max-width: $ibo-breadcrumbs--item-label--max-width; @extend %ibo-text-truncated-with-ellipsis; } + +.ibo-breadcrumbs--item, +.ibo-breadcrumbs--previous-items-list-toggler { + &:not(:last-child){ + &::after{ + content: '\f054'; + margin: 0 $ibo-breadcrumbs--item-separator--margin-x; + color: $ibo-breadcrumbs--item-separator--text-color; + @extend %fa-solid-base; + } + } +} + +.ibo-breadcrumbs--previous-items-list-toggler { + margin-right: $ibo-breadcrumbs--previous-items-list-toggler--margin-right; + color: $ibo-breadcrumbs--previous-items-list-toggler--text-color !important; + @extend %ibo-font-ral-med-100; + + &:not(:last-child) { + &::after { + position: absolute; /* To be outside of the button */ + right: -1 * $ibo-breadcrumbs--previous-items-list-toggler--margin-right; + } + } +} +.ibo-breadcrumbs--previous-items-list { + display: flex; + flex-direction: column; + align-items: stretch; /* For the items to occupy the full width */ + + position: fixed; + top: $ibo-breadcrumbs--previous-items-list--top; + padding: $ibo-breadcrumbs--previous-items-list--padding-y 0; + + background-color: $ibo-breadcrumbs--previous-items-list--background-color; + @extend %ibo-elevation-300; +} + +.ibo-breadcrumbs--previous-item { + color: $ibo-breadcrumbs--previous-item--text-color; + @extend %ibo-font-ral-med-100; + padding: $ibo-breadcrumbs--previous-item--padding-y $ibo-breadcrumbs--previous-item--padding-x; + + .ibo-breadcrumbs--item-label { + max-width: 200px; + } +} \ No newline at end of file diff --git a/dictionaries/ui/components/breadcrumbs/en.dictionary.itop.breadcrumbs.php b/dictionaries/ui/components/breadcrumbs/en.dictionary.itop.breadcrumbs.php new file mode 100644 index 000000000..65a77eadf --- /dev/null +++ b/dictionaries/ui/components/breadcrumbs/en.dictionary.itop.breadcrumbs.php @@ -0,0 +1,23 @@ + 'Previous pages', +)); \ No newline at end of file diff --git a/dictionaries/ui/components/breadcrumbs/fr.dictionary.itop.breadcrumbs.php b/dictionaries/ui/components/breadcrumbs/fr.dictionary.itop.breadcrumbs.php new file mode 100644 index 000000000..6ca4f71f5 --- /dev/null +++ b/dictionaries/ui/components/breadcrumbs/fr.dictionary.itop.breadcrumbs.php @@ -0,0 +1,23 @@ + 'Pages précédentes', +)); \ No newline at end of file diff --git a/js/components/breadcrumbs.js b/js/components/breadcrumbs.js index 6255a74f8..a463e0bc2 100644 --- a/js/components/breadcrumbs.js +++ b/js/components/breadcrumbs.js @@ -30,6 +30,26 @@ $(function() new_entry: null, max_count: 8 }, + css_classes: + { + is_hidden: 'ibo-is-hidden', + is_transparent: 'ibo-is-transparent', + is_opaque: 'ibo-is-opaque', + is_overflowing: 'ibo-is-overflowing', + breadcrumbs_item: 'ibo-breadcrumbs--item', + breadcrumbs_previous_item: 'ibo-breadcrumbs--previous-item', + }, + js_selectors: + { + breadcrumbs: '[data-role="ibo-breadcrumbs"]', + item: '[data-role="ibo-breadcrumbs--item"]', + previous_items_container: '[data-role="ibo-breadcrumbs--previous-items-container"]', + previous_items_list_toggler: '[data-role="ibo-breadcrumbs--previous-items-list-toggler"]', + previous_items_list: '[data-role="ibo-breadcrumbs--previous-items-list"]', + previous_item: '[data-role="ibo-breadcrumbs--previous-item"]', + }, + + items_observer: null, // the constructor _create: function() @@ -41,7 +61,7 @@ $(function() // Check that storage API is available if(typeof(Storage) !== 'undefined') { - $(window).bind('hashchange', function(e) + $(window).on('hashchange', function(e) { me.RefreshLatestEntry(); }); @@ -50,7 +70,7 @@ $(function() if (this.options.new_entry !== null) { var sUrl = this.options.new_entry.url; - if (sUrl.length == 0) { + if (sUrl.length === 0) { sUrl = window.location.href; } // Eliminate items having the same id, before appending the new item @@ -71,6 +91,8 @@ $(function() } this._writeDataToStorage(aBreadCrumb); + // Build markup + // - Add entries to the markup for (iEntry in aBreadCrumb) { var sBreadcrumbsItemHtml = ''; @@ -90,30 +112,131 @@ $(function() var sTitle = oEntry['description'], sLabel = oEntry['label']; - if (sTitle.length == 0) { + if (sTitle.length === 0) { sTitle = sLabel; } sTitle = EncodeHtml(sTitle, false); sLabel = EncodeHtml(sLabel, false); - if ((this.options.new_entry !== null) && (iEntry == aBreadCrumb.length - 1)) { + if ((this.options.new_entry !== null) && (iEntry === aBreadCrumb.length - 1)) { // Last entry is the current page - sBreadcrumbsItemHtml += ''+sIconSpec+''+sLabel+''; + sBreadcrumbsItemHtml += ''+sIconSpec+''+sLabel+''; } else { var sSanitizedUrl = StripArchiveArgument(oEntry['url']); - sBreadcrumbsItemHtml += ''+sIconSpec+''+sLabel+''; + sBreadcrumbsItemHtml += ''+sIconSpec+''+sLabel+''; } } - this.element.append(sBreadcrumbsItemHtml); + + const oNormalItemElem = $(sBreadcrumbsItemHtml) + .addClass(this.css_classes.breadcrumbs_item) + .attr('data-role', 'ibo-breadcrumbs--item'); + this.element.append(oNormalItemElem); + + const oPreviousItemElem = $(sBreadcrumbsItemHtml) + .addClass(this.css_classes.breadcrumbs_previous_item) + .attr('data-role', 'ibo-breadcrumbs--previous-item') + // Note: We prepend items as we want the oldest to be at the bottom of the list, like in a browser + this.element.find(this.js_selectors.previous_items_list).prepend(oPreviousItemElem); } } + + this._updateOverflowingState(); + this._bindEvents(); }, // events bound via _bind are removed automatically // revert other modifications here _destroy: function() { + // Remove listeners + this.element.find(this.js_selectors.previous_items_list_toggler).off('click'); + + // Remove observers + this.items_observer.disconnect(); + + // Clear any existing entries in the markup + this.element.find(this.js_selectors.item).remove(); + this.element.find(this.js_selectors.previous_item).remove(); + this.element.removeClass('ibo-breadcrumbs'); }, + _bindEvents: function () + { + const me = this; + + // Enable responsiveness if more than 1 item + if(window.IntersectionObserver && (this.element.find(this.js_selectors.item).length > 1)) { + // Set an observer on the items + this.items_observer = new IntersectionObserver(function(aItems, oIntersectObs){ + aItems.forEach(oItem => { + let oItemElem = $(oItem.target); + let bIsVisible = oItem.isIntersecting; + + // Important: We toggle "visibility" instead of "display" otherwise once they are hidden, they never trigger back the intersection. + if(bIsVisible) { + oItemElem.css('visibility', ''); + } + else { + // Here we also check if the item has an invisible left sibbling before hiding it. + // There reason is that on initialization, the last item might be overflowing on the right BEFORE the breadcrumbs is flagged as overflowing, making it disappear + let oLeftSiblingElem = oItemElem.prev(me.js_selectors.item); + if (oLeftSiblingElem.length > 0 && oLeftSiblingElem.css('visibility') !== 'hidden') { + bIsVisible = true; + } else { + oItemElem.css('visibility', 'hidden'); + } + } + me._updateItemDisplay(oItemElem, bIsVisible); + }); + + let bShouldShowPreviousItemsList = false; + me.element.find(me.js_selectors.item).each(function() { + if ($(this).css('visibility') === 'hidden') { + bShouldShowPreviousItemsList = true; + + // Note: Can break a .each function loop, must return false + return false; + } + }); + + // Move previous items toggler before first visible item for a better UX + if (bShouldShowPreviousItemsList) { + let oFirstVisibleItem = me.element.find(me.js_selectors.item).first(); + me.element.find(me.js_selectors.item).each(function() { + if ($(this).css('visibility') !== 'hidden') { + oFirstVisibleItem = $(this); + + // Note: Can break a .each function loop, must return false + return false; + } + }); + me.element.find(me.js_selectors.previous_items_container).insertBefore(oFirstVisibleItem); + } + + me._updateOverflowingState(); + me._updatePreviousItemsList(); + }, { + root: $(this.js_selectors.breadcrumbs)[0], + threshold: [1] // Must be completely visible + }); + this.element.find(this.js_selectors.item).each(function(){ + me.items_observer.observe(this); + }); + + this.element.find(this.js_selectors.previous_items_list_toggler).on('click', function (oEvent) { + oEvent.preventDefault(); + me.element.find(me.js_selectors.previous_items_list).toggleClass(me.css_classes.is_hidden); + }); + $('body').on('click', function (oEvent) { + if (true === me.element.find(me.js_selectors.previous_items_list).hasClass(me.css_classes.is_hidden)) { + return; + } + + if ($(oEvent.target.closest(me.js_selectors.previous_items_container)).length === 0) { + me.element.find(me.js_selectors.previous_items_list).addClass(me.css_classes.is_hidden); + } + }); + } + }, _readDataFromStorage: function() { var sBreadCrumbStorageKey = this.options.itop_instance_id + 'breadcrumb-v1'; @@ -131,6 +254,7 @@ $(function() sBreadCrumbData = JSON.stringify(aBreadCrumb); sessionStorage.setItem(sBreadCrumbStorageKey, sBreadCrumbData); }, + // Refresh the latest entry (navigating to a tab) RefreshLatestEntry: function(sRefreshHrefTo) { @@ -149,5 +273,68 @@ $(function() } this._writeDataToStorage(aBreadCrumb); }, + + // Helpers + /** + * Update item display based on its visibility to the user + * + * @param oItemElem {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 + * @return {void} + * @private + */ + _updateItemDisplay(oItemElem, bIsVisible = null) + { + const iEntryNumber = parseInt(oItemElem.attr('data-breadcrumb-entry-number')); + const oMatchingExtraItemElem = this.element.find(this.js_selectors.previous_items_list+' [data-breadcrumb-entry-number="'+iEntryNumber+'"]'); + + // Manually check if the item is visible if the info isn't passed + if (bIsVisible === null) { + bIsVisible = CombodoGlobalToolbox.IsElementVisibleToTheUser(oItemElem[0], true, 2); + } + + // Hide/show the corresponding extra item element + if (bIsVisible) { + oMatchingExtraItemElem.addClass(this.css_classes.is_hidden); + } else { + oMatchingExtraItemElem.removeClass(this.css_classes.is_hidden); + } + }, + /** + * Update previous items list + * + * @return {void} + * @private + */ + _updatePreviousItemsList: function () { + const iVisiblePreviousItemsCount = this.element.find(this.js_selectors.previous_item+':not(.'+this.css_classes.is_hidden+')').length; + const oPreviousItemsContainerElem = this.element.find(this.js_selectors.previous_items_container); + + if (iVisiblePreviousItemsCount > 0) { + oPreviousItemsContainerElem.removeClass(this.css_classes.is_hidden); + } else { + oPreviousItemsContainerElem.addClass(this.css_classes.is_hidden); + } + }, + /** + * Update the overflowing state of the breadcrumbs by checking if the items cumulated width is greater than the breadcrumbs visible space + * + * @return {void} + * @private + */ + _updateOverflowingState: function () { + const fBreadcrumbsWidth = this.element.outerWidth(); + let fItemsTotalWidth = 0; + + this.element.find(this.js_selectors.item).each(function () { + fItemsTotalWidth += $(this).outerWidth(); + }); + + if (fItemsTotalWidth > fBreadcrumbsWidth) { + this.element.addClass(this.css_classes.is_overflowing); + } else { + this.element.removeClass(this.css_classes.is_overflowing); + } + } }); }); diff --git a/js/search/search_form_handler.js b/js/search/search_form_handler.js index 460271caa..b908e1c52 100644 --- a/js/search/search_form_handler.js +++ b/js/search/search_form_handler.js @@ -224,7 +224,6 @@ $(function() $('#ibo-breadcrumbs') .breadcrumbs('destroy') - .html('') .breadcrumbs({ itop_instance_id: oData['breadcrumb_instance_id'], max_count: oData['breadcrumb_max_count'], diff --git a/templates/base/components/breadcrumbs/layout.html.twig b/templates/base/components/breadcrumbs/layout.html.twig index ea2de4ed9..665952d42 100644 --- a/templates/base/components/breadcrumbs/layout.html.twig +++ b/templates/base/components/breadcrumbs/layout.html.twig @@ -1 +1,11 @@ -
\ No newline at end of file +
+ + +
+
+
\ No newline at end of file