diff --git a/doc/reference/portal.md b/doc/reference/portal.md
index de7e639e..4baf0ea0 100644
--- a/doc/reference/portal.md
+++ b/doc/reference/portal.md
@@ -15,3 +15,11 @@ class SomeComponent extends Component {
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
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
+
some content
+```
diff --git a/src/compiler/code_generator.ts b/src/compiler/code_generator.ts
index d56bbb2b..c0414597 100644
--- a/src/compiler/code_generator.ts
+++ b/src/compiler/code_generator.ts
@@ -1371,7 +1371,9 @@ export class CodeGenerator {
});
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) {
this.insertAnchor(block);
}
diff --git a/src/compiler/parser.ts b/src/compiler/parser.ts
index 42a89f13..e3f7ca7c 100644
--- a/src/compiler/parser.ts
+++ b/src/compiler/parser.ts
@@ -169,6 +169,7 @@ export interface ASTTranslation {
export interface ASTTPortal {
type: ASTType.TPortal;
target: string;
+ isClosest: boolean;
content: AST;
}
@@ -833,11 +834,18 @@ function parseTTranslation(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;
}
- const target = node.getAttribute("t-portal")!;
- node.removeAttribute("t-portal");
const content = parseNode(node, ctx);
if (!content) {
return {
@@ -848,6 +856,7 @@ function parseTPortal(node: Element, ctx: ParsingContext): AST | null {
return {
type: ASTType.TPortal,
target,
+ isClosest,
content,
};
}
diff --git a/src/runtime/portal.ts b/src/runtime/portal.ts
index 37ba8a32..48430806 100644
--- a/src/runtime/portal.ts
+++ b/src/runtime/portal.ts
@@ -5,20 +5,34 @@ import { OwlError } from "./error_handling";
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> {
content: BDom | null;
selector: string;
+ isClosest: boolean;
target: HTMLElement | null = null;
- constructor(selector: string, content: BDom) {
+ constructor(selector: string, isClosest: boolean, content: BDom) {
super("");
this.selector = selector;
+ this.isClosest = isClosest;
this.content = content;
}
mount(parent: HTMLElement, anchor: ChildNode) {
super.mount(parent, anchor);
- this.target = document.querySelector(this.selector) as any;
+ this.target = getTarget(parent, this.selector, this.isClosest);
if (this.target) {
this.content!.mount(this.target!, null);
} else {
@@ -54,16 +68,19 @@ class VPortal extends VText implements Partial> {
export function portalTemplate(app: any, bdom: any, helpers: any) {
let { callSlot } = helpers;
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 {
static template = "__portal__";
static props = {
- target: {
- type: String,
- },
+ target: String,
+ isClosest: { type: Boolean, optional: true },
slots: true,
};
@@ -73,7 +90,7 @@ export class Portal extends Component {
onMounted(() => {
const portal: VPortal = node.bdom;
if (!portal.target) {
- const target: HTMLElement = document.querySelector(this.props.target);
+ const target = getTarget(portal.parentEl, this.props.target, this.props.isClosest);
if (target) {
portal.content!.moveBeforeDOMNode(target.firstChild, target);
} else {
diff --git a/tests/compiler/parser.test.ts b/tests/compiler/parser.test.ts
index cdbbcace..e11fe3e8 100644
--- a/tests/compiler/parser.test.ts
+++ b/tests/compiler/parser.test.ts
@@ -1998,6 +1998,7 @@ describe("qweb parser", () => {
test("t-portal", async () => {
expect(parse(`Content`)).toEqual({
type: ASTType.TPortal,
+ isClosest: false,
target: "target",
content: { type: ASTType.Text, value: "Content" },
});
@@ -2008,6 +2009,7 @@ describe("qweb parser", () => {
condition: "condition",
content: {
content: { type: ASTType.Text, value: "Content" },
+ isClosest: false,
target: "target",
type: ASTType.TPortal,
},
@@ -2016,4 +2018,13 @@ describe("qweb parser", () => {
type: ASTType.TIf,
});
});
+
+ test("t-portal with .closest", async () => {
+ expect(parse(`Content`)).toEqual({
+ type: ASTType.TPortal,
+ isClosest: true,
+ target: "target",
+ content: { type: ASTType.Text, value: "Content" },
+ });
+ });
});
diff --git a/tests/misc/__snapshots__/portal.test.ts.snap b/tests/misc/__snapshots__/portal.test.ts.snap
index 5840ced6..8b28653d 100644
--- a/tests/misc/__snapshots__/portal.test.ts.snap
+++ b/tests/misc/__snapshots__/portal.test.ts.snap
@@ -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(\`far target
\`);
+ let block3 = createBlock(\`\`);
+
+ 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]);
+ }
+}"
+`;
diff --git a/tests/misc/portal.test.ts b/tests/misc/portal.test.ts
index b5c230b4..626bb230 100644
--- a/tests/misc/portal.test.ts
+++ b/tests/misc/portal.test.ts
@@ -1028,3 +1028,21 @@ describe("Portal: Props validation", () => {
expect(error!.message).toBe(`invalid portal target`);
});
});
+
+describe("portal .closest suffix", () => {
+ test("basic use of .suffix", async () => {
+ class Parent extends Component {
+ static template = xml`
+ far target
+
+
close target
+
portal content
+
`;
+ }
+
+ await mount(Parent, fixture);
+ expect(fixture.innerHTML).toBe(
+ 'far target
close targetportal content
'
+ );
+ });
+});