diff --git a/package-lock.json b/package-lock.json index 7aedc7ef0e..35f31a904c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "unist-util-visit": "^5.0.0", "vue": "^2.7.14", "vue-color": "^2.8.1", + "vue-frag": "^1.4.3", "vue-material-design-icons": "^5.1.2", "vue2-datepicker": "^3.11.0" }, @@ -26686,6 +26687,17 @@ "node": ">=4.0" } }, + "node_modules/vue-frag": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/vue-frag/-/vue-frag-1.4.3.tgz", + "integrity": "sha512-pQZj03f/j9LRhzz9vKaXTCXUHVYHuAXicshFv76VFqwz4MG3bcb+sPZMAbd0wmw7THjkrTPuoM0EG9TbG8CgMQ==", + "funding": { + "url": "https://github.com/privatenumber/vue-frag?sponsor=1" + }, + "peerDependencies": { + "vue": "^2.6.0" + } + }, "node_modules/vue-hot-reload-api": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz", diff --git a/package.json b/package.json index 16e2dc5136..28c51bb7e5 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "unist-util-visit": "^5.0.0", "vue": "^2.7.14", "vue-color": "^2.8.1", + "vue-frag": "^1.4.3", "vue-material-design-icons": "^5.1.2", "vue2-datepicker": "^3.11.0" }, diff --git a/src/components/NcBreadcrumbs/NcBreadcrumbs.vue b/src/components/NcBreadcrumbs/NcBreadcrumbs.vue index 554658e2a4..62e7215b68 100644 --- a/src/components/NcBreadcrumbs/NcBreadcrumbs.vue +++ b/src/components/NcBreadcrumbs/NcBreadcrumbs.vue @@ -149,6 +149,7 @@ import IconFolder from 'vue-material-design-icons/Folder.vue' import debounce from 'debounce' import Vue from 'vue' +import { Fragment } from 'vue-frag' const crumbClass = 'vue-crumb' @@ -196,6 +197,7 @@ export default { // Is the menu open or not open: false, }, + breadcrumbsRefs: {}, } }, beforeMount() { @@ -226,21 +228,21 @@ export default { /** * Check that crumbs to hide are hidden */ - this.delayedHideCrumbs() + this.$nextTick(() => { + this.hideCrumbs() + }) }, - beforeDestroy() { + beforeUnmount() { window.removeEventListener('resize', this.handleWindowResize) unsubscribe('navigation-toggled', this.delayedResize) }, methods: { /** - * Check that all crumbs to hide are really hidden + * Call the resize function after a delay */ - delayedHideCrumbs() { - this.$nextTick(() => { - const crumbs = this.$slots.default || [] - this.hideCrumbs(crumbs) - }) + async delayedResize() { + await this.$nextTick() + this.handleWindowResize() }, /** * Close the actions menu @@ -254,53 +256,64 @@ export default { } this.menuBreadcrumbProps.open = false }, - /** - * Call the resize function after a delay - */ - delayedResize() { - this.$nextTick(() => { - this.handleWindowResize() - }) - }, /** * Check the width of the breadcrumb and hide breadcrumbs * if we overflow otherwise. */ handleWindowResize() { - // All breadcrumb components passed into the default slot - const breadcrumbs = this.$slots.default || [] // If there is no container yet, we cannot determine its size - if (this.$refs.container) { - const nrCrumbs = breadcrumbs.length - const hiddenIndices = [] - const availableWidth = this.$refs.container.offsetWidth - let totalWidth = this.getTotalWidth(breadcrumbs) - // If we have breadcumbs actions, we have to take their width into account too. - if (this.$refs.breadcrumb__actions) { - totalWidth += this.$refs.breadcrumb__actions.offsetWidth - } - let overflow = totalWidth - availableWidth - // If we overflow, we have to take the action-item width into account as well. - overflow += (overflow > 0) ? 64 : 0 - let i = 0 - // We start hiding the breadcrumb in the center - const startIndex = Math.floor(nrCrumbs / 2) - // Don't hide the first and last breadcrumb - while (overflow > 0 && i < nrCrumbs - 2) { - // We hide elements alternating to the left and right - const currentIndex = startIndex + ((i % 2) ? i + 1 : i) / 2 * Math.pow(-1, i + (nrCrumbs % 2)) - // Calculate the remaining overflow width after hiding this breadcrumb - overflow -= this.getWidth(breadcrumbs[currentIndex].elm) - hiddenIndices.push(currentIndex) - i++ + if (!this.$refs.container) { + return + } + // All breadcrumb components passed into the default slot + const breadcrumbs = Object.values(this.breadcrumbsRefs) + + const breadcrumbsVnodes = [] + // We have to iterate over all slot elements + this.$slots.default.forEach(vnode => { + if (this.isBreadcrumb(vnode)) { + breadcrumbsVnodes.push(vnode) + return } - // We only update the hidden crumbs if they have changed, - // otherwise we will run into an infinite update loop. - if (!this.arraysEqual(this.hiddenIndices, hiddenIndices.sort((a, b) => a - b))) { - // Get all breadcrumbs based on the hidden indices - this.hiddenCrumbs = hiddenIndices.map((index) => { return breadcrumbs[index] }) - this.hiddenIndices = hiddenIndices + // If we encounter a Fragment, we have to check its children too + if (vnode?.type === Fragment) { + vnode?.children?.forEach?.(child => { + if (this.isBreadcrumb(child)) { + breadcrumbsVnodes.push(child) + } + }) } + }) + + const nrCrumbs = breadcrumbs.length + const hiddenIndices = [] + const availableWidth = this.$refs.container.offsetWidth + let totalWidth = this.getTotalWidth(breadcrumbs) + // If we have breadcumbs actions, we have to take their width into account too. + if (this.$refs.breadcrumb__actions) { + totalWidth += this.$refs.breadcrumb__actions.offsetWidth + } + let overflow = totalWidth - availableWidth + // If we overflow, we have to take the action-item width into account as well. + overflow += (overflow > 0) ? 64 : 0 + let i = 0 + // We start hiding the breadcrumb in the center + const startIndex = Math.floor(nrCrumbs / 2) + // Don't hide the first and last breadcrumb + while (overflow > 0 && i < nrCrumbs - 2) { + // We hide elements alternating to the left and right + const currentIndex = startIndex + ((i % 2) ? i + 1 : i) / 2 * Math.pow(-1, i + (nrCrumbs % 2)) + // Calculate the remaining overflow width after hiding this breadcrumb + o verflow -= this.getWidth(breadcrumbs[currentIndex]?.elm) + hiddenIndices.push(currentIndex) + i++ + } + // We only update the hidden crumbs if they have changed, + // otherwise we will run into an infinite update loop. + if (!this.arraysEqual(this.hiddenIndices, hiddenIndices.sort((a, b) => a - b))) { + // Get all breadcrumbs based on the hidden indices + this.hiddenCrumbs = hiddenIndices.map((index) => { return breadcrumbsVnodes[index] }) + this.hiddenIndices = hiddenIndices } }, /** @@ -330,7 +343,7 @@ export default { * @return {number} The total width */ getTotalWidth(breadcrumbs) { - return breadcrumbs.reduce((width, crumb, index) => width + this.getWidth(crumb.elm), 0) + return breadcrumbs.reduce((width, crumb, index) => width + this.getWidth(crumb?.elm), 0) }, /** * Calculates the width of the provided element @@ -339,7 +352,7 @@ export default { * @return {number} The width */ getWidth(el) { - if (!el.classList) return 0 + if (!el?.classList) return 0 const hide = el.classList.contains(`${crumbClass}--hidden`) el.style.minWidth = 'auto' el.classList.remove(`${crumbClass}--hidden`) @@ -464,14 +477,12 @@ export default { /** * Check for each crumb if we have to hide it and * add it to the array of all crumbs. - * - * @param {Array} crumbs The array of the crumbs to hide - * @param {number} offset The offset of the indices of the provided crumbs array */ - hideCrumbs(crumbs, offset = 0) { + hideCrumbs() { + const crumbs = Object.values(this.breadcrumbsRefs) crumbs.forEach((crumb, i) => { if (crumb?.elm?.classList) { - if (this.hiddenIndices.includes(i + offset)) { + if (this.hiddenIndices.includes(i)) { crumb.elm.classList.add(`${crumbClass}--hidden`) } else { crumb.elm.classList.remove(`${crumbClass}--hidden`) @@ -479,6 +490,10 @@ export default { } }) }, + + isBreadcrumb(vnode) { + return (vnode?.componentOptions?.tag || vnode?.tag || '').includes('NcBreadcrumb') + }, }, /** * The render function to display the component @@ -488,7 +503,22 @@ export default { */ render(h) { // Get the breadcrumbs - const breadcrumbs = this.$slots.default || [] + const breadcrumbs = [] + // We have to iterate over all slot elements + this.$slots.default.forEach(vnode => { + if (this.isBreadcrumb(vnode)) { + breadcrumbs.push(vnode) + return + } + // If we encounter a Fragment, we have to check its children too + if (vnode?.type === Fragment) { + vnode?.children?.forEach?.(child => { + if (this.isBreadcrumb(child)) { + breadcrumbs.push(child) + } + }) + } + }) // Check that we have at least one breadcrumb if (breadcrumbs.length === 0) { @@ -498,15 +528,25 @@ export default { // Add the root icon to the first breadcrumb // eslint-disable-next-line import/no-named-as-default-member Vue.set(breadcrumbs[0].componentOptions.propsData, 'icon', this.rootIcon) + Vue.set(breadcrumbs[0].componentOptions.propsData, 'ref', 'breadcrumbs') + + /** + * Use a proxy object to store breadcrumbs refs + * and don't write to this.breadcrumbsRefs directly + * to not trigger a myriad of re-renders. + */ + const breadcrumbsRefs = {} + // Add the breadcrumbs to the array of the created VNodes, check if hiding them is necessary. + breadcrumbs.forEach((crumb, index) => { + Vue.set(crumb, 'ref', `crumb-${index}`) + breadcrumbsRefs[index] = crumb + }) // The array of all created VNodes let crumbs = [] - if (!this.hiddenCrumbs.length) { // We don't hide any breadcrumbs. crumbs = breadcrumbs - // But we need to check if some are already hidden, and show them. - this.hideCrumbs(crumbs) } else { /** * We show the first half of the breadcrumbs before the Actions dropdown menu @@ -514,7 +554,6 @@ export default { */ // Add the breadcrumbs to the array of the created VNodes, check if hiding them is necessary. crumbs = breadcrumbs.slice(0, Math.round(breadcrumbs.length / 2)) - this.hideCrumbs(crumbs) // The Actions menu // Use a breadcrumb component for the hidden breadcrumbs @@ -595,16 +634,16 @@ export default { // The second half of the breadcrumbs const crumbs2 = breadcrumbs.slice(Math.round(breadcrumbs.length / 2)) crumbs = crumbs.concat(crumbs2) - // One crumb is the Actions dropdown, so subtract one. - this.hideCrumbs(crumbs2, crumbs.length - 1) } - const wrapper = [h('nav', {}, [h('ul', { class: 'breadcrumb__crumbs' }, crumbs)])] + const wrapper = [h('nav', {}, [h('ul', { class: 'breadcrumb__crumbs' }, [crumbs])])] // Append the actions slot if it is populated if (this.$slots.actions) { wrapper.push(h('div', { class: 'breadcrumb__actions', ref: 'breadcrumb__actions' }, this.$slots.actions)) } + this.breadcrumbsRefs = breadcrumbsRefs + return h('div', { class: ['breadcrumb', { 'breadcrumb--collapsed': (this.hiddenCrumbs.length === breadcrumbs.length - 2) }], ref: 'container' }, wrapper) }, } @@ -616,7 +655,7 @@ export default { flex-grow: 1; display: inline-flex; - &--collapsed .vue-crumb:last-child { + &--collapsed :deep(.vue-crumb:last-child) { min-width: 100px; flex-shrink: 1; }