chromium/ui/webui/resources/cr_elements/cr_lazy_list/cr_lazy_list.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview 'cr-lazy-list' is a component optimized for showing a list of
 * items that overflows the view and requires scrolling. For performance
 * reasons, the DOM items are incrementally added to the view as the user
 * scrolls through the list. The component expects a `scrollTarget` property
 * to be specified indicating the scrolling container. This container is
 * used for observing scroll events and resizes. The container should have
 * bounded height so that cr-lazy-list can determine how many HTML elements to
 * render initially.
 * If using a container that can shrink arbitrarily small to the height of the
 * contents, a 'minViewportHeight' property should also be provided specifying
 * the minimum viewport height to try to fill with items.
 * Each list item's HTML element is created using the `template` property,
 * which should be set to a function returning a TemplateResult corresponding
 * to a passed in list item and selection index.
 * Set `listItemHost` to the `this` context for any event handlers in this
 * template. If this property is not provided, cr-lazy-list is assumed to be
 * residing in a ShadowRoot, and the shadowRoot's |host| is used.
 * The `items` property specifies an array of list item data.
 * The `itemSize` property should be set to an estimate of the list item size.
 * This is used when setting contain-intrinsic-size styling for list items.
 * To restore focus to a specific item if it is focused when the items
 * array changes, set `restoreFocusItem` to that HTMLElement. If the element
 * is focused when the items array is updated, focus will be restored.
 */

import {assert} from '//resources/js/assert.js';
import {getDeepActiveElement} from '//resources/js/util.js';
import {CrLitElement, html, render} from '//resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues, TemplateResult} from '//resources/lit/v3_0/lit.rollup.js';

import {getCss} from './cr_lazy_list.css.js';

export interface CrLazyListElement {
  $: {
    container: HTMLElement,
    slot: HTMLSlotElement,
  };
}

export class CrLazyListElement<T = object> extends CrLitElement {
  static get is() {
    return 'cr-lazy-list';
  }

  static override get styles() {
    return getCss();
  }

  override render() {
    const host = this.listItemHost === undefined ?
        (this.getRootNode() as ShadowRoot).host :
        this.listItemHost;

    // Render items into light DOM using the client provided template
    render(
        this.items.slice(0, this.numItemsDisplayed_).map((item, index) => {
          return this.template(item, index);
        }),
        this, {
          host: host,
        });

    // Render container + slot into shadow DOM
    return html`<div id="container"><slot id="slot"></slot></div>`;
  }

  static override get properties() {
    return {
      items: {type: Array},
      itemSize: {type: Number},
      listItemHost: {type: Object},
      minViewportHeight: {type: Number},
      scrollOffset: {type: Number},
      scrollTarget: {type: Object},
      restoreFocusElement: {type: Object},
      template: {type: Object},
      numItemsDisplayed_: {
        state: true,
        type: Number,
      },
    };
  }

  items: T[] = [];
  itemSize: number = 100;
  listItemHost?: Node;
  minViewportHeight?: number;
  scrollOffset: number = 0;
  scrollTarget: HTMLElement = document.documentElement;
  restoreFocusElement: Element|null = null;
  template: (item: T, index: number) => TemplateResult = () => html``;
  private numItemsDisplayed_: number = 0;

  // Internal state
  private lastRenderedHeight_: number = 0;
  private resizeObserver_: ResizeObserver|null = null;
  private scrollListener_: EventListener = () => this.onScroll_();

  override willUpdate(changedProperties: PropertyValues<this>) {
    super.willUpdate(changedProperties);

    if (changedProperties.has('items')) {
      this.numItemsDisplayed_ = this.items.length === 0 ?
          0 :
          Math.min(this.numItemsDisplayed_, this.items.length);
    }

    if (changedProperties.has('itemSize')) {
      this.style.setProperty('--list-item-size', `${this.itemSize}px`);
    }
  }

  override updated(changedProperties: PropertyValues<this>) {
    super.updated(changedProperties);

    let itemsChanged = false;
    if (changedProperties.has('items') ||
        changedProperties.has('minViewportHeight') ||
        changedProperties.has('scrollOffset')) {
      const previous = changedProperties.get('items');
      if (previous !== undefined || this.items.length !== 0) {
        this.onItemsChanged_();
        itemsChanged = true;
      }
    }

    if (changedProperties.has('scrollTarget')) {
      this.addRemoveScrollTargetListeners_(
          changedProperties.get('scrollTarget') || null);
      // Only re-render if there are items to display and we are not already
      // re-rendering for the items.
      if (this.scrollTarget && this.items.length > 0 && !itemsChanged) {
        this.fillCurrentViewport();
      }
    }
  }

  // Public API

  // Forces the list to fill the current viewport. Called when the viewport
  // size or position changes.
  fillCurrentViewport(): Promise<void> {
    if (this.items.length === 0) {
      return Promise.resolve();
    }

    // Update the height if the previous height calculation was done when this
    // element was not visible or if new DOM items were added.
    return this.update_(this.$.container.style.height === '0px');
  }

  // Forces the list to render |numItems| items. If |numItems| are already
  // rendered, this is a no-op.
  async ensureItemRendered(index: number): Promise<HTMLElement> {
    if (index < this.numItemsDisplayed_) {
      return this.$.slot.assignedElements()[index] as HTMLElement;
    }
    assert(index < this.items.length);
    await this.updateNumItemsDisplayed_(index + 1);
    return this.$.slot.assignedElements()[index] as HTMLElement;
  }

  // Private methods

  private addRemoveScrollTargetListeners_(oldTarget: HTMLElement|null) {
    if (oldTarget) {
      oldTarget.removeEventListener('scroll', this.scrollListener_);
      assert(this.resizeObserver_);
      this.resizeObserver_.disconnect();
    }
    if (this.scrollTarget) {
      this.scrollTarget.addEventListener('scroll', this.scrollListener_);
      this.resizeObserver_ = new ResizeObserver(() => {
        requestAnimationFrame(() => {
          const newHeight = this.getViewHeight_();
          if (newHeight > 0 && newHeight !== this.lastRenderedHeight_) {
            this.fillCurrentViewport();
          }
        });
      });
      this.resizeObserver_.observe(this.scrollTarget);
    }
  }

  private shouldRestoreFocus_(): boolean {
    if (!this.restoreFocusElement) {
      return false;
    }
    const active = getDeepActiveElement();
    return this.restoreFocusElement === active ||
        (!!this.restoreFocusElement.shadowRoot &&
         this.restoreFocusElement.shadowRoot.activeElement === active);
  }

  private async onItemsChanged_() {
    if (this.items.length > 0) {
      const restoreFocus = this.shouldRestoreFocus_();
      await this.update_(true);

      if (restoreFocus) {
        // Async to allow clients to update in response to viewport-filled.
        setTimeout(() => {
          (this.restoreFocusElement as HTMLElement).focus();
          this.fire('focus-restored-for-test');
        }, 0);
      }
    } else {
      // Update the container height to 0 since there are no items.
      this.$.container.style.height = '0px';
      this.fire('viewport-filled');
    }
  }

  private getViewHeight_() {
    return this.scrollTarget.scrollTop - this.scrollOffset +
        Math.max(this.minViewportHeight || 0, this.scrollTarget.offsetHeight);
  }

  private async update_(forceUpdateHeight: boolean): Promise<void> {
    if (!this.scrollTarget) {
      return;
    }

    const height = this.getViewHeight_();
    if (height <= 0) {
      return;
    }

    const added = await this.fillViewHeight_(height);
    if (added || forceUpdateHeight) {
      await this.updateHeight_();
      this.fire('viewport-filled');
    }
  }

  /**
   * @return Whether DOM items were created or not.
   */
  private async fillViewHeight_(height: number): Promise<boolean> {
    this.fire('fill-height-start');
    this.lastRenderedHeight_ = height;

    // Ensure we have added enough DOM items so that we are able to estimate
    // item average height.
    assert(this.items.length);
    const initialDomItemCount = this.$.slot.assignedElements().length;
    if (initialDomItemCount === 0) {
      await this.updateNumItemsDisplayed_(1);
    }

    const itemHeight = this.domItemAverageHeight_();
    // If this happens, the math below will be incorrect and we will render
    // all items. So return early, and correct |lastRenderedHeight_|.
    if (itemHeight === 0) {
      this.lastRenderedHeight_ = 0;
      return false;
    }

    const desiredDomItemCount =
        Math.min(Math.ceil(height / itemHeight), this.items.length);
    if (desiredDomItemCount > this.numItemsDisplayed_) {
      await this.updateNumItemsDisplayed_(desiredDomItemCount);
    }

    const added = initialDomItemCount !== desiredDomItemCount;
    if (added) {
      this.fire('fill-height-end');
    }
    return added;
  }

  private async updateNumItemsDisplayed_(itemsToDisplay: number) {
    this.numItemsDisplayed_ = itemsToDisplay;
    await this.updateComplete;
  }

  /**
   * @return The average DOM item height.
   */
  private domItemAverageHeight_(): number {
    // This logic should only be invoked if the list is non-empty and at least
    // one DOM item has been rendered so that an item average height can be
    // estimated. This is ensured by the callers.
    assert(this.items.length > 0);
    const domItems = this.$.slot.assignedElements();
    assert(domItems.length > 0);
    const lastDomItem = domItems.at(-1) as HTMLElement;
    return (lastDomItem.offsetTop + lastDomItem.offsetHeight) / domItems.length;
  }

  /**
   * Sets the height of the component based on an estimated average DOM item
   * height and the total number of items.
   */
  private async updateHeight_() {
    // Await 1 cycle to ensure any child Lit elements have time to finish
    // rendering, or the height estimated below will be incorrect.
    await new Promise(resolve => setTimeout(resolve, 0));
    const estScrollHeight = this.items.length > 0 ?
        this.items.length * this.domItemAverageHeight_() :
        0;
    this.$.container.style.height = estScrollHeight + 'px';
  }

  /**
   * Adds additional DOM items as needed to fill the view based on user scroll
   * interactions.
   */
  private async onScroll_() {
    const scrollTop = this.scrollTarget.scrollTop;
    if (scrollTop <= 0 || this.numItemsDisplayed_ === this.items.length) {
      return;
    }

    await this.fillCurrentViewport();
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'cr-lazy-list': CrLazyListElement;
  }
}

customElements.define(CrLazyListElement.is, CrLazyListElement);