aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src/components/VirtualList.vue
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src/components/VirtualList.vue')
-rw-r--r--apps/files/src/components/VirtualList.vue424
1 files changed, 424 insertions, 0 deletions
diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue
new file mode 100644
index 00000000000..4746fedf863
--- /dev/null
+++ b/apps/files/src/components/VirtualList.vue
@@ -0,0 +1,424 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <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>
+
+ <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 }}
+ </caption>
+
+ <!-- Header -->
+ <thead ref="thead" class="files-list__thead" data-cy-files-list-thead>
+ <slot name="header" />
+ </thead>
+
+ <!-- Body -->
+ <tbody :style="tbodyStyle"
+ class="files-list__tbody"
+ data-cy-files-list-tbody>
+ <component :is="dataComponent"
+ v-for="({key, item}, i) in renderedItems"
+ :key="key"
+ :source="item"
+ :index="i"
+ v-bind="extraProps" />
+ </tbody>
+
+ <!-- Footer -->
+ <tfoot ref="footer"
+ class="files-list__tfoot"
+ data-cy-files-list-tfoot>
+ <slot name="footer" />
+ </tfoot>
+ </table>
+ </div>
+</template>
+
+<script lang="ts">
+import type { File, Folder, Node } from '@nextcloud/files'
+import type { PropType } from 'vue'
+
+import { defineComponent } from 'vue'
+import debounce from 'debounce'
+
+import { useFileListWidth } from '../composables/useFileListWidth.ts'
+import logger from '../logger.ts'
+
+interface RecycledPoolItem {
+ key: string,
+ item: Node,
+}
+
+type DataSource = File | Folder
+type DataSourceKey = keyof DataSource
+
+export default defineComponent({
+ name: 'VirtualList',
+
+ props: {
+ dataComponent: {
+ type: [Object, Function],
+ required: true,
+ },
+ dataKey: {
+ type: String as PropType<DataSourceKey>,
+ required: true,
+ },
+ dataSources: {
+ type: Array as PropType<DataSource[]>,
+ required: true,
+ },
+ extraProps: {
+ type: Object as PropType<Record<string, unknown>>,
+ default: () => ({}),
+ },
+ scrollToIndex: {
+ type: Number,
+ default: 0,
+ },
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
+ /**
+ * Visually hidden caption for the table accessibility
+ */
+ caption: {
+ type: String,
+ default: '',
+ },
+ },
+
+ setup() {
+ const fileListWidth = useFileListWidth()
+
+ return {
+ fileListWidth,
+ }
+ },
+
+ data() {
+ return {
+ index: this.scrollToIndex,
+ beforeHeight: 0,
+ footerHeight: 0,
+ headerHeight: 0,
+ tableHeight: 0,
+ resizeObserver: null as ResizeObserver | null,
+ }
+ },
+
+ computed: {
+ // Wait for measurements to be done before rendering
+ isReady() {
+ return this.tableHeight > 0
+ },
+
+ // 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
+ // 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom)
+ 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
+ },
+
+ /**
+ * The number of rows currently (fully!) visible
+ */
+ visibleRows(): number {
+ return Math.floor((this.tableHeight - this.headerHeight) / this.itemHeight)
+ },
+
+ /**
+ * 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
+ }
+ 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() {
+ 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) {
+ return this.rowCount * this.columnCount
+ }
+
+ return this.rowCount
+ },
+
+ renderedItems(): RecycledPoolItem[] {
+ if (!this.isReady) {
+ return []
+ }
+
+ const items = this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems) as Node[]
+
+ const oldItems = items.filter(item => Object.values(this.$_recycledPool).includes(item[this.dataKey]))
+ const oldItemsKeys = oldItems.map(item => item[this.dataKey] as string)
+ const unusedKeys = Object.keys(this.$_recycledPool).filter(key => !oldItemsKeys.includes(this.$_recycledPool[key]))
+
+ return items.map(item => {
+ const index = Object.values(this.$_recycledPool).indexOf(item[this.dataKey])
+ // If defined, let's keep the key
+ if (index !== -1) {
+ return {
+ key: Object.keys(this.$_recycledPool)[index],
+ item,
+ }
+ }
+
+ // Get and consume reusable key or generate a new one
+ const key = unusedKeys.pop() || Math.random().toString(36).substr(2)
+ this.$_recycledPool[key] = item[this.dataKey]
+ return { key, item }
+ })
+ },
+
+ /**
+ * The total number of rows that are available
+ */
+ totalRowCount() {
+ return Math.ceil(this.dataSources.length / this.columnCount)
+ },
+
+ tbodyStyle() {
+ // 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 {
+ paddingBlock: `${rowsAbove * this.itemHeight}px ${rowsBelow * this.itemHeight}px`,
+ minHeight: `${this.totalRowCount * this.itemHeight}px`,
+ }
+ },
+ },
+ watch: {
+ 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
+ logger.debug('VirtualList: columnCount is 0, skipping scroll')
+ return
+ }
+ // If the column count changes in grid view,
+ // update the scroll position again
+ this.scrollTo(this.index)
+ },
+ },
+
+ mounted() {
+ this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]>
+
+ this.resizeObserver = new ResizeObserver(debounce(() => {
+ this.updateHeightVariables()
+ logger.debug('VirtualList: resizeObserver updated')
+ this.onScroll()
+ }, 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() {
+ if (this.resizeObserver) {
+ this.resizeObserver.disconnect()
+ }
+ },
+
+ methods: {
+ scrollTo(index: number) {
+ 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
+
+ // 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 index = this.scrollPosToIndex(this.$el.scrollTop)
+ if (index === this.index) {
+ return
+ }
+
+ // Max 0 to prevent negative 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>