diff options
Diffstat (limited to 'apps/files/src/components/VirtualList.vue')
-rw-r--r-- | apps/files/src/components/VirtualList.vue | 276 |
1 files changed, 215 insertions, 61 deletions
diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue index 173fe284d27..4746fedf863 100644 --- a/apps/files/src/components/VirtualList.vue +++ b/apps/files/src/components/VirtualList.vue @@ -1,15 +1,37 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - 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 ref="filters" class="files-list__filters"> + <slot name="filters" /> + </div> + <div v-if="!!$scopedSlots['header-overlay']" class="files-list__thead-overlay"> <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 }} @@ -23,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" @@ -34,7 +55,7 @@ </tbody> <!-- Footer --> - <tfoot v-show="isReady" + <tfoot ref="footer" class="files-list__tfoot" data-cy-files-list-tfoot> <slot name="footer" /> @@ -47,21 +68,22 @@ import type { File, Folder, Node } from '@nextcloud/files' import type { PropType } from 'vue' -import { debounce } from 'debounce' -import Vue from 'vue' +import { defineComponent } from 'vue' +import debounce from 'debounce' -import filesListWidthMixin from '../mixins/filesListWidth.ts' -import logger from '../logger.js' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import logger from '../logger.ts' interface RecycledPoolItem { key: string, item: Node, } -export default Vue.extend({ - name: 'VirtualList', +type DataSource = File | Folder +type DataSourceKey = keyof DataSource - mixins: [filesListWidthMixin], +export default defineComponent({ + name: 'VirtualList', props: { dataComponent: { @@ -69,11 +91,11 @@ export default Vue.extend({ required: true, }, dataKey: { - type: String, + type: String as PropType<DataSourceKey>, required: true, }, dataSources: { - type: Array as PropType<(File | Folder)[]>, + type: Array as PropType<DataSource[]>, required: true, }, extraProps: { @@ -89,7 +111,7 @@ export default Vue.extend({ default: false, }, /** - * Visually hidden caption for the table accesibility + * Visually hidden caption for the table accessibility */ caption: { type: String, @@ -97,10 +119,19 @@ export default Vue.extend({ }, }, + setup() { + const fileListWidth = useFileListWidth() + + return { + fileListWidth, + } + }, + data() { return { index: this.scrollToIndex, beforeHeight: 0, + footerHeight: 0, headerHeight: 0, tableHeight: 0, resizeObserver: null as ResizeObserver | null, @@ -116,35 +147,66 @@ export default Vue.extend({ // Items to render before and after the visible area bufferItems() { if (this.gridMode) { + // 1 row before and after in grid mode return this.columnCount } + // 3 rows before and after return 3 }, itemHeight() { // Align with css in FilesListVirtual - // 138px + 44px (name) + 15px (grid gap) - return this.gridMode ? (138 + 44 + 15) : 55 + // 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom) + return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 44 }, + // Grid mode only itemWidth() { - // 160px + 15px grid gap - return 160 + 15 + // 166px + 16px x 2 (padding left and right) + return 166 + 16 + 16 + }, + + /** + * The number of rows currently (fully!) visible + */ + visibleRows(): number { + return Math.floor((this.tableHeight - this.headerHeight) / this.itemHeight) }, - rowCount() { - return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + (this.bufferItems / this.columnCount) * 2 + 1 + /** + * Number of rows that will be rendered. + * This includes only visible + buffer rows. + */ + rowCount(): number { + return this.visibleRows + (this.bufferItems / this.columnCount) * 2 + 1 }, - columnCount() { + + /** + * Number of columns. + * 1 for list view otherwise depending on the file list width. + */ + columnCount(): number { if (!this.gridMode) { return 1 } - return Math.floor(this.filesListWidth / this.itemWidth) + return Math.floor(this.fileListWidth / this.itemWidth) }, + /** + * Index of the first item to be rendered + * The index can be any file, not just the first one + * But the start index is the first item to be rendered, + * which needs to align with the column count + */ startIndex() { - return Math.max(0, this.index - this.bufferItems) + const firstColumnIndex = this.index - (this.index % this.columnCount) + return Math.max(0, firstColumnIndex - this.bufferItems) }, + + /** + * Number of items to be rendered at the same time + * For list view this is the same as `rowCount`, for grid view this is `rowCount` * `columnCount` + */ shownItems() { // If in grid mode, we need to multiply the number of rows by the number of columns if (this.gridMode) { @@ -153,6 +215,7 @@ export default Vue.extend({ return this.rowCount }, + renderedItems(): RecycledPoolItem[] { if (!this.isReady) { return [] @@ -181,13 +244,23 @@ export default Vue.extend({ }) }, + /** + * The total number of rows that are available + */ + totalRowCount() { + 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`, } }, }, @@ -195,11 +268,17 @@ export default Vue.extend({ scrollToIndex(index) { this.scrollTo(index) }, + + totalRowCount() { + if (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, @@ -209,30 +288,28 @@ export default Vue.extend({ }, 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, 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, any> + }, 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() { @@ -243,28 +320,105 @@ export default Vue.extend({ methods: { scrollTo(index: number) { - 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 }) + if (!this.$el || this.index === index) { + return + } + + // 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 } + + // 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 - // Scroll to one row and a half before the index - const scrollTop = (Math.floor(index / this.columnCount) - 0.5) * this.itemHeight + this.beforeHeight - logger.debug('VirtualList: scrolling to index ' + index, { scrollTop, columnCount: this.columnCount }) - this.$el.scrollTop = scrollTop + + // 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() { this._onScrollHandle ??= requestAnimationFrame(() => { this._onScrollHandle = null - const topScroll = this.$el.scrollTop - this.beforeHeight - const index = Math.floor(topScroll / this.itemHeight) * this.columnCount + + const index = this.scrollPosToIndex(this.$el.scrollTop) + if (index === this.index) { + return + } + // Max 0 to prevent negative index - this.index = Math.max(0, index) + this.index = Math.max(0, Math.floor(index)) this.$emit('scroll') }) }, + + // Convert scroll position to index + // It should be the opposite of `indexToScrollPos` + scrollPosToIndex(scrollPos: number): number { + const topScroll = scrollPos - this.beforeHeight + // Max 0 to prevent negative index + return Math.max(0, Math.floor(topScroll / this.itemHeight)) * this.columnCount + }, + + // Convert index to scroll position + // It should be the opposite of `scrollPosToIndex` + indexToScrollPos(index: number): number { + 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 + }, }, }) </script> |