Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/files/src/components/FilesListTableFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export default defineComponent({
<style scoped lang="scss">
// Scoped row
tr {
margin-bottom: max(25vh, var(--body-container-margin));
margin-bottom: var(--body-container-margin);
border-top: 1px solid var(--color-border);
// Prevent hover effect on the whole row
background-color: transparent !important;
Expand Down
8 changes: 3 additions & 5 deletions apps/files/src/components/FilesListVirtual.vue
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,6 @@ export default defineComponent({
flex-direction: column;
width: 100%;
background-color: var(--color-main-background);

}

// Table header
Expand Down Expand Up @@ -740,8 +739,7 @@ export default defineComponent({

<style lang="scss">
// Grid mode
tbody.files-list__tbody.files-list__tbody--grid {
--half-clickable-area: calc(var(--clickable-area) / 2);
.files-list--grid tbody.files-list__tbody {
--item-padding: 16px;
--icon-preview-size: 166px;
--name-height: 32px;
Expand Down Expand Up @@ -833,8 +831,8 @@ tbody.files-list__tbody.files-list__tbody--grid {

.files-list__row-actions {
position: absolute;
right: calc(var(--half-clickable-area) / 2);
bottom: calc(var(--mtime-height) / 2);
inset-inline-end: calc(var(--clickable-area) / 4);
inset-block-end: calc(var(--mtime-height) / 2);
width: var(--clickable-area);
height: var(--clickable-area);
}
Expand Down
171 changes: 127 additions & 44 deletions apps/files/src/components/VirtualList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand All @@ -31,7 +34,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"
Expand All @@ -42,7 +44,7 @@
</tbody>

<!-- Footer -->
<tfoot v-show="isReady"
<tfoot ref="footer"
class="files-list__tfoot"
data-cy-files-list-tfoot>
<slot name="footer" />
Expand Down Expand Up @@ -113,6 +115,7 @@ export default Vue.extend({
return {
index: this.scrollToIndex,
beforeHeight: 0,
footerHeight: 0,
headerHeight: 0,
tableHeight: 0,
resizeObserver: null as ResizeObserver | null,
Expand Down Expand Up @@ -140,16 +143,33 @@ export default Vue.extend({
// 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom)
return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 55
},

// 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
}
Expand Down Expand Up @@ -212,16 +232,18 @@ export default Vue.extend({
* 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`,
}
},
Expand All @@ -233,15 +255,14 @@ export default Vue.extend({

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,
Expand All @@ -251,30 +272,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, 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() {
Expand All @@ -285,16 +304,60 @@ 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) {
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() {
Expand All @@ -307,6 +370,26 @@ export default Vue.extend({
this.$emit('scroll')
})
},

// Convert index to scroll position
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>
40 changes: 40 additions & 0 deletions cypress/e2e/core-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,43 @@ export enum UnifiedSearchFilter {
export function getUnifiedSearchFilter(filter: UnifiedSearchFilter) {
return getUnifiedSearchModal().find(`[data-cy-unified-search-filters] [data-cy-unified-search-filter="${CSS.escape(filter)}"]`)
}

/**
* Assertion that an element is fully within the current viewport.
* @param $el The element
* @param expected If the element is expected to be fully in viewport or not fully
* @example
* ```js
* cy.get('#my-element')
* .should(beFullyInViewport)
* ```
*/
export function beFullyInViewport($el: JQuery<HTMLElement>, expected = true) {
const { top, left, bottom, right } = $el.get(0)!.getBoundingClientRect()
const innerHeight = Cypress.$('body').innerHeight()!
const innerWidth = Cypress.$('body').innerWidth()!
const fullyVisible = top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth

console.debug(`fullyVisible: ${fullyVisible}, top: ${top >= 0}, left: ${left >= 0}, bottom: ${bottom <= innerHeight}, right: ${right <= innerWidth}`)

if (expected) {
// eslint-disable-next-line no-unused-expressions
expect(fullyVisible, 'Fully within viewport').to.be.true
} else {
// eslint-disable-next-line no-unused-expressions
expect(fullyVisible, 'Not fully within viewport').to.be.false
}
}

/**
* Opposite of `beFullyInViewport` - resolves when element is not or only partially in viewport.
* @param $el The element
* @example
* ```js
* cy.get('#my-element')
* .should(notBeFullyInViewport)
* ```
*/
export function notBeFullyInViewport($el: JQuery<HTMLElement>) {
return beFullyInViewport($el, false)
}
Loading
Loading