[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:
+1
-3
@@ -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(
|
||||
|
||||
@@ -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,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 }}
|
||||
|
||||
Reference in New Issue
Block a user