[IMP] web: improve usePosition API

**Before this commit**
The usePosition hook takes two arguments:
- the target element (could be a function returning an element)
- the positioning options, which are optional.

This API is a bit weird for these reasons:
- the target argument's type is variable
- the "popper" option is not very meaningful and has a
  default value of "popper", which is not clearly stated

**After this commit**
The usePosition hook now takes three different arguments:
- first is "refName", which is the reference to the element to position
  in the template
- then is "getTarget", a callback that must return the target element,
- finally the "options"

Part-of: odoo/odoo#135188
This commit is contained in:
Bruno Boi
2023-10-13 21:11:52 +00:00
parent f610fb1d31
commit b449ae6036
12 changed files with 109 additions and 122 deletions
@@ -46,9 +46,7 @@ export class AnalyticDistribution extends Component {
this.widgetRef = useRef("analyticDistribution");
this.dropdownRef = useRef("analyticDropdown");
this.mainRef = useRef("mainElement");
usePosition(() => this.widgetRef.el, {
popper: "analyticDropdown",
});
usePosition("analyticDropdown", () => this.widgetRef.el);
this.nextId = 1;
this.focusSelector = false;
@@ -22,8 +22,8 @@ export class Many2ManyAttendeeExpandable extends Many2ManyAttendee {
useEffect(() => {
const popover = document.querySelector(".o_field_many2manyattendeeexpandable")
.closest(".o_popover");
const targetElement = document.querySelector(`.fc-event[data-event-id="${this.props.record.resId}"]`);
reposition(targetElement, popover, null, { position: "right", margin: 0 });
const target = document.querySelector(`.fc-event[data-event-id="${this.props.record.resId}"]`);
reposition(popover, target, { position: "right", margin: 0 });
}, () => [ this.state.expanded ]);
}
}
@@ -48,10 +48,7 @@ export class NavigableList extends Component {
this.close();
});
// position and size
usePosition(() => this.props.anchorRef, {
popper: "root",
position: this.props.position,
});
usePosition("root", () => this.props.anchorRef, { position: this.props.position });
useEffect(
() => {
this.open();
@@ -64,8 +64,7 @@ export class AutoComplete extends Component {
// position and size
if (this.props.dropdown) {
usePosition(() => this.inputRef.el, {
popper: "sourcesList",
usePosition("sourcesList", () => this.inputRef.el, {
position: "bottom-start",
});
} else {
@@ -102,7 +102,6 @@ export class Dropdown extends Component {
}
this.defaultDirection = direction;
const positioningOptions = {
popper: "menuRef",
position,
onPositioned: (el, { direction }) => {
this.state.directionCaretClass = DIRECTION_CARET_CLASS[direction];
@@ -149,11 +148,19 @@ export class Dropdown extends Component {
);
// Position menu relatively to parent element
this.position = usePosition(() => this.rootRef.el.parentElement, positioningOptions);
this.position = usePosition(
"menuRef",
() => this.rootRef.el.parentElement,
positioningOptions
);
} else {
// Position menu relatively to inner toggler
const togglerRef = useRef("togglerRef");
this.position = usePosition(() => togglerRef.el, positioningOptions);
this.position = usePosition(
"menuRef",
() => togglerRef.el,
positioningOptions
);
}
useEffect(
+14 -12
View File
@@ -7,18 +7,20 @@ import { usePosition } from "@web/core/position_hook";
export class Popover extends Component {
setup() {
useForwardRefToParent("ref");
const position = usePosition(this.props.target, {
onPositioned: (el, solution) => {
(this.props.onPositioned || this.onPositioned.bind(this))(el, solution);
if (this.props.fixedPosition) {
// Prevent further positioning updates if fixed position is wanted
position.lock();
}
},
position: this.props.position,
popper: "ref",
fixedPosition: this.props.fixedPosition,
});
const position = usePosition(
"ref",
() => this.props.target,
{
onPositioned: (el, solution) => {
(this.props.onPositioned || this.onPositioned.bind(this))(el, solution);
if (this.props.fixedPosition) {
// Prevent further positioning updates if fixed position is wanted
position.lock();
}
},
position: this.props.position,
}
);
}
onPositioned(el, { direction, variant }) {
const position = `${direction[0]}${variant[0]}`;
+23 -29
View File
@@ -23,7 +23,6 @@ import { localization } from "@web/core/l10n/localization";
/**
* @typedef Options
* @property {string} [popper="popper"] useRef reference to the popper element
* @property {HTMLElement} [container] container element
* @property {number} [margin=0]
* margin in pixels between the popper and the target.
@@ -82,7 +81,6 @@ const FIT_FLIP_ORDER = { top: "tb", right: "rl", bottom: "bt", left: "lr" };
/** @type {Options} */
const DEFAULTS = {
popper: "popper",
margin: 0,
position: "bottom",
};
@@ -110,15 +108,15 @@ function getIFrame(el) {
* The popper will stay at `margin` distance from its target. One could also
* use the CSS margins of the popper element to achieve the same result.
*
* @param {HTMLElement} target
* @param {HTMLElement} popper
* @param {HTMLIFrameElement?} [iframe]
* @param {HTMLElement} target
* @param {Options} options
* @param {HTMLIFrameElement} [iframe]
* @returns {PositioningSolution} the best positioning solution, relative to
* the containing block of the popper.
* => can be applied to popper.style.(top|left)
*/
function getBestPosition(target, popper, iframe, { container, margin, position }) {
function getBestPosition(popper, target, { container, margin, position }, iframe) {
// Retrieve directions and variants
const [directionKey, variantKey = "middle"] = position.split("-");
const directions =
@@ -252,15 +250,13 @@ function getBestPosition(target, popper, iframe, { container, margin, position }
* tried in different direction and variant flip orders (depending on the requested position).
* If no position is found that fits the container, the requested position stays used.
*
* When the final position is applied, a corresponding CSS class is also added to the popper.
* This could be used to further styling.
*
* @param {HTMLElement} target
* @deprecated too low level, will soon not be exported anymore, use usePosition instead
* @param {HTMLElement} popper
* @param {HTMLIFrameElement} [iframe]
* @param {HTMLElement} target
* @param {Options} options
* @param {HTMLIFrameElement} [iframe]
*/
export function reposition(target, popper, iframe, options) {
export function reposition(popper, target, options, iframe) {
let [directionKey, variantKey = "middle"] = options.position.split("-");
if (localization.direction === "rtl") {
if (["bottom", "top"].includes(directionKey)) {
@@ -279,19 +275,17 @@ export function reposition(target, popper, iframe, options) {
popper.style.left = "0px";
// Get best positioning solution and apply it
const position = getBestPosition(target, popper, iframe, options);
const { top, left, variant } = position;
const position = getBestPosition(popper, target, options, iframe);
const { top, left, direction, variant } = position;
popper.style.top = `${top}px`;
popper.style.left = `${left}px`;
if (variant === "fit") {
const styleProperty = ["top", "bottom"].includes(directionKey) ? "width" : "height";
const styleProperty = ["top", "bottom"].includes(direction) ? "width" : "height";
popper.style[styleProperty] = target.getBoundingClientRect()[styleProperty] + "px";
}
if (options.onPositioned) {
options.onPositioned(popper, position);
}
options.onPositioned?.(popper, position);
}
const POSITION_BUS = Symbol("position-bus");
@@ -304,29 +298,29 @@ const POSITION_BUS = Symbol("position-bus");
* If all of fallback positions are also clipped off `container`,
* the original position is used.
*
* Note: The popper element should be indicated in your template with a t-ref reference.
* This could be customized with the `popper` option.
* Note: The popper element should be indicated in your template
* with a t-ref reference matching the refName argument.
*
* @param {HTMLElement | (() => HTMLElement)} target
* @param {Options} options
* @param {string} refName
* name of the reference to the popper element in the template.
* @param {() => HTMLElement} getTarget
* @param {Options} [options={}] the options to be used for positioning
* @returns {PositioningControl}
* control object to lock/unlock the positioning.
*/
export function usePosition(target, options) {
const popperRef = useRef(options?.popper || DEFAULTS.popper);
const getTarget = typeof target === "function" ? target : () => target;
export function usePosition(refName, getTarget, options = {}) {
const ref = useRef(refName);
let lock = false;
const update = () => {
const targetEl = getTarget();
const popperEl = popperRef.el;
if (!targetEl || !popperEl || lock) {
if (!ref.el || !targetEl || lock) {
// No compute needed
return;
}
// Prepare
const iframe = getIFrame(targetEl);
const currentOptions = { ...DEFAULTS, ...options };
reposition(targetEl, popperEl, iframe, currentOptions);
reposition(ref.el, targetEl, { ...DEFAULTS, ...options }, iframe);
};
const component = useComponent();
@@ -347,7 +341,7 @@ export function usePosition(target, options) {
if (isTopmost) {
// Attach listeners to keep the positioning up to date
const scrollListener = (e) => {
if (popperRef.el?.contains(e.target)) {
if (ref.el?.contains(e.target)) {
// In case the scroll event occurs inside the popper, do not reposition
return;
}
@@ -748,11 +748,11 @@ export class PropertiesField extends Component {
const popover = document
.querySelector(".o_field_property_definition")
.closest(".o_popover");
const targetElement = document.querySelector(
const target = document.querySelector(
`*[property-name="${propertyName}"] .o_field_property_open_popover`
);
reposition(targetElement, popover, null, { position: "top", margin: 10 });
reposition(popover, target, { position: "top", margin: 10 });
const arrow = popover.querySelector(".popover-arrow");
if (arrow) {
@@ -178,12 +178,15 @@ QUnit.test("reposition popover should properly change classNames", async (assert
const TestPopover = class extends Popover {
setup() {
// Don't call super.setup() in order to replace the use of usePosition hook...
usePosition(this.props.target, {
container,
onPositioned: this.onPositioned.bind(this),
position: this.props.position,
popper: "ref",
});
usePosition(
"ref",
() => this.props.target,
{
container,
onPositioned: this.onPositioned.bind(this),
position: this.props.position,
}
);
}
};
@@ -279,7 +282,7 @@ QUnit.test("popover fixed position", async (assert) => {
fixture.appendChild(container);
const TestPopover = class extends Popover {
onPositioned(el, { direction, variant }) {
onPositioned() {
assert.step("onPositioned");
}
};
@@ -51,7 +51,7 @@ function getTestComponent(popperOptions = {}, target = document.createElement("d
class TestComp extends Component {
setup() {
usePosition(target, popperOptions);
usePosition("popper", () => target, popperOptions);
}
}
TestComp.template = xml`<div id="popper" t-ref="popper" />`;
@@ -294,23 +294,6 @@ QUnit.test("popper is an inner element", async (assert) => {
await mount(TestComp, container);
});
QUnit.test("can change the popper target name", async (assert) => {
assert.expect(2);
const TestComp = getTestComponent({
popper: "myRef",
onPositioned: (el) => {
assert.notOk(document.getElementById("not-popper") === el);
assert.ok(document.getElementById("popper") === el);
},
});
TestComp.template = xml`
<div id="not-popper">
<div id="popper" t-ref="myRef"/>
</div>
`;
await mount(TestComp, container);
});
QUnit.test("has no effect when component is destroyed", async (assert) => {
mockAnimationFrame();
const TestComp = getTestComponent({
@@ -582,7 +565,7 @@ QUnit.test("popper as child of another", async (assert) => {
`;
setup() {
const ref = useRef("ref");
usePosition(() => ref.el, { position: "left" });
usePosition("popper", () => ref.el, { position: "left" });
}
}
const target = document.createElement("div");
@@ -591,9 +574,9 @@ QUnit.test("popper as child of another", async (assert) => {
container.appendChild(target);
class Parent extends Component {
static components = { Child };
static template = /* xml */ xml`<div id="popper"><Child/></div>`;
static template = /* xml */ xml`<div id="popper" t-ref="popper"><Child/></div>`;
setup() {
usePosition(target);
usePosition("popper", () => target);
}
}
@@ -48,7 +48,7 @@ export class TourPointer extends Component {
static height = 28; // in pixels
setup() {
const rootRef = useRef("popper");
const rootRef = useRef("pointer");
/** @type {DOMREct | null} */
let dimensions = null;
let lastMeasuredContent = null;
@@ -58,8 +58,8 @@ export class TourPointer extends Component {
useEffect(
() => {
const { el } = rootRef;
if (el) {
const { el: pointer } = rootRef;
if (pointer) {
const hasContentChanged = lastMeasuredContent !== this.content;
const hasOpenStateChanged = lastOpenState !== this.isOpen;
lastOpenState = this.isOpen;
@@ -67,9 +67,9 @@ export class TourPointer extends Component {
// Content changed: we must re-measure the dimensions of the text.
if (hasContentChanged) {
lastMeasuredContent = this.content;
el.style.removeProperty("width");
el.style.removeProperty("height");
dimensions = el.getBoundingClientRect();
pointer.style.removeProperty("width");
pointer.style.removeProperty("height");
dimensions = pointer.getBoundingClientRect();
}
// If the content or the "is open" state changed: we must apply
@@ -79,13 +79,13 @@ export class TourPointer extends Component {
? [dimensions.width, dimensions.height]
: [this.constructor.width, this.constructor.height];
if (this.isOpen) {
el.style.removeProperty("transition");
pointer.style.removeProperty("transition");
} else {
// No transition if switching from open to closed
el.style.setProperty("transition", "none");
pointer.style.setProperty("transition", "none");
}
el.style.setProperty("width", `${width}px`);
el.style.setProperty("height", `${height}px`);
pointer.style.setProperty("width", `${width}px`);
pointer.style.setProperty("height", `${height}px`);
}
if (!this.isOpen) {
@@ -103,34 +103,38 @@ export class TourPointer extends Component {
}
const wouldOverflow =
window.innerWidth - x - width / 2 < dimensions?.width;
el.classList.toggle("o_expand_left", wouldOverflow);
pointer.classList.toggle("o_expand_left", wouldOverflow);
}
lastAnchor = anchor;
el.style.bottom = "";
el.style.right = "";
reposition(anchor, el, null, {
position: this.position,
margin: 6,
onPositioned: (popper, position) => {
const popperRect = popper.getBoundingClientRect();
const { top, left, direction } = position;
if (direction === "top") {
// position from the bottom instead of the top as it is needed
// to ensure the expand animation is properly done
popper.style.bottom = `${
window.innerHeight - top - popperRect.height
}px`;
popper.style.removeProperty("top");
} else if (direction === "left") {
// position from the right instead of the left as it is needed
// to ensure the expand animation is properly done
popper.style.right = `${
window.innerWidth - left - popperRect.width
}px`;
popper.style.removeProperty("left");
}
},
});
pointer.style.bottom = "";
pointer.style.right = "";
reposition(
pointer,
anchor,
{
position: this.position,
margin: 6,
onPositioned: (_, position) => {
const popperRect = pointer.getBoundingClientRect();
const { top, left, direction } = position;
if (direction === "top") {
// position from the bottom instead of the top as it is needed
// to ensure the expand animation is properly done
pointer.style.bottom = `${
window.innerHeight - top - popperRect.height
}px`;
pointer.style.removeProperty("top");
} else if (direction === "left") {
// position from the right instead of the left as it is needed
// to ensure the expand animation is properly done
pointer.style.right = `${
window.innerWidth - left - popperRect.width
}px`;
pointer.style.removeProperty("left");
}
},
}
);
}
} else {
lastMeasuredContent = null;
@@ -3,7 +3,7 @@
<t t-name="web_tour.TourPointer">
<div
t-if="props.pointerState.isVisible"
t-ref="popper"
t-ref="pointer"
t-attf-class="
o_tour_pointer
o_{{ position }}