[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 <boi@odoo.com>
Co-authored-by: Julien Mougenot <jum@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
This commit is contained in:
Bruno Boi
2023-02-14 17:09:04 +01:00
co-authored by Julien Mougenot Mathieu Duckerts-Antoine
parent 48bc5315da
commit a3dd93194f
2 changed files with 264 additions and 0 deletions
+109
View File
@@ -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<typeof import("@odoo/owl").useRef>} 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<T>} params
* @returns {ReturnType<useState<T>>}
*/
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;
}
@@ -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<ItemType>} 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<ItemType, any>} **/
class Item extends Component {
static props = ["id"];
static template = xml`
<div class="item" t-att-data-id="props.id" t-att-style="style" t-esc="props.id"/>
`;
get style() {
return `top: ${(this.props.id - 1) * ITEM_HEIGHT}px; ${ITEM_STYLE}`;
}
}
/** @extends {Component<TestComponentProps, any>} **/
class TestComponent extends Component {
static props = ["getItems?", "scrollableRef?", "initialScroll?", "getItemHeight?"];
static components = { Item };
static template = xml`
<div class="scrollable" t-ref="scrollable" style="${CONTAINER_STYLE}">
<div class="inner" t-att-style="innerStyle">
<t t-foreach="items" t-as="item" t-key="item.id">
<Item id="item.id"/>
</t>
</div>
</div>
`;
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");
});