chromium/ui/webui/resources/cr_elements/cr_scroll_observer_mixin.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 CrScrollObserverMixin has logic to add CSS classes based
 * on the scroll state of a scrolling container element specified by a
 * #container ID.
 *
 * Elements using this mixin are expected to define a #container element, which
 * is the element being scrolled. There should only be one such element in the
 * DOM.
 * <div id="container">...</div>
 * Alternatively, clients can set a container element by overriding the
 * getContainer() method. This method should always return the same element
 * throughout the life of the client. It is first called in connectedCallback().
 *
 * The mixin will toggle CSS classes on #container indicating the current scroll
 * state.
 * can-scroll: Has content to scroll to
 * scrolled-to-top: Scrolled all the way to the top
 * scrolled-to-bottom: Scrolled all the way to the bottom
 *
 * Clients can use these classes to define styles.
 */

import {assert} from '//resources/js/assert.js';
import type {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {dedupingMixin} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

type Constructor<T> = new (...args: any[]) => T;

export const CrScrollObserverMixin = dedupingMixin(
    <T extends Constructor<PolymerElement>>(superClass: T): T&
    Constructor<CrScrollObserverMixinInterface> => {
      class CrScrollObserverMixin extends superClass implements
          CrScrollObserverMixinInterface {
        private intersectionObserver_: IntersectionObserver|null = null;
        private topProbe_: HTMLElement|null = null;
        private bottomProbe_: HTMLElement|null = null;

        override connectedCallback() {
          super.connectedCallback();

          const container = this.getContainer();
          this.topProbe_ = document.createElement('div');
          this.bottomProbe_ = document.createElement('div');
          container.prepend(this.topProbe_);
          container.append(this.bottomProbe_);

          this.enableScrollObservation(true);
        }

        override disconnectedCallback() {
          super.disconnectedCallback();

          this.enableScrollObservation(false);
        }

        // Defaults to returning #container. Override for different behavior.
        getContainer(): HTMLElement {
          const container =
              this.shadowRoot!.querySelector<HTMLElement>('#container');
          assert(container);
          return container;
        }

        private getIntersectionObserver_(): IntersectionObserver {
          const callback = (entries: IntersectionObserverEntry[]) => {
            // In some rare cases, there could be more than one entry per
            // observed element, in which case the last entry's result
            // stands.
            const container = this.getContainer();
            for (const entry of entries) {
              const target = entry.target;
              if (target === this.topProbe_) {
                container.classList.toggle(
                    'scrolled-to-top', entry.intersectionRatio !== 0);
                const canScroll = entry.intersectionRatio === 0 ||
                    !container.classList.contains('scrolled-to-bottom');
                container.classList.toggle('can-scroll', canScroll);
              }
              if (target === this.bottomProbe_) {
                container.classList.toggle(
                    'scrolled-to-bottom', entry.intersectionRatio !== 0);
                const canScroll = entry.intersectionRatio === 0 ||
                    !container.classList.contains('scrolled-to-top');
                container.classList.toggle('can-scroll', canScroll);
              }
            }
          };
          return new IntersectionObserver(
              callback, {root: this.getContainer(), threshold: 0});
        }

        /**
         * @param enable Whether to enable the mixin or disable it.
         *     This function does nothing if the mixin is already in the
         *     requested state.
         */
        enableScrollObservation(enable: boolean) {
          // Behavior is already enabled/disabled. Return early.
          if (enable === !!this.intersectionObserver_) {
            return;
          }

          if (!enable) {
            this.intersectionObserver_!.disconnect();
            this.intersectionObserver_ = null;
            return;
          }

          this.intersectionObserver_ = this.getIntersectionObserver_();

          // Need to register the observer within a setTimeout() callback,
          // otherwise the drop shadow flashes once on startup, because of the
          // DOM modifications earlier in this function causing a relayout.
          window.setTimeout(() => {
            // In case this is already detached.
            if (!this.isConnected) {
              return;
            }

            if (this.intersectionObserver_) {
              assert(this.topProbe_);
              assert(this.bottomProbe_);
              this.intersectionObserver_.observe(this.topProbe_);
              this.intersectionObserver_.observe(this.bottomProbe_);
            }
          });
        }
      }

      return CrScrollObserverMixin;
    });

export interface CrScrollObserverMixinInterface {
  enableScrollObservation(enable: boolean): void;

  // Defaults to returning #container. Override for different behavior. Do
  // not override if using via CrContainerShadowMixin.
  getContainer(): HTMLElement;
}