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"); +});