From 4b79bbff82a4d0ee65028c1558ad662b661b29a6 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Thu, 19 Oct 2023 10:39:37 +0200 Subject: [PATCH] [MOV] web: move ResizablePanel component to web This component has been introduced in web_studio. We have now a similar usecase in website, for the AceEditor component. We thus move the component definition to web. Part-of: odoo/odoo#139154 --- .../core/resizable_panel/resizable_panel.js | 162 ++++++++++++++++++ .../core/resizable_panel/resizable_panel.scss | 10 ++ .../core/resizable_panel/resizable_panel.xml | 14 ++ .../tests/core/resizable_panel_tests.js | 162 ++++++++++++++++++ 4 files changed, 348 insertions(+) create mode 100644 addons/web/static/src/core/resizable_panel/resizable_panel.js create mode 100644 addons/web/static/src/core/resizable_panel/resizable_panel.scss create mode 100644 addons/web/static/src/core/resizable_panel/resizable_panel.xml create mode 100644 addons/web/static/tests/core/resizable_panel_tests.js diff --git a/addons/web/static/src/core/resizable_panel/resizable_panel.js b/addons/web/static/src/core/resizable_panel/resizable_panel.js new file mode 100644 index 00000000000..1d249d3c4da --- /dev/null +++ b/addons/web/static/src/core/resizable_panel/resizable_panel.js @@ -0,0 +1,162 @@ +/** @odoo-module */ + +import { + Component, + onMounted, + onWillUpdateProps, + onWillUnmount, + useEffect, + useExternalListener, + useRef, + useComponent, +} from "@odoo/owl"; + +function useResizable({ + containerRef, + handleRef, + getMinWidth = () => 400, + onResize = () => {}, + getResizeSide = () => "end", +}) { + containerRef = typeof containerRef == "string" ? useRef(containerRef) : containerRef; + handleRef = typeof handleRef == "string" ? useRef(handleRef) : handleRef; + const props = useComponent().props; + + let minWidth = getMinWidth(props); + let resizeSide = getResizeSide(props); + let isChangingSize = false; + + useExternalListener(document, "mouseup", () => onMouseUp()); + useExternalListener(document, "mousemove", (ev) => onMouseMove(ev)); + + useExternalListener(window, "resize", () => { + const limit = getLimitWidth(); + if (getContainerRect().width >= limit) { + resize(computeFinalWidth(limit)); + } + }); + + let docDirection; + useEffect( + (container) => { + if (container) { + docDirection = getComputedStyle(container).direction; + } + }, + () => [containerRef.el] + ); + + onMounted(() => { + if (handleRef.el) { + resize(computeFinalWidth(minWidth)); + handleRef.el.addEventListener("mousedown", onMouseDown); + } + }); + + onWillUpdateProps((nextProps) => { + minWidth = getMinWidth(nextProps); + resizeSide = getResizeSide(nextProps); + }); + + onWillUnmount(() => { + if (handleRef.el) { + handleRef.el.removeEventListener("mousedown", onMouseDown); + } + }); + + function onMouseDown() { + isChangingSize = true; + document.body.classList.add("pe-none", "user-select-none"); + } + + function onMouseUp() { + isChangingSize = false; + document.body.classList.remove("pe-none", "user-select-none"); + } + + function onMouseMove(ev) { + if (!isChangingSize || !containerRef.el) { + return; + } + const direction = + (docDirection === "ltr" && resizeSide === "end") || + (docDirection === "rtl" && resizeSide === "start") + ? 1 + : -1; + const fixedSide = direction === 1 ? "left" : "right"; + const containerRect = getContainerRect(); + const newWidth = (ev.clientX - containerRect[fixedSide]) * direction; + resize(computeFinalWidth(newWidth)); + } + + function computeFinalWidth(targetContainerWidth) { + const handlerSpacing = handleRef.el ? handleRef.el.offsetWidth / 2 : 10; + const w = Math.max(minWidth, targetContainerWidth + handlerSpacing); + const limit = getLimitWidth(); + return Math.min(w, limit - handlerSpacing); + } + + function getContainerRect() { + const container = containerRef.el; + const offsetParent = container.offsetParent; + let containerRect = {}; + if (!offsetParent) { + containerRect = container.getBoundingClientRect(); + } else { + containerRect.left = container.offsetLeft; + containerRect.right = container.offsetLeft + container.offsetWidth; + containerRect.width = container.offsetWidth; + } + return containerRect; + } + + function getLimitWidth() { + const offsetParent = containerRef.el.offsetParent; + return offsetParent ? offsetParent.offsetWidth : window.innerWidth; + } + + function resize(width) { + containerRef.el.style.setProperty("width", `${width}px`); + onResize(width); + } +} + +export class ResizablePanel extends Component { + static template = "web_studio.ResizablePanel"; + + static components = {}; + static props = { + onResize: { type: Function, optional: true }, + minWidth: { type: Number, optional: true }, + class: { type: String, optional: true }, + slots: { type: Object }, + handleSide: { + validate: (val) => ["start", "end"].includes(val), + optional: true, + }, + }; + static defaultProps = { + onResize: () => {}, + minWidth: 400, + class: "", + handleSide: "end", + }; + + setup() { + useResizable({ + containerRef: "containerRef", + handleRef: "handleRef", + onResize: this.props.onResize, + getMinWidth: (props) => props.minWidth, + getResizeSide: (props) => props.handleSide, + }); + } + + get class() { + const classes = this.props.class.split(" "); + if (!classes.some((cls) => cls.startsWith("position-"))) { + classes.push("position-relative"); + } + return classes.join(" "); + } +} diff --git a/addons/web/static/src/core/resizable_panel/resizable_panel.scss b/addons/web/static/src/core/resizable_panel/resizable_panel.scss new file mode 100644 index 00000000000..4a58b71d6ae --- /dev/null +++ b/addons/web/static/src/core/resizable_panel/resizable_panel.scss @@ -0,0 +1,10 @@ +.o_resizable_panel { + max-width: 100vw; + flex-grow: 0; +} + +.o_resizable_panel_handle { + cursor: col-resize; + z-index: 10; + width: 5px; +} diff --git a/addons/web/static/src/core/resizable_panel/resizable_panel.xml b/addons/web/static/src/core/resizable_panel/resizable_panel.xml new file mode 100644 index 00000000000..43ebf4ea962 --- /dev/null +++ b/addons/web/static/src/core/resizable_panel/resizable_panel.xml @@ -0,0 +1,14 @@ + + + + +
+ +
+
+
+ +
diff --git a/addons/web/static/tests/core/resizable_panel_tests.js b/addons/web/static/tests/core/resizable_panel_tests.js new file mode 100644 index 00000000000..cc3c7318a39 --- /dev/null +++ b/addons/web/static/tests/core/resizable_panel_tests.js @@ -0,0 +1,162 @@ +/** @odoo-module **/ + +import { ResizablePanel } from "@web_studio/client_action/xml_resource_editor/resizable_panel/resizable_panel"; +import { Component, reactive, xml } from "@odoo/owl"; +import { browser } from "@web/core/browser/browser"; +import { makeTestEnv } from "@web/../tests/helpers/mock_env"; +import { + getFixture, + patchWithCleanup, + mount, + nextTick, + triggerEvents, +} from "@web/../tests/helpers/utils"; + +QUnit.module("Resizable Panel", ({ beforeEach }) => { + let env; + let target; + + beforeEach(async () => { + env = await makeTestEnv(); + target = getFixture(); + patchWithCleanup(browser, { + setTimeout: (fn) => Promise.resolve().then(fn), + }); + }); + + QUnit.test("Width cannot exceed viewport width", async (assert) => { + class Parent extends Component { + static components = { ResizablePanel }; + static template = xml` + +

A

+

Cool

+

Paragraph

+
+ `; + } + + await mount(Parent, target, { env }); + assert.containsOnce(target, ".o_resizable_panel"); + assert.containsOnce(target, ".o_resizable_panel_handle"); + + const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0); + const sidepanel = target.querySelector(".o_resizable_panel"); + sidepanel.style.width = `${vw + 100}px`; + + const sidepanelWidth = sidepanel.getBoundingClientRect().width; + assert.ok( + sidepanelWidth <= vw && sidepanelWidth > vw * 0.95, + "The sidepanel should be smaller or equal to the view width" + ); + }); + + QUnit.test("handles right-to-left", async (assert) => { + class Parent extends Component { + static components = { ResizablePanel }; + static template = xml` +
+
+ +
+ A cool paragraph +
+
+
+ `; + } + await mount(Parent, target, { env }); + const parentEl = target.querySelector(".parent-el"); + const resizablePanelEl = target.querySelector(".o_resizable_panel"); + let resizablePabelRect = resizablePanelEl.getBoundingClientRect(); + assert.strictEqual(resizablePabelRect.width, 22.5); + + const handle = resizablePanelEl.querySelector(".o_resizable_panel_handle"); + await triggerEvents(handle, null, ["mousedown", ["mousemove", { clientX: 10 }], "mouseup"]); + resizablePabelRect = resizablePanelEl.getBoundingClientRect(); + assert.ok(resizablePabelRect.width > parentEl.offsetWidth - 10 - 50); + }); + + QUnit.test("handles resize handle at start in fixed position", async (assert) => { + class Parent extends Component { + static components = { ResizablePanel }; + static template = xml` +
+ +
+ A cool paragraph +
+
+
+ `; + } + await mount(Parent, target, { env }); + const resizablePanelEl = target.querySelector(".o_resizable_panel"); + resizablePanelEl.style.setProperty("right", "100px"); + let resizablePabelRect = resizablePanelEl.getBoundingClientRect(); + assert.strictEqual(resizablePabelRect.width, 22.5); + + const handle = resizablePanelEl.querySelector(".o_resizable_panel_handle"); + await triggerEvents(handle, null, [ + "mousedown", + ["mousemove", { clientX: window.innerWidth - 200 }], + "mouseup", + ]); + resizablePabelRect = resizablePanelEl.getBoundingClientRect(); + assert.strictEqual(resizablePabelRect.width, 100 + handle.offsetWidth / 2); + }); + + QUnit.test("resizing the window adapts the panel", async (assert) => { + class Parent extends Component { + static components = { ResizablePanel }; + static template = xml` +
+ +

A

+

Cool

+

Paragraph

+
+
+ `; + } + await mount(Parent, target, { env }); + const resizablePanelEl = target.querySelector(".o_resizable_panel"); + const handle = resizablePanelEl.querySelector(".o_resizable_panel_handle"); + await triggerEvents(handle, null, [ + "mousedown", + ["mousemove", { clientX: 99999 }], + "mouseup", + ]); + assert.strictEqual(resizablePanelEl.offsetWidth, 398); + target.querySelector(".parent-el").style.setProperty("width", "200px"); + window.dispatchEvent(new Event("resize")); + await nextTick(); + assert.strictEqual(resizablePanelEl.offsetWidth, 198); + }); + + QUnit.test("minWidth props can be updated", async (assert) => { + class Parent extends Component { + static components = { ResizablePanel }; + static template = xml` +
+ +
+ A cool paragraph +
+
+
+ `; + } + const state = reactive({ minWidth: 20 }); + await mount(Parent, target, { env, props: { state } }); + const resizablePanelEl = target.querySelector(".o_resizable_panel"); + const handle = resizablePanelEl.querySelector(".o_resizable_panel_handle"); + await triggerEvents(handle, null, ["mousedown", ["mousemove", { clientX: 15 }], "mouseup"]); + + assert.strictEqual(resizablePanelEl.getBoundingClientRect().width, 20); + state.minWidth = 40; + await nextTick(); + await triggerEvents(handle, null, ["mousedown", ["mousemove", { clientX: 15 }], "mouseup"]); + assert.strictEqual(resizablePanelEl.getBoundingClientRect().width, 40); + }); +});