diff options
Diffstat (limited to 'apps/settings/src/components/Users/VirtualList.vue')
-rw-r--r-- | apps/settings/src/components/Users/VirtualList.vue | 199 |
1 files changed, 199 insertions, 0 deletions
diff --git a/apps/settings/src/components/Users/VirtualList.vue b/apps/settings/src/components/Users/VirtualList.vue new file mode 100644 index 00000000000..e642882f23d --- /dev/null +++ b/apps/settings/src/components/Users/VirtualList.vue @@ -0,0 +1,199 @@ +<!-- + - @copyright 2023 Christopher Ng <chrng8@gmail.com> + - + - @author Christopher Ng <chrng8@gmail.com> + - + - @license AGPL-3.0-or-later + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - +--> + +<template> + <table class="user-list"> + <slot name="before" /> + + <thead ref="thead" + role="rowgroup" + class="user-list__header"> + <slot name="header" /> + </thead> + + <tbody :style="tbodyStyle" + class="user-list__body"> + <component :is="dataComponent" + v-for="(item, i) in renderedItems" + :key="item[dataKey]" + :user="item" + :visible="(i >= bufferItems || index <= bufferItems) && (i < shownItems - bufferItems)" + v-bind="extraProps" /> + </tbody> + + <tfoot ref="tfoot" + v-element-visibility="handleFooterVisibility" + role="rowgroup" + class="user-list__footer"> + <slot name="footer" /> + </tfoot> + </table> +</template> + +<script lang="ts"> +import Vue from 'vue' +import { vElementVisibility } from '@vueuse/components' +import { debounce } from 'debounce' + +import logger from '../../logger.js' + +Vue.directive('elementVisibility', vElementVisibility) + +// Items to render before and after the visible area +const bufferItems = 3 + +export default Vue.extend({ + name: 'VirtualList', + + props: { + dataComponent: { + type: [Object, Function], + required: true, + }, + dataKey: { + type: String, + required: true, + }, + dataSources: { + type: Array, + required: true, + }, + itemHeight: { + type: Number, + required: true, + }, + extraProps: { + type: Object, + default: () => ({}), + }, + }, + + data() { + return { + bufferItems, + index: 0, + headerHeight: 0, + tableHeight: 0, + resizeObserver: null as ResizeObserver | null, + } + }, + + computed: { + startIndex() { + return Math.max(0, this.index - bufferItems) + }, + + shownItems() { + return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + bufferItems * 2 + }, + + renderedItems() { + return this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems) + }, + + tbodyStyle() { + const isOverScrolled = this.startIndex + this.shownItems > this.dataSources.length + const lastIndex = this.dataSources.length - this.startIndex - this.shownItems + const hiddenAfterItems = Math.min(this.dataSources.length - this.startIndex, lastIndex) + return { + paddingTop: `${this.startIndex * this.itemHeight}px`, + paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`, + } + }, + }, + + mounted() { + const root = this.$el as HTMLElement + const tfoot = this.$refs?.tfoot as HTMLElement + const thead = this.$refs?.thead as HTMLElement + + this.resizeObserver = new ResizeObserver(debounce(() => { + this.headerHeight = thead?.clientHeight ?? 0 + this.tableHeight = root?.clientHeight ?? 0 + logger.debug('VirtualList resizeObserver updated') + this.onScroll() + }, 100, false)) + + this.resizeObserver.observe(root) + this.resizeObserver.observe(tfoot) + this.resizeObserver.observe(thead) + + this.$el.addEventListener('scroll', this.onScroll) + }, + + beforeDestroy() { + if (this.resizeObserver) { + this.resizeObserver.disconnect() + } + }, + + methods: { + handleFooterVisibility(visible: boolean) { + if (visible) { + this.$emit('scroll-end') + } + }, + + onScroll() { + // Max 0 to prevent negative index + this.index = Math.max(0, Math.round(this.$el.scrollTop / this.itemHeight)) + }, + }, +}) +</script> + +<style lang="scss" scoped> +.user-list { + --avatar-cell-width: 48px; + --cell-padding: 7px; + --cell-width: 200px; + --cell-width-large: 300px; + --cell-min-width: calc(var(--cell-width) - (2 * var(--cell-padding))); + + // Necessary for virtual scroll optimized rendering + display: block; + overflow: auto; + height: 100%; + + &__header, + &__footer { + position: sticky; + // Fix sticky positioning in Firefox + display: block; + } + + &__header { + top: 0; + z-index: 20; + } + + &__footer { + left: 0; + } + + &__body { + display: flex; + flex-direction: column; + width: 100%; + } +} +</style> |