diff --git a/lib/KBreadcrumbs.vue b/lib/KBreadcrumbs.vue index 6e7416111..6cc1d9b70 100644 --- a/lib/KBreadcrumbs.vue +++ b/lib/KBreadcrumbs.vue @@ -1,378 +1,110 @@ <template> - - <div - v-show="showSingleItem || crumbs.length > 1" - :class="{ 'breadcrumbs-collapsed': collapsedCrumbs.length }" - > - <nav class="breadcrumbs" v-bind="$attrs" :aria-label="$attrs.ariaLabel"> - <div - v-show="collapsedCrumbs.length" - class="breadcrumbs-dropdown-wrapper" + <KListWithOverflow + overflowDirection="start" + :items="breadcrumbs" +> + <template #item="{ item }"> + <li> + <KRouterLink + v-if="item.link" + :text="item.text" + :to="item.link" > - <KIconButton - size="small" - :icon="showDropdown ? 'chevronUp' : 'chevronDown'" - appearance="raised-button" - @click="showDropdown = !showDropdown" - /> - <div - v-if="showDropdown" - class="breadcrumbs-dropdown" - :style="{ background: $themeTokens.surface }" - > - <ol class="breadcrumbs-dropdown-items"> - <li - v-for="(crumb, index) in collapsedCrumbs" - :key="index" - class="breadcrumbs-dropdown-item" - > - <KRouterLink - v-if="crumb.link" - class="krouter-item" - :text="crumb.text" - :to="crumb.link" - > - <template #text="{ text }"> - <span class="breadcrumbs-crumb-text" dir="auto">{{ text }}</span> - </template> - </KRouterLink> - <span v-else dir="auto">{{ crumb.text }}</span> - </li> - </ol> - </div> - </div> - - <ol class="breadcrumbs-visible-items"> - <template v-for="(crumb, index) in crumbs"> - <li - v-if="index !== crumbs.length - 1" - v-show="!crumb.collapsed" - :key="index" - class="breadcrumbs-visible-item breadcrumbs-visible-item-notlast" - > - <KRouterLink - v-if="crumb.link" - :text="crumb.text" - :to="crumb.link" - dir="auto" - > - <template #text="{ text }"> - <span class="breadcrumbs-crumb-text">{{ text }}</span> - </template> - </KRouterLink> - <span v-else>{{ crumb.text }}</span> - </li> - - <li - v-else - :key="index" - class="breadcrumb-visible-item-last breadcrumbs-visible-item" - > - <span - class="breadcrumbs-crumb-text" - :style="{ maxWidth: lastBreadcrumbMaxWidth }" - dir="auto" - > - {{ crumb.text }} - </span> - </li> + <template #text="{ text }"> + <span >{{ text }}</span> </template> - </ol> - </nav> - - - <!-- This is a duplicate of breacrumbs-visible-items to help to reference sizes. --> - <div class="breadcrumbs breadcrumbs-offscreen" aria-hidden="true"> - <ol class="breadcrumbs-visible-items"> - <template v-for="(crumb, index) in crumbs"> - <li - v-if="index !== crumbs.length - 1" - :ref="`crumb${index}`" - :key="index" - class="breadcrumbs-visible-item breadcrumbs-visible-item-notlast" - > - <KRouterLink v-if="crumb.link" :text="crumb.text" :to="crumb.link" tabindex="-1"> - <template #text="{ text }"> - <span class="breadcrumbs-crumb-text">{{ text }}</span> - </template> - </KRouterLink> - <span v-else>{{ crumb.text }}</span> - </li> - - <li - v-else - :ref="`crumb${index}`" - :key="index" - class="breadcrumb-visible-item-last breadcrumbs-visible-item" - > - <span - class="breadcrumbs-crumb-text" - :style="{ maxWidth: lastBreadcrumbMaxWidth }" - > - {{ crumb.text }} - </span> - </li> - </template> - </ol> - </div> - </div> - + </KRouterLink> + <span v-else>{{ item.text }}</span> + </li> + </template> + <template #more="{ overflowItems }"> + <KIconButton + size="small" + icon="chevronUp" + appearance="raised-button" + > + <template #menu> + <KDropdownMenu + :options="overflowItems" + /> + </template> + </KIconButton> + <span> + > + </span> + </template> +</KListWithOverflow> </template> - <script> - - import filter from 'lodash/filter'; - import startsWith from 'lodash/startsWith'; - import throttle from 'lodash/throttle'; - import every from 'lodash/every'; - import keys from 'lodash/keys'; - import KResponsiveElementMixin from './KResponsiveElementMixin'; - - const DROPDOWN_BTN_WIDTH = 55; - const DEFAULT_LAST_BREADCRUMB_MAX_WIDTH = 300; - - function validateLinkObject(object) { - const validKeys = ['name', 'path', 'params', 'query']; - return every(keys(object), key => validKeys.includes(key)); - } - - /** - * Used to aid deeply nested navigation of content channels, topics, and resources - */ - export default { - name: 'KBreadcrumbs', - mixins: [KResponsiveElementMixin], - inheritAttrs: false, - props: { - /** - * An array of objects, each with a `text` attribute (String) and a - * `link` attribute (vue router link object). The `link` attribute - * of the last item in the array is optional and ignored. The `link` - * attribute can be set to `null` which will render the breadcrumb only - * as text, regardless of its position in the breadcrumb. - */ - items: { - type: Array, - required: true, - validator(crumbItems) { - // All must have text - if (!crumbItems.every(crumb => Boolean(crumb.text))) { - return false; - } - // If link is truthy make sure it is a valid router link - return crumbItems.every(crumb => !crumb.link || validateLinkObject(crumb.link)); - }, - }, - /** - * By default, the breadcrums will be hidden when the length of items is 1. - * When set to `true`, a breadcrumb will be shown even when there is only one. - */ - showSingleItem: { - type: Boolean, - default: false, - }, +import KListWithOverflow from './KListWithOverflow.vue'; +import KDropdownMenu from './KDropdownMenu.vue'; +export default { + name: 'KBreadcrumbs', + props: { + breadcrumbs: { + type: Array, + required: true, }, - - data: () => ({ - // Array of crumb objects. - // Each object contains: - // text, router-link 'to' object, vue ref, and its collapsed state - crumbs: [], + }, + components: { + KListWithOverflow, + KDropdownMenu + }, + data() { + return { showDropdown: false, - lastBreadcrumbMaxWidth: `${DEFAULT_LAST_BREADCRUMB_MAX_WIDTH}px`, - }), - computed: { - collapsedCrumbs() { - return this.crumbs.filter(crumb => crumb.collapsed === true).reverse(); - }, - parentWidth() { - return this.elementWidth; - }, - }, - watch: { - items(val) { - this.crumbs = Array.from(val); - this.attachRefs(); - }, - }, - created() { - this.crumbs = Array.from(this.items); - }, - mounted() { - this.attachRefs(); - // The throttled update function is defined here and not as a method on purpose - // since having it defined as a method on the options object would cause problems - // when there are more KBreadcrumbs instances rendered on one page. - // In such a scenario, all instances would share the same throttled function - // resulting in some instances not being updated when they should be. - // This is happening because of how the Vue component constructor and instances are built. - // Having it defined in the context of the `mounted` function ensures that each component - // instance will have its own throttled update function. - const throttledUpdateCrumbs = throttle(this.updateCrumbs, 100); - this.$watch('parentWidth', throttledUpdateCrumbs); - }, - methods: { - attachRefs() { - this.$nextTick(() => { - const crumbRefs = filter(this.$refs, (value, key) => startsWith(key, 'crumb')); - this.crumbs = this.crumbs.map((crumb, index) => { - const updatedCrumb = crumb; - updatedCrumb.ref = crumbRefs[index]; - return updatedCrumb; - }); - this.updateCrumbs(); - }); - }, - updateCrumbs() { - if (this.crumbs.length) { - // needs to be reset before another re-calculation - // otherwise calculactions below won't be precise - this.lastBreadcrumbMaxWidth = `${DEFAULT_LAST_BREADCRUMB_MAX_WIDTH}px`; - - this.$nextTick(() => { - const tempCrumbs = Array.from(this.crumbs); - let lastCrumbWidth = Math.ceil(tempCrumbs.pop().ref[0].getBoundingClientRect().width); - let remainingWidth = this.parentWidth - DROPDOWN_BTN_WIDTH - lastCrumbWidth; - let trackingIndex = this.crumbs.length - 2; - - while (tempCrumbs.length) { - if (remainingWidth <= 0) { - tempCrumbs.forEach((crumb, index) => { - const updatedCrumb = crumb; - updatedCrumb.collapsed = true; - this.crumbs.splice(index, 1, updatedCrumb); - }); - break; - } - - lastCrumbWidth = Math.ceil( - tempCrumbs[tempCrumbs.length - 1].ref[0].getBoundingClientRect().width - ); - - if (lastCrumbWidth > remainingWidth) { - tempCrumbs.forEach((crumb, index) => { - const updatedCrumb = crumb; - updatedCrumb.collapsed = true; - this.crumbs.splice(index, 1, updatedCrumb); - }); - break; - } - - remainingWidth -= lastCrumbWidth; - const lastCrumb = tempCrumbs.pop(); - lastCrumb.collapsed = false; - this.crumbs.splice(trackingIndex, 1, lastCrumb); - trackingIndex -= 1; - } - - // Allow the last breadcrumb use all space available - // Fixes https://github.com/learningequality/kolibri-design-system/issues/198 - // and https://github.com/learningequality/kolibri/issues/6918 - if (remainingWidth > 0) { - this.lastBreadcrumbMaxWidth = `${DEFAULT_LAST_BREADCRUMB_MAX_WIDTH + - remainingWidth}px`; - } - }); - } - }, - }, - }; - -</script> - - -<style lang="scss" scoped> - - @import './styles/definitions'; - $crumb-max-width: 300px; - - .breadcrumbs { - height: 32px; - margin-top: 8px; - margin-bottom: 8px; - font-size: 16px; - font-weight: bold; - line-height: 32px; - white-space: nowrap; - } - - .breadcrumbs-crumb-text { - display: inline-block; - width: 100%; - max-width: $crumb-max-width; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - vertical-align: bottom; - } - - .breadcrumbs-dropdown-wrapper { - display: inline-block; - - &::after { - margin-right: 8px; - margin-left: 8px; - content: '›'; + }; + }, + methods: { + toggleDropdown() { + this.showDropdown = !this.showDropdown; } - } - - .breadcrumbs-dropdown { - @extend %dropshadow-2dp; - - position: absolute; - z-index: 8; - max-width: 100%; - padding: 16px; - font-weight: bold; - } - - .breadcrumbs-dropdown-items { - padding: 0; - margin: 0; - list-style: none; - } - - .breadcrumbs-dropdown-item { - display: block; - padding-top: 8px; - padding-bottom: 8px; - - .krouter-item { - width: 100%; - } - } - - .breadcrumbs-visible-items { - display: inline-block; - width: 100%; - padding: 0; - margin: 0; - white-space: nowrap; - list-style: none; - - .breadcrumbs-collapsed & { - // account for dropdown button width - width: calc(100% - 55px); - } - } - - .breadcrumbs-visible-item { - display: inline-block; - max-width: 100%; - } + }, +}; +</script> - .breadcrumbs-visible-item-notlast { - &::after { - margin-right: 8px; - margin-left: 8px; - content: '›'; - } - } - .breadcrumbs-offscreen { - position: absolute; - left: -1000em; - } +<style scoped> + +.breadcrumbs { + height: 32px; + width: auto; + margin-top: 8px; + margin-bottom: 8px; + font-size: 16px; + font-weight: bold; + line-height: 32px; + white-space: nowrap; +} + +.link { + color: blue; + padding-left: 10px; +} + +.text { + color: black; + padding-left: 10px; +} + +.icon { + color: black; + padding-left: 5; + padding-right: 5; +} + +.more-button { + color: blue; + cursor: pointer; +} + +.overflowed-items { + position: absolute; + background-color: #fff; + border: 1px solid #ddd; + padding: 10px; + z-index: 1; +} </style> diff --git a/lib/KListWithOverflow.vue b/lib/KListWithOverflow.vue index 5b65fda4a..564e78854 100644 --- a/lib/KListWithOverflow.vue +++ b/lib/KListWithOverflow.vue @@ -28,6 +28,7 @@ <div ref="moreButtonWrapper" class="more-button-wrapper" + :class="{ 'start-button': overflowDirection === 'start' }" > <!-- @slot Slot responsible of rendering the "see more" button. This slot receives as prop a list `overflowItems` with items that dont fit into the visible list.--> <slot @@ -70,12 +71,20 @@ type: [Object, String], default: null, }, + overflowDirection: { + type: String, + default: 'end', + validator(value) { + return ['start', 'end'].includes(value); + }, + }, }, data() { return { mounted: false, overflowItems: [], // default to true just to measure its width at first render + visibleItems: [], isMoreButtonVisible: true, moreButtonWidth: 0, }; @@ -152,21 +161,24 @@ itemsSizes.push(itemSize); } + if (this.overflowDirection === 'start') { + itemsSizes.reverse(); + } + const indexSequence = [...Array(list.children.length).keys()]; + const directionIndexes = + this.overflowDirection === 'start' ? indexSequence.reverse() : indexSequence; + const overflowItemsIdx = []; - for (let i = 0; i < list.children.length; i++) { - const item = list.children[i]; - const itemWidth = itemsSizes[i].width; - // If the item dont fit in the available space or if we have already - // overflowed items, we hide it. This means that once one item overflows, - // all the following items will be hidden. + directionIndexes.forEach((i) => { + const itemWidth = itemsSizes[i].width; if (itemWidth >= availableWidth || overflowItemsIdx.length > 0) { overflowItemsIdx.push(i); - item.style.visibility = 'hidden'; - item.style.position = 'absolute'; + list.children[i].style.visibility = 'hidden'; + list.children[i].style.position = 'absolute'; } else { - item.style.visibility = 'visible'; - item.style.position = 'unset'; + list.children[i].style.visibility = 'visible'; + list.children[i].style.position = 'unset'; maxWidth += itemWidth; availableWidth -= itemWidth; const itemHeight = itemsSizes[i].height; @@ -174,7 +186,7 @@ maxHeight = itemHeight; } } - } + }); // check if overflowed items would fit if the moreButton were not visible const overflowedWidth = overflowItemsIdx.reduce( @@ -195,11 +207,17 @@ maxWidth -= removedDividerWidth; } - maxWidth = Math.ceil(maxWidth); - this.overflowItems = overflowItemsIdx.map(idx => this.items[idx]); + this.overflowItems = overflowItemsIdx.map((idx) => this.items[idx]); this.isMoreButtonVisible = overflowItemsIdx.length > 0; - list.style.maxWidth = `${maxWidth}px`; + list.style.maxWidth = `${Math.ceil(maxWidth)}px`; list.style.maxHeight = `${maxHeight}px`; + + this.setVisibleItems(directionIndexes, overflowItemsIdx); + }, + setVisibleItems(directionIndexes, overflowItemsIdx) { + // Set the visible items based on overflow logic + const visibleIndexes = directionIndexes.filter((idx) => !overflowItemsIdx.includes(idx)); + this.visibleItems = visibleIndexes.map((idx) => this.items[idx]); }, /** * Fixes the visibility of the dividers that are shown and hidden when the list overflows. @@ -276,5 +294,8 @@ .more-button-wrapper { visibility: hidden; } + .more-button-wrapper.start-button { + order: -1; /* Puts the more button at the start */ + } -</style> +</style> \ No newline at end of file