UNPKG

@vaadin/grid

Version:

A free, flexible and high-quality Web Component for showing large amounts of tabular data

541 lines (466 loc) 20.3 kB
/** * @license * Copyright (c) 2016 - 2026 Vaadin Ltd. * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ import { microTask, timeOut } from '@vaadin/component-base/src/async.js'; import { Debouncer } from '@vaadin/component-base/src/debounce.js'; import { getNormalizedScrollLeft } from '@vaadin/component-base/src/dir-utils.js'; import { OverflowController } from '@vaadin/component-base/src/overflow-controller.js'; const timeouts = { SCROLLING: 500, UPDATE_CONTENT_VISIBILITY: 100, }; export const ScrollMixin = (superClass) => class ScrollMixin extends superClass { static get properties() { return { /** * Allows you to choose between modes for rendering columns in the grid: * * "eager" (default): All columns are rendered upfront, regardless of their visibility within the viewport. * This mode should generally be preferred, as it avoids the limitations imposed by the "lazy" mode. * Use this mode unless the grid has a large number of columns and performance outweighs the limitations * in priority. * * "lazy": Optimizes the rendering of cells when there are multiple columns in the grid by virtualizing * horizontal scrolling. In this mode, body cells are rendered only when their corresponding columns are * inside the visible viewport. * * Using "lazy" rendering should be used only if you're dealing with a large number of columns and performance * is your highest priority. For most use cases, the default "eager" mode is recommended due to the * limitations imposed by the "lazy" mode. * * When using the "lazy" mode, keep the following limitations in mind: * * - Row Height: When only a number of columns are visible at once, the height of a row can only be that of * the highest cell currently visible on that row. Make sure each cell on a single row has the same height * as all other cells on that row. If row cells have different heights, users may experience jumpiness when * scrolling the grid horizontally as lazily rendered cells with different heights are scrolled into view. * * - Auto-width Columns: For the columns that are initially outside the visible viewport but still use auto-width, * only the header content is taken into account when calculating the column width because the body cells * of the columns outside the viewport are not initially rendered. * * - Screen Reader Compatibility: Screen readers may not be able to associate the focused cells with the correct * headers when only a subset of the body cells on a row is rendered. * * - Keyboard Navigation: Tabbing through focusable elements inside the grid body may not work as expected because * some of the columns that would include focusable elements in the body cells may be outside the visible viewport * and thus not rendered. * * @attr {eager|lazy} column-rendering */ columnRendering: { type: String, value: 'eager', sync: true, }, /** * Cached array of frozen cells * @private */ _frozenCells: { type: Array, value: () => [], }, /** * Cached array of cells frozen to end * @private */ _frozenToEndCells: { type: Array, value: () => [], }, }; } static get observers() { return ['__columnRenderingChanged(_columnTree, columnRendering)']; } /** @private */ get _scrollLeft() { return this.$.table.scrollLeft; } /** @private */ get _scrollTop() { return this.$.table.scrollTop; } /** * Override (from iron-scroll-target-behavior) to avoid document scroll * @private */ set _scrollTop(top) { this.$.table.scrollTop = top; } /** @protected */ get _lazyColumns() { return this.columnRendering === 'lazy'; } /** @protected */ ready() { super.ready(); this.scrollTarget = this.$.table; this.$.items.addEventListener('focusin', (e) => { const composedPath = e.composedPath(); const row = composedPath[composedPath.indexOf(this.$.items) - 1]; if (row) { // Don't change scroll position if the user is interacting with the mouse. if (!this._isMousedown) { // Make sure the focused element (row, cell, or focusable element inside a cell) // is inside the viewport. If the whole row fits into the viewport, then scroll // the row into view. This ensures that labels, helper texts and other related // elements of focusable elements within cells also become visible. When the row // is larger than the viewport, scroll the focus event target into the viewport. // This works better when focusing elements within cells, which could otherwise // still be outside the viewport when scrolling to the top or bottom of the row. const tableHeight = this.$.table.clientHeight; const headerHeight = this.$.header.clientHeight; const footerHeight = this.$.footer.clientHeight; const viewportHeight = tableHeight - headerHeight - footerHeight; const isRowLargerThanViewport = row.clientHeight > viewportHeight; const scrollTarget = isRowLargerThanViewport ? e.target : row; this.__scrollIntoViewport(scrollTarget); } if (!this.$.table.contains(e.relatedTarget)) { // Virtualizer can't catch the event because if orginates from the light DOM. // Dispatch a virtualizer-element-focused event for virtualizer to catch. this.$.table.dispatchEvent(new CustomEvent('virtualizer-element-focused', { detail: { element: row } })); } } }); this.$.table.addEventListener('scroll', () => this._afterScroll()); this.__overflowController = new OverflowController(this, this.$.table); this.addController(this.__overflowController); } /** * Scroll to a flat index in the grid. The method doesn't take into account * the hierarchy of the items. * * @param {number} index Row index to scroll to * @protected */ _scrollToFlatIndex(index) { index = Math.min(this._flatSize - 1, Math.max(0, index)); this.__virtualizer.scrollToIndex(index); const rowElement = [...this.$.items.children].find((child) => child.index === index); this.__scrollIntoViewport(rowElement); } /** * Scrolls horizontally so that the column becomes visible in the viewport. * * The column can be specified either by its index (among visible columns * in visual order) or by the column element itself. * * @param {GridColumn | number} indexOrColumn - Column element or column index */ scrollToColumn(indexOrColumn) { // Defer if grid not ready if (!this._columnTree) { this.__pendingScrollToColumn = indexOrColumn; return; } // Get visible leaf columns in visual order (respects reordering, excludes hidden) const visibleColumns = this._getColumnsInOrder(); let column; if (typeof indexOrColumn === 'number') { column = visibleColumns[indexOrColumn]; if (!column) { console.warn(`Column index ${indexOrColumn} is out of bounds`); return; } } else { column = indexOrColumn; if (!visibleColumns.includes(column)) { console.warn('Column is not a visible column of this grid'); return; } } // Frozen columns are always visible, no scroll needed if (column.frozen || column.frozenToEnd) { return; } this._scrollHorizontallyToCell(column._headerCell); // Update cell position synchronously this.__updateHorizontalScrollPosition(); // Synchronously update cells when using lazy column rendering this.__updateColumnsBodyContentHidden(); } /** @private */ __scrollToPendingColumn() { if (this.__pendingScrollToColumn !== undefined) { const indexOrColumn = this.__pendingScrollToColumn; delete this.__pendingScrollToColumn; this.scrollToColumn(indexOrColumn); } } /** * Makes sure the given element is fully inside the visible viewport, * taking header/footer into account. * @private */ __scrollIntoViewport(element) { if (!element) { return; } const elementRect = element.getBoundingClientRect(); const elementComputedStyle = getComputedStyle(element); const elementTop = elementRect.top + parseInt(elementComputedStyle.scrollMarginTop || 0); const elementBottom = elementRect.bottom + parseInt(elementComputedStyle.scrollMarginBottom || 0); const footerTop = this.$.footer.getBoundingClientRect().top; const headerBottom = this.$.header.getBoundingClientRect().bottom; if (elementBottom > footerTop) { this.$.table.scrollTop += elementBottom - footerTop; } else if (elementTop < headerBottom) { this.$.table.scrollTop -= headerBottom - elementTop; } } /** @private */ _scheduleScrolling() { if (!this._scrollingFrame) { // Defer setting state attributes to avoid Edge hiccups this._scrollingFrame = requestAnimationFrame(() => this.$.scroller.toggleAttribute('scrolling', true)); } this._debounceScrolling = Debouncer.debounce(this._debounceScrolling, timeOut.after(timeouts.SCROLLING), () => { cancelAnimationFrame(this._scrollingFrame); delete this._scrollingFrame; this.$.scroller.toggleAttribute('scrolling', false); }); } /** @private */ _afterScroll() { this.__updateHorizontalScrollPosition(); if (!this.hasAttribute('reordering')) { this._scheduleScrolling(); } if (!this.hasAttribute('navigating')) { this._hideTooltip(true); } this._debounceColumnContentVisibility = Debouncer.debounce( this._debounceColumnContentVisibility, timeOut.after(timeouts.UPDATE_CONTENT_VISIBILITY), () => { // If horizontal scroll position changed and lazy column rendering is enabled, // update the visible columns. if (this._lazyColumns && this.__cachedScrollLeft !== this._scrollLeft) { this.__cachedScrollLeft = this._scrollLeft; this.__updateColumnsBodyContentHidden(); } }, ); } /** @private */ __updateColumnsBodyContentHidden() { if (!this._columnTree || !this._areSizerCellsAssigned()) { return; } this.__scrollToPendingColumn(); const columnsInOrder = this._getColumnsInOrder(); let bodyContentHiddenChanged = false; // Remove the column cells from the DOM if the column is outside the viewport. // Add the column cells to the DOM if the column is inside the viewport. // // Update the _bodyContentHidden property of the column to reflect the current // visibility state and make it run renderers for the cells if necessary. columnsInOrder.forEach((column) => { const bodyContentHidden = this._lazyColumns && !this.__isColumnInViewport(column); if (column._bodyContentHidden !== bodyContentHidden) { bodyContentHiddenChanged = true; column._cells.forEach((cell) => { if (cell !== column._sizerCell) { if (bodyContentHidden) { cell.remove(); } else if (cell.__parentRow) { // Add the cell to the correct DOM position in the row const followingColumnCell = [...cell.__parentRow.children].find( (child) => columnsInOrder.indexOf(child._column) > columnsInOrder.indexOf(column), ); cell.__parentRow.insertBefore(cell, followingColumnCell); } } }); } column._bodyContentHidden = bodyContentHidden; }); if (bodyContentHiddenChanged) { // Frozen columns may have changed their visibility this._frozenCellsChanged(); } if (this._lazyColumns) { // Calculate the offset to apply to the body cells const lastFrozenColumn = [...columnsInOrder].reverse().find((column) => column.frozen); const lastFrozenColumnEnd = this.__getColumnEnd(lastFrozenColumn); const firstVisibleColumn = columnsInOrder.find((column) => !column.frozen && !column._bodyContentHidden); this.__lazyColumnsStart = this.__getColumnStart(firstVisibleColumn) - lastFrozenColumnEnd; this.$.items.style.setProperty('--_grid-lazy-columns-start', `${this.__lazyColumnsStart}px`); // Make sure the body has a focusable element in lazy columns mode this._resetKeyboardNavigation(); } } /** @private */ __getColumnEnd(column) { if (!column) { return this.__isRTL ? this.$.table.clientWidth : 0; } return column._sizerCell.offsetLeft + (this.__isRTL ? 0 : column._sizerCell.offsetWidth); } /** @private */ __getColumnStart(column) { if (!column) { return this.__isRTL ? this.$.table.clientWidth : 0; } return column._sizerCell.offsetLeft + (this.__isRTL ? column._sizerCell.offsetWidth : 0); } /** * Returns true if the given column is horizontally inside the viewport. * @private */ __isColumnInViewport(column) { if (column.frozen || column.frozenToEnd) { // Assume frozen columns to always be inside the viewport return true; } // Check if the column's sizer cell is inside the viewport return this.__isHorizontallyInViewport(column._sizerCell); } /** @private */ __isHorizontallyInViewport(element) { return ( element.offsetLeft + element.offsetWidth >= this._scrollLeft && element.offsetLeft <= this._scrollLeft + this.clientWidth ); } /** @private */ __columnRenderingChanged(_columnTree, columnRendering) { if (columnRendering === 'eager') { this.$.scroller.removeAttribute('column-rendering'); } else { this.$.scroller.setAttribute('column-rendering', columnRendering); } this.__updateColumnsBodyContentHidden(); } /** @protected */ _frozenCellsChanged() { this._debouncerCacheElements = Debouncer.debounce(this._debouncerCacheElements, microTask, () => { Array.from(this.shadowRoot.querySelectorAll('[part~="cell"]')).forEach((cell) => { cell.style.transform = ''; }); this._frozenCells = Array.prototype.slice.call(this.$.table.querySelectorAll('[frozen]')); this._frozenToEndCells = Array.prototype.slice.call(this.$.table.querySelectorAll('[frozen-to-end]')); this.__updateHorizontalScrollPosition(); }); this._debounceUpdateFrozenColumn(); } /** @protected */ _debounceUpdateFrozenColumn() { this.__debounceUpdateFrozenColumn = Debouncer.debounce(this.__debounceUpdateFrozenColumn, microTask, () => this._updateFrozenColumn(), ); } /** @private */ _updateFrozenColumn() { if (!this._columnTree) { return; } const columnsRow = this._columnTree[this._columnTree.length - 1].slice(0); columnsRow.sort((a, b) => { return a._order - b._order; }); let lastFrozen; let firstFrozenToEnd; // Use for loop to only iterate columns once for (let i = 0; i < columnsRow.length; i++) { const col = columnsRow[i]; col._lastFrozen = false; col._firstFrozenToEnd = false; if (firstFrozenToEnd === undefined && col.frozenToEnd && !col.hidden) { firstFrozenToEnd = i; } if (col.frozen && !col.hidden) { lastFrozen = i; } } if (lastFrozen !== undefined) { columnsRow[lastFrozen]._lastFrozen = true; } if (firstFrozenToEnd !== undefined) { columnsRow[firstFrozenToEnd]._firstFrozenToEnd = true; } this.__updateColumnsBodyContentHidden(); } /** @private */ __updateHorizontalScrollPosition() { if (!this._columnTree) { return; } const scrollWidth = this.$.table.scrollWidth; const clientWidth = this.$.table.clientWidth; const scrollLeft = Math.max(0, this.$.table.scrollLeft); const normalizedScrollLeft = getNormalizedScrollLeft(this.$.table, this.getAttribute('dir')); // Position header, footer and items container const transform = `translate(${-scrollLeft}px, 0)`; this.$.header.style.transform = transform; this.$.footer.style.transform = transform; this.$.items.style.transform = transform; // Position frozen cells const x = this.__isRTL ? normalizedScrollLeft + clientWidth - scrollWidth : scrollLeft; this.__horizontalScrollPosition = x; const transformFrozen = `translate(${x}px, 0)`; this._frozenCells.forEach((cell) => { cell.style.transform = transformFrozen; }); // Position cells frozen to end const remaining = this.__isRTL ? normalizedScrollLeft : scrollLeft + clientWidth - scrollWidth; const transformFrozenToEnd = `translate(${remaining}px, 0)`; let transformFrozenToEndBody = transformFrozenToEnd; if (this._lazyColumns && this._areSizerCellsAssigned()) { // Lazy column rendering is used, calculate the offset to apply to the frozen to end cells const columnsInOrder = this._getColumnsInOrder(); const lastVisibleColumn = [...columnsInOrder] .reverse() .find((column) => !column.frozenToEnd && !column._bodyContentHidden); const lastVisibleColumnEnd = this.__getColumnEnd(lastVisibleColumn); const firstFrozenToEndColumn = columnsInOrder.find((column) => column.frozenToEnd); const firstFrozenToEndColumnStart = this.__getColumnStart(firstFrozenToEndColumn); const translateX = remaining + (firstFrozenToEndColumnStart - lastVisibleColumnEnd) + this.__lazyColumnsStart; transformFrozenToEndBody = `translate(${translateX}px, 0)`; } this._frozenToEndCells.forEach((cell) => { if (this.$.items.contains(cell)) { cell.style.transform = transformFrozenToEndBody; } else { cell.style.transform = transformFrozenToEnd; } }); const focusedRow = this.shadowRoot.querySelector("[part~='row']:focus"); if (focusedRow) { // Update the horizontal scroll position property of the focused row this.__updateRowScrollPositionProperty(focusedRow); } const lastHeaderRow = this.$.header.querySelector("[part~='last-header-row']"); if (lastHeaderRow) { this.__updateRowScrollPositionProperty(lastHeaderRow); } const firstFooterRow = this.$.footer.querySelector("[part~='first-footer-row']"); if (firstFooterRow) { this.__updateRowScrollPositionProperty(firstFooterRow); } } /** * Synchronizes the internal `--_grid-horizontal-scroll-position` CSS property * of the given row with the current horizontal scroll position of the grid. * @private */ __updateRowScrollPositionProperty(row) { if (row instanceof HTMLTableRowElement === false) { return; } const newValue = `${this.__horizontalScrollPosition}px`; if (row.style.getPropertyValue('--_grid-horizontal-scroll-position') !== newValue) { row.style.setProperty('--_grid-horizontal-scroll-position', newValue); } } /** @private */ _areSizerCellsAssigned() { return this._getColumnsInOrder().every((column) => column._sizerCell); } };