Skip to content

Commit

Permalink
[IMP] portal: add support for .closest modifier
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
ged-odoo committed May 8, 2023
1 parent ba20267 commit 0a5a1e6
Show file tree
Hide file tree
Showing 7 changed files with 99 additions and 11 deletions.
8 changes: 8 additions & 0 deletions doc/reference/portal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<div t-portal.closest="'.target'">some content</div>
```
4 changes: 3 additions & 1 deletion src/compiler/code_generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
15 changes: 12 additions & 3 deletions src/compiler/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export interface ASTTranslation {
export interface ASTTPortal {
type: ASTType.TPortal;
target: string;
isClosest: boolean;
content: AST;
}

Expand Down Expand Up @@ -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 {
Expand All @@ -848,6 +856,7 @@ function parseTPortal(node: Element, ctx: ParsingContext): AST | null {
return {
type: ASTType.TPortal,
target,
isClosest,
content,
};
}
Expand Down
31 changes: 24 additions & 7 deletions src/runtime/portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<VNode<VPortal>> {
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 {
Expand Down Expand Up @@ -54,16 +68,19 @@ class VPortal extends VText implements Partial<VNode<VPortal>> {
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,
};

Expand All @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions tests/compiler/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1998,6 +1998,7 @@ describe("qweb parser", () => {
test("t-portal", async () => {
expect(parse(`<t t-portal="target">Content</t>`)).toEqual({
type: ASTType.TPortal,
isClosest: false,
target: "target",
content: { type: ASTType.Text, value: "Content" },
});
Expand All @@ -2008,6 +2009,7 @@ describe("qweb parser", () => {
condition: "condition",
content: {
content: { type: ASTType.Text, value: "Content" },
isClosest: false,
target: "target",
type: ASTType.TPortal,
},
Expand All @@ -2016,4 +2018,13 @@ describe("qweb parser", () => {
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" },
});
});
});
23 changes: 23 additions & 0 deletions tests/misc/__snapshots__/portal.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
}"
`;
18 changes: 18 additions & 0 deletions tests/misc/portal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<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>'
);
});
});

0 comments on commit 0a5a1e6

Please sign in to comment.