diff options
Diffstat (limited to 'apps/files/src/components/VirtualList.vue')
-rw-r--r-- | apps/files/src/components/VirtualList.vue | 182 |
1 files changed, 133 insertions, 49 deletions
diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue index b3d1b1002f4..4746fedf863 100644 --- a/apps/files/src/components/VirtualList.vue +++ b/apps/files/src/components/VirtualList.vue @@ -3,13 +3,16 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> - <div class="files-list" data-cy-files-list> + <div class="files-list" + :class="{ 'files-list--grid': gridMode }" + data-cy-files-list + @scroll.passive="onScroll"> <!-- Header --> <div ref="before" class="files-list__before"> <slot name="before" /> </div> - <div class="files-list__filters"> + <div ref="filters" class="files-list__filters"> <slot name="filters" /> </div> @@ -17,7 +20,18 @@ <slot name="header-overlay" /> </div> - <table class="files-list__table" :class="{ 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'] }"> + <div v-if="dataSources.length === 0" + class="files-list__empty"> + <slot name="empty" /> + </div> + + <table :aria-hidden="dataSources.length === 0" + :inert="dataSources.length === 0" + class="files-list__table" + :class="{ + 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'], + 'files-list__table--hidden': dataSources.length === 0, + }"> <!-- Accessibility table caption for screen readers --> <caption v-if="caption" class="hidden-visually"> {{ caption }} @@ -31,7 +45,6 @@ <!-- Body --> <tbody :style="tbodyStyle" class="files-list__tbody" - :class="gridMode ? 'files-list__tbody--grid' : 'files-list__tbody--list'" data-cy-files-list-tbody> <component :is="dataComponent" v-for="({key, item}, i) in renderedItems" @@ -42,7 +55,7 @@ </tbody> <!-- Footer --> - <tfoot v-show="isReady" + <tfoot ref="footer" class="files-list__tfoot" data-cy-files-list-tfoot> <slot name="footer" /> @@ -118,6 +131,7 @@ export default defineComponent({ return { index: this.scrollToIndex, beforeHeight: 0, + footerHeight: 0, headerHeight: 0, tableHeight: 0, resizeObserver: null as ResizeObserver | null, @@ -143,18 +157,35 @@ export default defineComponent({ itemHeight() { // Align with css in FilesListVirtual // 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom) - return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 55 + return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 44 }, + // Grid mode only itemWidth() { // 166px + 16px x 2 (padding left and right) return 166 + 16 + 16 }, - rowCount() { - return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + (this.bufferItems / this.columnCount) * 2 + 1 + /** + * The number of rows currently (fully!) visible + */ + visibleRows(): number { + return Math.floor((this.tableHeight - this.headerHeight) / this.itemHeight) }, - columnCount() { + + /** + * Number of rows that will be rendered. + * This includes only visible + buffer rows. + */ + rowCount(): number { + return this.visibleRows + (this.bufferItems / this.columnCount) * 2 + 1 + }, + + /** + * Number of columns. + * 1 for list view otherwise depending on the file list width. + */ + columnCount(): number { if (!this.gridMode) { return 1 } @@ -217,16 +248,18 @@ export default defineComponent({ * The total number of rows that are available */ totalRowCount() { - return Math.floor(this.dataSources.length / this.columnCount) + return Math.ceil(this.dataSources.length / this.columnCount) }, tbodyStyle() { - const isOverScrolled = this.startIndex + this.rowCount > this.dataSources.length - const lastIndex = this.dataSources.length - this.startIndex - this.shownItems - const hiddenAfterItems = Math.floor(Math.min(this.dataSources.length - this.startIndex, lastIndex) / this.columnCount) + // The number of (virtual) rows above the currently rendered ones. + // start index is aligned so this should always be an integer + const rowsAbove = Math.round(this.startIndex / this.columnCount) + // The number of (virtual) rows below the currently rendered ones. + const rowsBelow = Math.max(0, this.totalRowCount - rowsAbove - this.rowCount) + return { - paddingTop: `${Math.floor(this.startIndex / this.columnCount) * this.itemHeight}px`, - paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`, + paddingBlock: `${rowsAbove * this.itemHeight}px ${rowsBelow * this.itemHeight}px`, minHeight: `${this.totalRowCount * this.itemHeight}px`, } }, @@ -238,15 +271,14 @@ export default defineComponent({ totalRowCount() { if (this.scrollToIndex) { - this.$nextTick(() => this.scrollTo(this.scrollToIndex)) + this.scrollTo(this.scrollToIndex) } }, columnCount(columnCount, oldColumnCount) { if (oldColumnCount === 0) { - // We're initializing, the scroll position - // is handled on mounted - console.debug('VirtualList: columnCount is 0, skipping scroll') + // We're initializing, the scroll position is handled on mounted + logger.debug('VirtualList: columnCount is 0, skipping scroll') return } // If the column count changes in grid view, @@ -256,30 +288,28 @@ export default defineComponent({ }, mounted() { - const before = this.$refs?.before as HTMLElement - const root = this.$el as HTMLElement - const thead = this.$refs?.thead as HTMLElement + this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]> this.resizeObserver = new ResizeObserver(debounce(() => { - this.beforeHeight = before?.clientHeight ?? 0 - this.headerHeight = thead?.clientHeight ?? 0 - this.tableHeight = root?.clientHeight ?? 0 + this.updateHeightVariables() logger.debug('VirtualList: resizeObserver updated') this.onScroll() - }, 100, { immediate: false })) - - this.resizeObserver.observe(before) - this.resizeObserver.observe(root) - this.resizeObserver.observe(thead) - - if (this.scrollToIndex) { - this.scrollTo(this.scrollToIndex) - } - - // Adding scroll listener AFTER the initial scroll to index - this.$el.addEventListener('scroll', this.onScroll, { passive: true }) - - this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]> + }, 100)) + this.resizeObserver.observe(this.$el) + this.resizeObserver.observe(this.$refs.before as HTMLElement) + this.resizeObserver.observe(this.$refs.filters as HTMLElement) + this.resizeObserver.observe(this.$refs.footer as HTMLElement) + + this.$nextTick(() => { + // Make sure height values are initialized + this.updateHeightVariables() + // If we need to scroll to an index we do so in the next tick. + // This is needed to apply updates from the initialization of the height variables + // which will update the tbody styles until next tick. + if (this.scrollToIndex) { + this.scrollTo(this.scrollToIndex) + } + }) }, beforeDestroy() { @@ -290,21 +320,60 @@ export default defineComponent({ methods: { scrollTo(index: number) { - if (!this.$el) { + if (!this.$el || this.index === index) { return } - // Check if the content is smaller than the viewport, meaning no scrollbar - const targetRow = Math.ceil(this.dataSources.length / this.columnCount) - if (targetRow < this.rowCount) { - logger.debug('VirtualList: Skip scrolling, nothing to scroll', { index, targetRow, rowCount: this.rowCount }) + // Check if the content is smaller (not equal! keep the footer in mind) than the viewport + // meaning there is no scrollbar + if (this.totalRowCount < this.visibleRows) { + logger.debug('VirtualList: Skip scrolling, nothing to scroll', { + index, + totalRows: this.totalRowCount, + visibleRows: this.visibleRows, + }) return } - // Scroll to one row and a half before the index - const scrollTop = this.indexToScrollPos(index) - logger.debug('VirtualList: scrolling to index ' + index, { scrollTop, columnCount: this.columnCount, beforeHeight: this.beforeHeight }) - this.$el.scrollTop = scrollTop + // We can not scroll further as the last page of rows + // For the grid view we also need to account for all columns in that row (columnCount - 1) + const clampedIndex = (this.totalRowCount - this.visibleRows) * this.columnCount + (this.columnCount - 1) + // The scroll position + let scrollTop = this.indexToScrollPos(Math.min(index, clampedIndex)) + + // First we need to update the internal index for rendering. + // This will cause the <tbody> element to be resized allowing us to set the correct scroll position. + this.index = index + + // If this is not the first row we can add a half row from above. + // This is to help users understand the table is scrolled and not items did not just disappear. + // But we also can only add a half row if we have enough rows below to scroll (visual rows / end of scrollable area) + if (index >= this.columnCount && index <= clampedIndex) { + scrollTop -= (this.itemHeight / 2) + // As we render one half row more we also need to adjust the internal index + this.index = index - this.columnCount + } else if (index > clampedIndex) { + // If we are on the last page we cannot scroll any further + // but we can at least scroll the footer into view + if (index <= (clampedIndex + this.columnCount)) { + // We only show have of the footer for the first of the last page + // To still show the previous row partly. Same reasoning as above: + // help the user understand that the table is scrolled not "magically trimmed" + scrollTop += this.footerHeight / 2 + } else { + // We reached the very end of the files list and we are focussing not the first visible row + // so all we now can do is scroll to the end (footer) + scrollTop += this.footerHeight + } + } + + // Now we need to wait for the <tbody> element to get resized so we can correctly apply the scrollTop position + this.$nextTick(() => { + this.$el.scrollTop = scrollTop + logger.debug(`VirtualList: scrolling to index ${index}`, { + clampedIndex, scrollTop, columnCount: this.columnCount, total: this.totalRowCount, visibleRows: this.visibleRows, beforeHeight: this.beforeHeight, + }) + }) }, onScroll() { @@ -333,7 +402,22 @@ export default defineComponent({ // Convert index to scroll position // It should be the opposite of `scrollPosToIndex` indexToScrollPos(index: number): number { - return (Math.floor(index / this.columnCount) - 0.5) * this.itemHeight + this.beforeHeight + return Math.floor(index / this.columnCount) * this.itemHeight + this.beforeHeight + }, + + /** + * Update the height variables. + * To be called by resize observer and `onMount` + */ + updateHeightVariables(): void { + this.tableHeight = this.$el?.clientHeight ?? 0 + this.beforeHeight = (this.$refs.before as HTMLElement)?.clientHeight ?? 0 + this.footerHeight = (this.$refs.footer as HTMLElement)?.clientHeight ?? 0 + + // Get the header height which consists of table header and filters + const theadHeight = (this.$refs.thead as HTMLElement)?.clientHeight ?? 0 + const filterHeight = (this.$refs.filters as HTMLElement)?.clientHeight ?? 0 + this.headerHeight = theadHeight + filterHeight }, }, }) |