| // Copyright 2022 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /** |
| * @fileoverview Mixin for scrollable containers with <iron-list>. |
| * |
| * Any containers with the 'scrollable' attribute set will have the following |
| * classes toggled appropriately: can-scroll, is-scrolled, scrolled-to-bottom. |
| * These classes are used to style the container div and list elements |
| * appropriately, see cr_shared_style.css. |
| * |
| * The associated HTML should look something like: |
| * <div id="container" scrollable> |
| * <iron-list items="[[items]]" scroll-target="container"> |
| * <template> |
| * <my-element item="[[item]] tabindex$="[[tabIndex]]"></my-element> |
| * </template> |
| * </iron-list> |
| * </div> |
| * |
| * In order to get correct keyboard focus (tab) behavior within the list, |
| * any elements with tabbable sub-elements also need to set tabindex, e.g: |
| * |
| * <dom-module id="my-element> |
| * <template> |
| * ... |
| * <paper-icon-button toggles active="{{opened}}" tabindex$="[[tabindex]]"> |
| * </template> |
| * </dom-module> |
| * |
| * NOTE: If 'container' is not fixed size, it is important to call |
| * updateScrollableContents() when [[items]] changes, otherwise the container |
| * will not be sized correctly. |
| */ |
| |
| // clang-format off |
| import type { PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js'; |
| import {beforeNextRender, dedupingMixin, microTask} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js'; |
| import type {IronListElement} from '//resources/polymer/v3_0/iron-list/iron-list.js'; |
| // clang-format on |
| |
| type IronListElementWithExtras = IronListElement&{ |
| savedScrollTops: number[], |
| }; |
| |
| type Constructor<T> = new (...args: any[]) => T; |
| |
| export const ScrollableMixin = dedupingMixin( |
| <T extends Constructor<PolymerElement>>(superClass: T): T& |
| Constructor<ScrollableMixinInterface> => { |
| class ScrollableMixin extends superClass implements |
| ScrollableMixinInterface { |
| private resizeObserver_: ResizeObserver; |
| |
| constructor(...args: any[]) { |
| super(...args); |
| |
| this.resizeObserver_ = new ResizeObserver((entries) => { |
| requestAnimationFrame(() => { |
| for (const entry of entries) { |
| this.onScrollableContainerResize_(entry.target as HTMLElement); |
| } |
| }); |
| }); |
| } |
| |
| override ready() { |
| super.ready(); |
| |
| beforeNextRender(this, () => { |
| this.requestUpdateScroll(); |
| |
| // Listen to the 'scroll' event for each scrollable container. |
| const scrollableElements = |
| this.shadowRoot!.querySelectorAll('[scrollable]'); |
| for (const scrollableElement of scrollableElements) { |
| scrollableElement.addEventListener( |
| 'scroll', this.updateScrollEvent_.bind(this)); |
| } |
| }); |
| } |
| |
| override disconnectedCallback() { |
| super.disconnectedCallback(); |
| this.resizeObserver_.disconnect(); |
| } |
| |
| /** |
| * Called any time the contents of a scrollable container may have |
| * changed. This ensures that the <iron-list> contents of dynamically |
| * sized containers are resized correctly. |
| */ |
| updateScrollableContents() { |
| this.requestUpdateScroll(); |
| |
| const ironLists = this.shadowRoot!.querySelectorAll<IronListElement>( |
| '[scrollable] iron-list'); |
| |
| for (const ironList of ironLists) { |
| // When the scroll-container of an iron-list has scrollHeight of 1, |
| // the iron-list will default to showing a minimum of 3 items. |
| // After an iron-resize is fired, it will resize to have the correct |
| // scrollHeight, but another iron-resize is required to render all |
| // the items correctly. |
| // If the scrollHeight of the scroll-container is 0, the element is |
| // not yet rendered, and we must wait until its scrollHeight becomes |
| // 1, then fire the first iron-resize event. |
| const scrollContainer = ironList.parentElement!; |
| const scrollHeight = scrollContainer.scrollHeight; |
| |
| if (scrollHeight <= 1 && ironList.items!.length > 0 && |
| window.getComputedStyle(scrollContainer).display !== 'none') { |
| // The scroll-container does not have a proper scrollHeight yet. |
| // An additional iron-resize is needed, which will be triggered by |
| // the observer after scrollHeight changes. |
| // Do not observe for resize if there are no items, or if the |
| // scroll-container is explicitly hidden, as in those cases there |
| // will not be any future resizes. |
| this.resizeObserver_.observe(scrollContainer); |
| } |
| |
| if (scrollHeight !== 0) { |
| // If the iron-list is already rendered, fire an initial |
| // iron-resize event. Otherwise, the resizeObserver_ will handle |
| // firing the iron-resize event, upon its scrollHeight becoming 1. |
| ironList.notifyResize(); |
| } |
| } |
| } |
| |
| /** |
| * Setup the initial scrolling related classes for each scrollable |
| * container. Called from ready() and updateScrollableContents(). May |
| * also be called directly when the contents change (e.g. when not using |
| * iron-list). |
| */ |
| requestUpdateScroll() { |
| requestAnimationFrame(() => { |
| const scrollableElements = |
| this.shadowRoot!.querySelectorAll<HTMLElement>('[scrollable]'); |
| for (const scrollableElement of scrollableElements) { |
| this.updateScroll_(scrollableElement); |
| } |
| }); |
| } |
| |
| saveScroll(list: IronListElementWithExtras) { |
| // Store a FIFO of saved scroll positions so that multiple updates in |
| // a frame are applied correctly. Specifically we need to track when |
| // '0' is saved (but not apply it), and still handle patterns like |
| // [30, 0, 32]. |
| list.savedScrollTops = list.savedScrollTops || []; |
| list.savedScrollTops.push(list.scrollTarget!.scrollTop); |
| } |
| |
| restoreScroll(list: IronListElementWithExtras) { |
| microTask.run(() => { |
| const scrollTop = list.savedScrollTops.shift(); |
| // Ignore scrollTop of 0 in case it was intermittent (we do not need |
| // to explicitly scroll to 0). |
| if (scrollTop !== 0) { |
| list.scroll(0, scrollTop!); |
| } |
| }); |
| } |
| |
| /** |
| * Event wrapper for updateScroll_. |
| */ |
| private updateScrollEvent_(event: Event) { |
| const scrollable = event.target as HTMLElement; |
| this.updateScroll_(scrollable); |
| } |
| |
| /** |
| * This gets called once initially and any time a scrollable container |
| * scrolls. |
| */ |
| private updateScroll_(scrollable: HTMLElement) { |
| scrollable.classList.toggle( |
| 'can-scroll', scrollable.clientHeight < scrollable.scrollHeight); |
| scrollable.classList.toggle('is-scrolled', scrollable.scrollTop > 0); |
| scrollable.classList.toggle( |
| 'scrolled-to-bottom', |
| scrollable.scrollTop + scrollable.clientHeight >= |
| scrollable.scrollHeight); |
| } |
| |
| /** |
| * This gets called upon a resize event on the scrollable element |
| */ |
| private onScrollableContainerResize_(scrollable: HTMLElement) { |
| const nodeList = |
| scrollable.querySelectorAll<IronListElement>('iron-list'); |
| if (nodeList.length === 0 || scrollable.scrollHeight > 1) { |
| // Stop observing after the scrollHeight has its correct value, or |
| // if somehow there are no more iron-lists in the scrollable. |
| this.resizeObserver_.unobserve(scrollable); |
| } |
| |
| if (scrollable.scrollHeight !== 0) { |
| // Fire iron-resize event only if scrollHeight has changed from 0 to |
| // 1 or from 1 to the correct size. ResizeObserver doesn't exactly |
| // observe scrollHeight and may fire despite it staying at 0, so |
| // we can ignore those events. |
| for (const node of nodeList) { |
| node.notifyResize(); |
| } |
| } |
| } |
| } |
| return ScrollableMixin; |
| }); |
| |
| export interface ScrollableMixinInterface { |
| updateScrollableContents(): void; |
| requestUpdateScroll(): void; |
| saveScroll(list: IronListElement): void; |
| restoreScroll(list: IronListElement): void; |
| } |