All Downloads are FREE. Search and download functionalities are using the official Maven repository.

package.src.vaadin-grid-scroll-mixin.js Maven / Gradle / Ivy

Go to download

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

There is a newer version: 24.6.0
Show newest version
/**
 * @license
 * Copyright (c) 2016 - 2024 Vaadin Ltd.
 * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
 */
import { isElementHidden } from '@vaadin/a11y-base';
import { animationFrame, 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 { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js';

const timeouts = {
  SCROLLING: 500,
  UPDATE_CONTENT_VISIBILITY: 100,
};

/**
 * @polymerMixin
 */
export const ScrollMixin = (superClass) =>
  class ScrollMixin extends ResizeMixin(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
         * @type {!ColumnRendering}
         */
        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: () => [],
        },

        /** @private */
        _rowWithFocusedElement: Element,
      };
    }

    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 itemsIndex = e.composedPath().indexOf(this.$.items);
        this._rowWithFocusedElement = e.composedPath()[itemsIndex - 1];

        if (this._rowWithFocusedElement) {
          // Make sure the row with the focused element is fully inside the visible viewport
          // Don't change scroll position if the user is interacting with the mouse
          if (!this._isMousedown) {
            this.__scrollIntoViewport(this._rowWithFocusedElement.index);
          }

          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: this._rowWithFocusedElement } }),
            );
          }
        }
      });
      this.$.items.addEventListener('focusout', () => {
        this._rowWithFocusedElement = undefined;
      });

      this.$.table.addEventListener('scroll', () => this._afterScroll());
    }

    /**
     * @protected
     * @override
     */
    _onResize() {
      this._updateOverflow();
      this.__updateHorizontalScrollPosition();

      // For Firefox, manually restore last scroll position when grid becomes
      // visible again. This solves an issue where switching visibility of two
      // grids causes Firefox trying to synchronize the scroll positions between
      // the two grid's table elements.
      // See https://github.com/vaadin/web-components/issues/5796
      if (this._firefox) {
        const isVisible = !isElementHidden(this);
        if (isVisible && this.__previousVisible === false) {
          this._scrollTop = this.__memorizedScrollTop || 0;
        }
        this.__previousVisible = isVisible;
      }
    }

    /**
     * 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);
      this.__scrollIntoViewport(index);
    }

    /**
     * Makes sure the row with the given index (if found in the DOM) is fully
     * inside the visible viewport, taking header/footer into account.
     * @private
     */
    __scrollIntoViewport(index) {
      const rowElement = [...this.$.items.children].find((child) => child.index === index);
      if (rowElement) {
        const dstRect = rowElement.getBoundingClientRect();
        const footerTop = this.$.footer.getBoundingClientRect().top;
        const headerBottom = this.$.header.getBoundingClientRect().bottom;
        if (dstRect.bottom > footerTop) {
          this.$.table.scrollTop += dstRect.bottom - footerTop;
        } else if (dstRect.top < headerBottom) {
          this.$.table.scrollTop -= headerBottom - dstRect.top;
        }
      }
    }

    /** @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._updateOverflow();

      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();
          }
        },
      );

      // Memorize last scroll position in Firefox
      if (this._firefox) {
        const isVisible = !isElementHidden(this);
        if (isVisible && this.__previousVisible !== false) {
          this.__memorizedScrollTop = this._scrollTop;
        }
      }
    }

    /** @private */
    __updateColumnsBodyContentHidden() {
      if (!this._columnTree || !this._areSizerCellsAssigned()) {
        return;
      }

      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();
    }

    /** @private */
    _updateOverflow() {
      this._debounceOverflow = Debouncer.debounce(this._debounceOverflow, animationFrame, () => {
        this.__doUpdateOverflow();
      });
    }

    /** @private */
    __doUpdateOverflow() {
      // Set overflow styling attributes
      let overflow = '';
      const table = this.$.table;
      if (table.scrollTop < table.scrollHeight - table.clientHeight) {
        overflow += ' bottom';
      }

      if (table.scrollTop > 0) {
        overflow += ' top';
      }

      const scrollLeft = getNormalizedScrollLeft(table, this.getAttribute('dir'));
      if (scrollLeft > 0) {
        overflow += ' start';
      }

      if (scrollLeft < table.scrollWidth - table.clientWidth) {
        overflow += ' end';
      }

      if (this.__isRTL) {
        overflow = overflow.replace(/start|end/giu, (matched) => {
          return matched === 'start' ? 'end' : 'start';
        });
      }

      // TODO: Remove "right" and "left" values in the next major.
      if (table.scrollLeft < table.scrollWidth - table.clientWidth) {
        overflow += ' right';
      }

      if (table.scrollLeft > 0) {
        overflow += ' left';
      }

      const value = overflow.trim();
      if (value.length > 0 && this.getAttribute('overflow') !== value) {
        this.setAttribute('overflow', value);
      } else if (value.length === 0 && this.hasAttribute('overflow')) {
        this.removeAttribute('overflow');
      }
    }

    /** @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;
      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;
        }
      });

      // Only update the --_grid-horizontal-scroll-position custom property when navigating
      // on row focus mode to avoid performance issues.
      if (this.hasAttribute('navigating') && this.__rowFocusMode) {
        this.$.table.style.setProperty('--_grid-horizontal-scroll-position', `${-x}px`);
      }
    }

    /** @private */
    _areSizerCellsAssigned() {
      return this._getColumnsInOrder().every((column) => column._sizerCell);
    }
  };




© 2015 - 2024 Weber Informatics LLC | Privacy Policy