mirror of
https://github.com/odoo/owl.git
synced 2025-10-06 19:59:41 +07:00
[IMP] portal: add support for .closest modifier
It is sometimes useful in practice to be able to configure the portal so that it looks for a target as close as possible as the portal location. For example, in odoo, a portal may be set up in a form view that should target the current form view. But if the form view itself is in a dialog, it may fail, because it may find a valid target in the form view that is located underneath. With this commit, we add a .closest modifier to the `t-portal` directive.
This commit is contained in:
@@ -15,3 +15,11 @@ class SomeComponent extends Component {
|
|||||||
The `t-portal` directive takes a valid css selector as argument. The content of
|
The `t-portal` directive takes a valid css selector as argument. The content of
|
||||||
the portalled template will be mounted at the corresponding location. Note that
|
the portalled template will be mounted at the corresponding location. Note that
|
||||||
Owl need to insert an empty text node at the location of the portalled content.
|
Owl need to insert an empty text node at the location of the portalled content.
|
||||||
|
|
||||||
|
The `t-portal` directive supports a `.closest` modifier. It is useful to select
|
||||||
|
the closest target from the portal location: Owl will look for a target in the
|
||||||
|
current parent element, then in its parent, and so on until it finds it.
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<div t-portal.closest="'.target'">some content</div>
|
||||||
|
```
|
||||||
|
|||||||
@@ -1371,7 +1371,9 @@ export class CodeGenerator {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const target = compileExpr(ast.target);
|
const target = compileExpr(ast.target);
|
||||||
const blockString = `${id}({target: ${target},slots: {'default': {__render: ${name}.bind(this), __ctx: ${ctxStr}}}}, key + \`${key}\`, node, ctx, Portal)`;
|
const blockString = `${id}({target: ${target},${
|
||||||
|
ast.isClosest ? "isClosest: true," : ""
|
||||||
|
}slots: {'default': {__render: ${name}.bind(this), __ctx: ${ctxStr}}}}, key + \`${key}\`, node, ctx, Portal)`;
|
||||||
if (block) {
|
if (block) {
|
||||||
this.insertAnchor(block);
|
this.insertAnchor(block);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-3
@@ -169,6 +169,7 @@ export interface ASTTranslation {
|
|||||||
export interface ASTTPortal {
|
export interface ASTTPortal {
|
||||||
type: ASTType.TPortal;
|
type: ASTType.TPortal;
|
||||||
target: string;
|
target: string;
|
||||||
|
isClosest: boolean;
|
||||||
content: AST;
|
content: AST;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,11 +834,18 @@ function parseTTranslation(node: Element, ctx: ParsingContext): AST | null {
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
function parseTPortal(node: Element, ctx: ParsingContext): AST | null {
|
function parseTPortal(node: Element, ctx: ParsingContext): AST | null {
|
||||||
if (!node.hasAttribute("t-portal")) {
|
let target, isClosest;
|
||||||
|
if (node.hasAttribute("t-portal")) {
|
||||||
|
target = node.getAttribute("t-portal")!;
|
||||||
|
node.removeAttribute("t-portal");
|
||||||
|
isClosest = false;
|
||||||
|
} else if (node.hasAttribute("t-portal.closest")) {
|
||||||
|
target = node.getAttribute("t-portal.closest")!;
|
||||||
|
node.removeAttribute("t-portal.closest");
|
||||||
|
isClosest = true;
|
||||||
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const target = node.getAttribute("t-portal")!;
|
|
||||||
node.removeAttribute("t-portal");
|
|
||||||
const content = parseNode(node, ctx);
|
const content = parseNode(node, ctx);
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return {
|
return {
|
||||||
@@ -848,6 +856,7 @@ function parseTPortal(node: Element, ctx: ParsingContext): AST | null {
|
|||||||
return {
|
return {
|
||||||
type: ASTType.TPortal,
|
type: ASTType.TPortal,
|
||||||
target,
|
target,
|
||||||
|
isClosest,
|
||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-7
@@ -5,20 +5,34 @@ import { OwlError } from "./error_handling";
|
|||||||
|
|
||||||
const VText: any = text("").constructor;
|
const VText: any = text("").constructor;
|
||||||
|
|
||||||
|
function getTarget(
|
||||||
|
currentParentEl: HTMLElement | Document,
|
||||||
|
selector: string,
|
||||||
|
isClosest: boolean
|
||||||
|
): HTMLElement | null {
|
||||||
|
if (!isClosest || currentParentEl === document) {
|
||||||
|
return document.querySelector(selector);
|
||||||
|
}
|
||||||
|
const attempt = currentParentEl.querySelector(selector) as HTMLElement | null;
|
||||||
|
return attempt || getTarget(currentParentEl.parentElement!, selector, true);
|
||||||
|
}
|
||||||
|
|
||||||
class VPortal extends VText implements Partial<VNode<VPortal>> {
|
class VPortal extends VText implements Partial<VNode<VPortal>> {
|
||||||
content: BDom | null;
|
content: BDom | null;
|
||||||
selector: string;
|
selector: string;
|
||||||
|
isClosest: boolean;
|
||||||
target: HTMLElement | null = null;
|
target: HTMLElement | null = null;
|
||||||
|
|
||||||
constructor(selector: string, content: BDom) {
|
constructor(selector: string, isClosest: boolean, content: BDom) {
|
||||||
super("");
|
super("");
|
||||||
this.selector = selector;
|
this.selector = selector;
|
||||||
|
this.isClosest = isClosest;
|
||||||
this.content = content;
|
this.content = content;
|
||||||
}
|
}
|
||||||
|
|
||||||
mount(parent: HTMLElement, anchor: ChildNode) {
|
mount(parent: HTMLElement, anchor: ChildNode) {
|
||||||
super.mount(parent, anchor);
|
super.mount(parent, anchor);
|
||||||
this.target = document.querySelector(this.selector) as any;
|
this.target = getTarget(parent, this.selector, this.isClosest);
|
||||||
if (this.target) {
|
if (this.target) {
|
||||||
this.content!.mount(this.target!, null);
|
this.content!.mount(this.target!, null);
|
||||||
} else {
|
} else {
|
||||||
@@ -54,16 +68,19 @@ class VPortal extends VText implements Partial<VNode<VPortal>> {
|
|||||||
export function portalTemplate(app: any, bdom: any, helpers: any) {
|
export function portalTemplate(app: any, bdom: any, helpers: any) {
|
||||||
let { callSlot } = helpers;
|
let { callSlot } = helpers;
|
||||||
return function template(ctx: any, node: any, key = ""): any {
|
return function template(ctx: any, node: any, key = ""): any {
|
||||||
return new VPortal(ctx.props.target, callSlot(ctx, node, key, "default", false, null));
|
return new VPortal(
|
||||||
|
ctx.props.target,
|
||||||
|
ctx.props.isClosest,
|
||||||
|
callSlot(ctx, node, key, "default", false, null)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Portal extends Component {
|
export class Portal extends Component {
|
||||||
static template = "__portal__";
|
static template = "__portal__";
|
||||||
static props = {
|
static props = {
|
||||||
target: {
|
target: String,
|
||||||
type: String,
|
isClosest: { type: Boolean, optional: true },
|
||||||
},
|
|
||||||
slots: true,
|
slots: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,7 +90,7 @@ export class Portal extends Component {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const portal: VPortal = node.bdom;
|
const portal: VPortal = node.bdom;
|
||||||
if (!portal.target) {
|
if (!portal.target) {
|
||||||
const target: HTMLElement = document.querySelector(this.props.target);
|
const target = getTarget(portal.parentEl, this.props.target, this.props.isClosest);
|
||||||
if (target) {
|
if (target) {
|
||||||
portal.content!.moveBeforeDOMNode(target.firstChild, target);
|
portal.content!.moveBeforeDOMNode(target.firstChild, target);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1998,6 +1998,7 @@ describe("qweb parser", () => {
|
|||||||
test("t-portal", async () => {
|
test("t-portal", async () => {
|
||||||
expect(parse(`<t t-portal="target">Content</t>`)).toEqual({
|
expect(parse(`<t t-portal="target">Content</t>`)).toEqual({
|
||||||
type: ASTType.TPortal,
|
type: ASTType.TPortal,
|
||||||
|
isClosest: false,
|
||||||
target: "target",
|
target: "target",
|
||||||
content: { type: ASTType.Text, value: "Content" },
|
content: { type: ASTType.Text, value: "Content" },
|
||||||
});
|
});
|
||||||
@@ -2008,6 +2009,7 @@ describe("qweb parser", () => {
|
|||||||
condition: "condition",
|
condition: "condition",
|
||||||
content: {
|
content: {
|
||||||
content: { type: ASTType.Text, value: "Content" },
|
content: { type: ASTType.Text, value: "Content" },
|
||||||
|
isClosest: false,
|
||||||
target: "target",
|
target: "target",
|
||||||
type: ASTType.TPortal,
|
type: ASTType.TPortal,
|
||||||
},
|
},
|
||||||
@@ -2016,4 +2018,13 @@ describe("qweb parser", () => {
|
|||||||
type: ASTType.TIf,
|
type: ASTType.TIf,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("t-portal with .closest", async () => {
|
||||||
|
expect(parse(`<t t-portal.closest="target">Content</t>`)).toEqual({
|
||||||
|
type: ASTType.TPortal,
|
||||||
|
isClosest: true,
|
||||||
|
target: "target",
|
||||||
|
content: { type: ASTType.Text, value: "Content" },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -999,3 +999,26 @@ exports[`Portal: UI/UX focus is kept across re-renders 2`] = `
|
|||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`portal .closest suffix basic use of .suffix 1`] = `
|
||||||
|
"function anonymous(app, bdom, helpers
|
||||||
|
) {
|
||||||
|
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||||
|
const Portal = app.Portal;
|
||||||
|
const comp1 = app.createComponent(null, false, true, false, false);
|
||||||
|
|
||||||
|
let block2 = createBlock(\`<p class=\\"target\\">far target</p>\`);
|
||||||
|
let block3 = createBlock(\`<div><p class=\\"target\\">close target</p><block-child-0/></div>\`);
|
||||||
|
|
||||||
|
function slot1(ctx, node, key = \\"\\") {
|
||||||
|
return text(\`portal content\`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return function template(ctx, node, key = \\"\\") {
|
||||||
|
const b2 = block2();
|
||||||
|
const b5 = comp1({target: '.target',isClosest: true,slots: {'default': {__render: slot1.bind(this), __ctx: ctx}}}, key + \`__1\`, node, ctx, Portal);
|
||||||
|
const b3 = block3([], [b5]);
|
||||||
|
return multi([b2, b3]);
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
`;
|
||||||
|
|||||||
@@ -1028,3 +1028,21 @@ describe("Portal: Props validation", () => {
|
|||||||
expect(error!.message).toBe(`invalid portal target`);
|
expect(error!.message).toBe(`invalid portal target`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("portal .closest suffix", () => {
|
||||||
|
test("basic use of .suffix", async () => {
|
||||||
|
class Parent extends Component {
|
||||||
|
static template = xml`
|
||||||
|
<p class="target">far target</p>
|
||||||
|
<div>
|
||||||
|
<p class="target">close target</p>
|
||||||
|
<t t-portal.closest="'.target'">portal content</t>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mount(Parent, fixture);
|
||||||
|
expect(fixture.innerHTML).toBe(
|
||||||
|
'<p class="target">far target</p><div><p class="target">close targetportal content</p></div>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user