From a3dd93194fa7f11fe165d79c131f043114bed601 Mon Sep 17 00:00:00 2001 From: Bruno Boi Date: Thu, 1 Dec 2022 15:48:24 +0000 Subject: [PATCH] [IMP] web: introduce useVirtual hook This commit introduces a hook named "useVirtual" which allows to filter a given list of objects and return only those that are visible in the current viewport. This effectively allows to use virtualization in lists. The hook will trim down the list of objects it received to those that fit in the current viewport (with a fixed margin above and under to allow smoother transitions on scroll). The following requirements must be met to use this feature: - the scrollable area has a fixed height - along with the list of items, a "getItemHeight" getter must be provided to determine the actual height of each individual item; - the items are rendered with a proper offset inside the scrollable area. This can be achieved e.g. with a css grid or an absolute positioning. Part-of: odoo/odoo#110819 Co-authored-by: Bruno Boi Co-authored-by: Julien Mougenot Co-authored-by: Mathieu Duckerts-Antoine --- addons/web/static/src/core/virtual_hook.js | 109 ++++++++++++ .../static/tests/core/virtual_hook_tests.js | 155 ++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 addons/web/static/src/core/virtual_hook.js create mode 100644 addons/web/static/tests/core/virtual_hook_tests.js diff --git a/addons/web/static/src/core/virtual_hook.js b/addons/web/static/src/core/virtual_hook.js new file mode 100644 index 00000000000..62e7a13ed2a --- /dev/null +++ b/addons/web/static/src/core/virtual_hook.js @@ -0,0 +1,109 @@ +/** @odoo-module **/ + +import { + onWillRender, + onWillStart, + toRaw, + useEffect, + useExternalListener, + useState, +} from "@odoo/owl"; +import { shallowEqual } from "@web/core/utils/arrays"; +import { throttleForAnimation } from "@web/core/utils/timing"; + +/** + * @template T + * @typedef VirtualHookParams + * @property {() => T[]} getItems + * a getter for the array of all items + * @property {(item: T) => number} getItemHeight + * a getter for the height of a given item. + * The height should be a number of pixels. + * @property {ReturnType} scrollableRef + * a ref to the scrollable element + * @property {ScrollPosition} [initialScroll={ top: 0 }] + * the initial scroll position of the scrollable element + */ + +/** + * @typedef ScrollPosition + * @property {number} top + */ + +/** + * Calculates the displayed items in a virtual list. + * + * Requirements: + * - the scrollable area has a fixed height + * - the items are rendered with a proper offset inside the scrollable area. + * This can be achieved e.g. with a css grid or an absolute positioning. + * + * @template T + * @param {VirtualHookParams} params + * @returns {ReturnType>} + */ +export function useVirtual({ getItems, scrollableRef, initialScroll, getItemHeight }) { + const computeVirtualItems = () => { + const { items, scroll } = current; + + const yStart = scroll.top - window.innerHeight; + const yEnd = scroll.top + 2 * window.innerHeight; + + let [startIndex, endIndex] = [0, 0]; + let currentTop = 0; + + for (const item of items) { + const height = getItemHeight(item); + if (currentTop + height < yStart) { + startIndex++; + endIndex++; + } else if (currentTop + height <= yEnd + height) { + endIndex++; + } else { + break; + } + currentTop += height; + } + + const prevItems = toRaw(virtualItems); + const newItems = items.slice(startIndex, endIndex); + + if (!shallowEqual(prevItems, newItems)) { + virtualItems.length = 0; + virtualItems.push(...newItems); + } + }; + + const current = { + items: getItems(), + scroll: { top: 0, ...initialScroll }, + }; + + const virtualItems = useState([]); + + onWillStart(computeVirtualItems); + onWillRender(() => { + const previousItems = current.items; + current.items = getItems(); + if (!shallowEqual(previousItems, current.items)) { + computeVirtualItems(); + } + }); + const throttledCompute = throttleForAnimation(computeVirtualItems); + const scrollListener = (/** @type {Event & { target: Element }} */ ev) => { + current.scroll.top = ev.target.scrollTop; + throttledCompute(); + }; + useExternalListener(window, "resize", throttledCompute); + useEffect( + (el) => { + if (el) { + el.addEventListener("scroll", scrollListener); + return () => el.removeEventListener("scroll", scrollListener); + } + }, + () => [scrollableRef.el] + ); + + return virtualItems; +} diff --git a/addons/web/static/tests/core/virtual_hook_tests.js b/addons/web/static/tests/core/virtual_hook_tests.js new file mode 100644 index 00000000000..c26782a134e --- /dev/null +++ b/addons/web/static/tests/core/virtual_hook_tests.js @@ -0,0 +1,155 @@ +/** @odoo-module **/ + +import { Component, useRef, xml } from "@odoo/owl"; +import { useVirtual } from "@web/core/virtual_hook"; +import { getFixture, mount, patchWithCleanup, triggerEvent } from "../helpers/utils"; + +/** + * @typedef ItemType + * @property {number} id + * + * @typedef {import("@web/core/virtual_hook").VirtualHookParams} TestComponentProps + */ + +function objectToStyle(obj) { + return Object.entries(obj) + .map(([k, v]) => `${k}: ${v};`) + .join(""); +} + +/** @type {ItemType[]} */ +const ITEMS = Array.from({ length: 200 }, (_, i) => ({ id: i + 1 })); +const ITEM_HEIGHT = 50; +const ITEM_STYLE = objectToStyle({ + height: `${ITEM_HEIGHT}px`, + width: "100%", + border: "1px solid black", + position: "absolute", + "background-color": "white", +}); +const CONTAINER_SIZE = 5 * ITEM_HEIGHT; +const CONTAINER_STYLE = objectToStyle({ + height: `${CONTAINER_SIZE}px`, + width: "100%", + overflow: "auto", + position: "relative", + "background-color": "lightblue", +}); +const MAX_SCROLL_TOP = ITEMS.length * ITEM_HEIGHT - CONTAINER_SIZE; + +/** + * @param {HTMLElement} [target] + * @param {TestComponentProps} [props] + */ +async function mountTestComponent(target, props) { + /** @extends {Component} **/ + class Item extends Component { + static props = ["id"]; + static template = xml` +
+ `; + get style() { + return `top: ${(this.props.id - 1) * ITEM_HEIGHT}px; ${ITEM_STYLE}`; + } + } + + /** @extends {Component} **/ + class TestComponent extends Component { + static props = ["getItems?", "scrollableRef?", "initialScroll?", "getItemHeight?"]; + static components = { Item }; + static template = xml` +
+
+ + + +
+
+ `; + setup() { + const scrollableRef = useRef("scrollable"); + this.items = useVirtual({ + getItems: () => ITEMS, + getItemHeight: () => ITEM_HEIGHT, + scrollableRef, + ...this.props, + }); + } + get innerStyle() { + return `height: ${ITEMS.length * ITEM_HEIGHT}px;`; + } + } + await mount(TestComponent, target, { props }); +} + +function scroll(target, scrollTop) { + target.querySelector(".scrollable").scrollTop = scrollTop; + return triggerEvent(target, ".scrollable", "scroll"); +} + +let target; +QUnit.module("useVirtual hook", { + async beforeEach() { + target = getFixture(); + // In this test suite, we trick the hook by setting the window size to the size + // of the scrollable, so that it is a measurable size and this suite can run + // in a window of any size. + patchWithCleanup(window, { innerHeight: CONTAINER_SIZE }); + }, +}); + +QUnit.test("basic usage", async (assert) => { + await mountTestComponent(target); + assert.containsN(target, ".item", 11); + assert.strictEqual(target.querySelector(".item").dataset.id, "1"); + assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "11"); + + // scroll to the middle + await scroll(target, MAX_SCROLL_TOP / 2); + assert.containsN(target, ".item", 16); + assert.strictEqual(target.querySelector(".item").dataset.id, "93"); + assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "108"); + + // scroll to the end + await scroll(target, MAX_SCROLL_TOP); + assert.containsN(target, ".item", 11); + assert.strictEqual(target.querySelector(".item").dataset.id, "190"); + assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "200"); +}); + +QUnit.test("updates on resize", async (assert) => { + await mountTestComponent(target); + assert.containsN(target, ".item", 11); + assert.strictEqual(target.querySelector(".item").dataset.id, "1"); + assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "11"); + + // resize the window + patchWithCleanup(window, { innerHeight: CONTAINER_SIZE / 2 }); + await triggerEvent(window, null, "resize"); + assert.containsN(target, ".item", 6); + assert.strictEqual(target.querySelector(".item").dataset.id, "1"); + assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "6"); + + // resize the window + patchWithCleanup(window, { innerHeight: CONTAINER_SIZE * 2 }); + await triggerEvent(window, null, "resize"); + assert.containsN(target, ".item", 21); + assert.strictEqual(target.querySelector(".item").dataset.id, "1"); + assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "21"); +}); + +QUnit.test("initialScroll: middle", async (assert) => { + const initialScroll = { top: MAX_SCROLL_TOP / 2 }; + await mountTestComponent(target, { initialScroll }); + assert.containsN(target, ".item", 16); + assert.strictEqual(target.querySelector(".item").dataset.id, "93"); + assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "108"); +}); + +QUnit.test("initialScroll: bottom", async (assert) => { + const initialScroll = { top: MAX_SCROLL_TOP }; + await mountTestComponent(target, { initialScroll }); + assert.containsN(target, ".item", 11); + assert.strictEqual(target.querySelector(".item").dataset.id, "190"); + assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "200"); +});