diff --git a/doc/readme.md b/doc/readme.md index 5fdc87f1..501bf5c3 100644 --- a/doc/readme.md +++ b/doc/readme.md @@ -50,20 +50,20 @@ and `EventBus` is exported as `owl.core.EventBus`): ``` Component misc Context AsyncRoot -QWeb router -Store Link -useState RouteComponent -config Router - mode tags -core xml - EventBus utils - Observer debounce -hooks escape - onWillStart loadJS - onMounted loadFile - onWillUpdateProps shallowEqual - onWillPatch whenReady - onPatched +QWeb Portal +Store router +useState Link +config RouteComponent + mode Router +core tags + EventBus xml + Observer utils +hooks debounce + onWillStart escape + onMounted loadJS + onWillUpdateProps loadFile + onWillPatch shallowEqual + onPatched whenReady onWillUnmount useContext useState diff --git a/doc/reference/misc.md b/doc/reference/misc.md index 5a8c1a17..f2a9e310 100644 --- a/doc/reference/misc.md +++ b/doc/reference/misc.md @@ -1,5 +1,116 @@ # 🦉 Miscellaneous 🦉 +## Content + +- [Portal](#portal) +- [AsyncRoot](#asyncroot) + +## `Portal` + +### Overview + +The component `Portal` is meant to be used as a transparent way to 'teleport' a piece +of DOM to the node represented by its sole `target` props. + +This component aims at helping the implementation of the needed infrastructure +for modals (as in `bootstrap-modal`). + +### Usage + +The content it will teleport is defined within the `` node and +internally uses the `default` [Slot](component.md#slots). + +This slot must contain only **one** node, which in turn can have as many children as necessary. + +The element under which the content will be teleported is represented as a selector +by the `target` props which only accepts a string as value. + +The `target` props only supports static selector, and is not meant to be passed to `Portal` +as a variable. Namely, `` is the intended use. +By contrast, `` is not supported. + +The component `Portal` has no particular state, rather it is meant to be a slave to its parent, +and ultimately just a way for the parent to teleport a piece of its own DOM elsewhere. + +The `Portal`'s root node is always `` and is placed where the teleported content +*would have* been. It is this element that the [teleported events](#expected-behaviors) are re-directed on. + +### Example + +The canonic use-case is to implement a Dialog, where a Component may choose to break the natural +workflow to help the user put in some data, which it could use later on. + +JavaScript: + +```js +const { Component } = owl; +const { Portal } = owl.misc; + +class TeleportedComponent extends Component {} +class App extends Component { + static components = { Portal, TeleportedComponent }; +} + +const app = new App(); +app.mount(document.body); +``` + +XML: + +```xml + +
+ I will move soon enough +
+ +
+ I am like the rest of us + + + +
+
+``` + +In this example, the `Portal` component will teleport the `TeleportedComponent`'s `div` as a child of the `body`. +`TeleportedComponent` is acting as a Dialog here. + +The resulting DOM will look like: + +```xml + +
+ I am like the rest of us + +
+
+ I will move soon enough +
+ +``` + +### Expected Behaviors + +The teleported piece is updated as any other `Component`'s DOM and in the same sequence. +Namely the teleported piece will be updated in function of its parents components, and patched as +a normal child. + +The [_business_ events](component.md#event-handling) triggered by a child component will be stopped +to not bubble outside of the `target`. They will, on the other hand, be re-directed onto the +`Portal`'s root node and bubble up the DOM as if it were triggered by a regular child component. + +Beware that those re-directed events are copies of the original event. +They have: + +- The same payload. +- The same `originalComponent` than their original counterpart, + that is the actual Component that triggered it. +- A **different** `target` property than their original counterpart. + The `target` of a re-directed event is necessarily the `Portal`'s root node. + +Pure DOM events do not follow this pattern and are free to bubble their natural, unaltered way +up to the `body`. + ## `AsyncRoot` When this component is used, a new rendering sub tree is created, such that the diff --git a/src/component/component.ts b/src/component/component.ts index 46ed3727..48b193c4 100644 --- a/src/component/component.ts +++ b/src/component/component.ts @@ -84,6 +84,8 @@ interface Internal { refs: { [key: string]: Component | HTMLElement | undefined } | null; } +export const portalSymbol = Symbol("portal"); // FIXME + //------------------------------------------------------------------------------ // Component //------------------------------------------------------------------------------ @@ -397,14 +399,7 @@ export class Component { * willUnmount(). */ trigger(eventType: string, payload?: any) { - if (this.el) { - const ev = new OwlEvent(this, eventType, { - bubbles: true, - cancelable: true, - detail: payload - }); - this.el.dispatchEvent(ev); - } + this.__trigger(this, eventType, payload); } //-------------------------------------------------------------------------- @@ -478,7 +473,24 @@ export class Component { } } } - + /** + * Private trigger method, allows to choose the component which triggered + * the event in the first place + */ + __trigger(component: Component, eventType: string, payload?: any) { + if (this.el) { + const ev = new OwlEvent(component, eventType, { + bubbles: true, + cancelable: true, + detail: payload + }); + const triggerHook = this.env[portalSymbol as any]; + if (triggerHook) { + triggerHook(ev); + } + this.el.dispatchEvent(ev); + } + } /** * The __updateProps method is called by the t-component directive whenever * it updates a component (so, when the parent template is rerendered). diff --git a/src/component/fiber.ts b/src/component/fiber.ts index 1e4c967f..cf58c5df 100644 --- a/src/component/fiber.ts +++ b/src/component/fiber.ts @@ -222,6 +222,7 @@ export class Fiber { if (this.target) { this.target.appendChild(this.component.el!); inDOM = document.body.contains(this.target); + this.component.env.qweb.trigger("dom-appended"); } // call patched/mounted hook on each fiber of (reversed) patchQueue diff --git a/src/index.ts b/src/index.ts index 3feef049..e5cb40bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import * as _store from "./store"; import * as _utils from "./utils"; import * as _tags from "./tags"; import { AsyncRoot } from "./misc/async_root"; +import { Portal } from "./misc/portal"; import * as _hooks from "./hooks"; import * as _context from "./context"; import { Link } from "./router/link"; @@ -29,7 +30,7 @@ export const router = { Router, RouteComponent, Link }; export const Store = _store.Store; export const utils = _utils; export const tags = _tags; -export const misc = { AsyncRoot }; +export const misc = { AsyncRoot, Portal }; export const hooks = Object.assign({}, _hooks, { useContext: _context.useContext, useDispatch: _store.useDispatch, diff --git a/src/misc/portal.ts b/src/misc/portal.ts new file mode 100644 index 00000000..41178a5f --- /dev/null +++ b/src/misc/portal.ts @@ -0,0 +1,156 @@ +import { Component, portalSymbol } from "../component/component"; +import { VNode, patch } from "../vdom/index"; +import { xml } from "../tags"; +import { OwlEvent } from "../core/owl_event"; +import { useSubEnv } from "../hooks"; + +/** + * Portal + * + * The Portal component allows to render a part of a component outside it's DOM. + * It is for example useful for dialogs: for css reasons, dialogs are in general + * placed in a specific spot of the DOM (e.g. directly in the body). With the + * Portal, a component can conditionally specify in its tempate that it contains + * a dialog, and where this dialog should be inserted in the DOM. + * + * The Portal component ensures that the communication between the content of + * the Portal and its parent properly works: business events reaching the Portal + * are re-triggered on an empty node located in the parent's DOM. + */ + +export class Portal extends Component { + static template = xml``; + static props = { + target: { + type: String + } + }; + + // boolean to indicate whether or not we must listen to 'dom-appended' event + // to hook on the moment when the target is inserted into the DOM (because it + // is not when the portal is rendered) + doTargetLookUp: boolean = true; + // set of encountered events that need to be redirected + _handledEvents: Set = new Set(); + // function that will be the event's tunnel (needs to be an arrow function to + // avoid having to rebind `this`) + _handlerTunnel: (f: OwlEvent) => void = (ev: OwlEvent) => { + ev.stopPropagation(); + this.__trigger(ev.originalComponent, ev.type, ev.detail); + }; + // Storing the parent's env + parentEnv: any = null; + // represents the element that is moved somewhere else + portal: VNode | null = null; + // the target where we will move `portal` + target: HTMLElement | null = null; + + constructor(parent, props) { + super(parent, props); + this.parentEnv = parent ? parent.env : {}; + // put a callback in the env that is propagated to children s.t. portal can + // register an handler to those events just before children will trigger them + useSubEnv({ + [portalSymbol]: ev => { + if (!this._handledEvents.has(ev.type)) { + this.portal!.elm!.addEventListener(ev.type, this._handlerTunnel); + this._handledEvents.add(ev.type); + } + } + }); + } + /** + * At each DOM change, we must ensure that the portal contains exactly one + * child + */ + __checkVNodeStructure(vnode: VNode) { + const children = vnode.children!; + let countRealNodes = 0; + for (let child of children) { + if ((child as VNode).sel) { + countRealNodes++; + } + } + if (countRealNodes !== 1) { + throw new Error(`Portal must have exactly one non-text child (has ${countRealNodes})`); + } + } + /** + * Ensure the target is still there at whichever time we render + */ + __checkTargetPresence() { + if (!this.target || !document.contains(this.target)) { + throw new Error(`Could not find any match for "${this.props.target}"`); + } + } + /** + * Move the portal's element to the target + */ + __deployPortal() { + this.__checkTargetPresence(); + this.target!.appendChild(this.portal!.elm!); + } + /** + * Override to remove from the DOM the element we have teleported + * + * @override + */ + __destroy(parent) { + if (this.portal && this.portal.elm) { + const displacedElm = this.portal.elm!; + const parent = displacedElm.parentNode; + if (parent) { + parent.removeChild(displacedElm); + } + } + super.__destroy(parent); + } + /** + * Override to patch the element that has been teleported + * + * @override + */ + __patch(vnode) { + if (this.doTargetLookUp) { + const target = document.querySelector(this.props.target); + if (!target) { + this.env.qweb.on("dom-appended", this, () => { + this.doTargetLookUp = false; + this.env.qweb.off("dom-appended", this); + this.target = document.querySelector(this.props.target); + this.__deployPortal(); + }); + } else { + this.doTargetLookUp = false; + this.target = target; + } + } + this.__checkVNodeStructure(vnode); + const shouldDeploy = !this.portal && !this.doTargetLookUp; + + if (!this.doTargetLookUp && !shouldDeploy) { + // Only on pure patching, provided the + // this.target's parent has not been unmounted + this.__checkTargetPresence(); + } + + const portalPatch = this.portal ? this.portal : document.createElement(vnode.children[0].sel); + this.portal = patch(portalPatch, vnode.children![0] as VNode); + vnode.children = []; + + super.__patch(vnode); + + if (shouldDeploy) { + this.__deployPortal(); + } + } + /** + * Override to set the env + */ + __trigger(component: Component, eventType: string, payload?: any) { + const env = this.env; + this.env = this.parentEnv; + super.__trigger(component, eventType, payload); + this.env = env; + } +} diff --git a/tests/misc/portal.test.ts b/tests/misc/portal.test.ts new file mode 100644 index 00000000..a0d12ecb --- /dev/null +++ b/tests/misc/portal.test.ts @@ -0,0 +1,828 @@ +import { Portal } from "../../src/misc/portal"; +import { xml } from "../../src/tags"; +import { makeTestFixture, makeTestEnv, nextTick } from "../helpers"; +import { Component } from "../../src/component/component"; +import { useState } from "../../src/hooks"; +import { QWeb } from "../../src/qweb"; + +//------------------------------------------------------------------------------ +// Setup and helpers +//------------------------------------------------------------------------------ + +// We create before each test: +// - fixture: a div, appended to the DOM, intended to be the target of dom +// manipulations. Note that it is removed after each test. +// - outside: a div with id #outside appended into fixture, meant to be used as +// target by Portal component +// - a test env, necessary to create components, that is set on Component + +let fixture: HTMLElement; +let outside: HTMLElement; + +beforeEach(() => { + fixture = makeTestFixture(); + outside = document.createElement("div"); + outside.setAttribute("id", "outside"); + fixture.appendChild(outside); + + Component.env = makeTestEnv(); +}); + +afterEach(() => { + fixture.remove(); +}); + +describe("Portal: Props validation", () => { + test("target is mandatory", async () => { + const dev = QWeb.dev; + QWeb.dev = true; + class Parent extends Component { + static components = { Portal }; + static template = xml` +
+ +
2
+
+
`; + } + let error; + try { + const parent = new Parent(); + await parent.mount(fixture); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.message).toBe(`Missing props 'target' (component 'Portal')`); + + QWeb.dev = dev; + }); + + test("target is not list", async () => { + const dev = QWeb.dev; + QWeb.dev = true; + class Parent extends Component { + static components = { Portal }; + static template = xml` +
+ +
2
+
+
`; + } + let error; + try { + const parent = new Parent(); + await parent.mount(fixture); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.message).toBe(`Invalid Prop 'target' in component 'Portal'`); + + QWeb.dev = dev; + }); +}); + +describe("Portal: Basic use and DOM placement", () => { + test("basic use of portal", async () => { + const dev = QWeb.dev; + QWeb.dev = true; + class Parent extends Component { + static components = { Portal }; + static template = xml` +
+ 1 + +
2
+
+
`; + } + let error; + let parent; + try { + parent = new Parent(); + } catch (e) { + error = e; + } + expect(error).toBeUndefined(); + await parent.mount(fixture); + expect(outside.innerHTML).toBe("
2
"); + expect(parent.el!.outerHTML).toBe("
1
"); + QWeb.dev = dev; + }); + + test("conditional use of Portal", async () => { + class Parent extends Component { + static components = { Portal }; + static template = xml` +
+ 1 + +
2
+
+
`; + + state = useState({ hasPortal: false }); + } + + const parent = new Parent(); + await parent.mount(fixture); + expect(outside.innerHTML).toBe(""); + expect(parent.el!.outerHTML).toBe("
1
"); + + parent.state.hasPortal = true; + await nextTick(); + expect(outside.innerHTML).toBe("
2
"); + expect(parent.el!.outerHTML).toBe("
1
"); + + parent.state.hasPortal = false; + await nextTick(); + expect(outside.innerHTML).toBe(""); + expect(parent.el!.outerHTML).toBe("
1
"); + + parent.state.hasPortal = true; + await nextTick(); + expect(outside.innerHTML).toBe("
2
"); + expect(parent.el!.outerHTML).toBe("
1
"); + }); + + test("conditional use of Portal (with sub Component)", async () => { + class Child extends Component { + static template = xml`
`; + } + class Parent extends Component { + static components = { Portal, Child }; + static template = xml` +
+ 1 + + + +
`; + state = useState({ hasPortal: false, val: 1 }); + } + + const parent = new Parent(); + await parent.mount(fixture); + expect(outside.innerHTML).toBe(""); + expect(parent.el!.outerHTML).toBe("
1
"); + + parent.state.hasPortal = true; + await nextTick(); + expect(outside.innerHTML).toBe("
1
"); + expect(parent.el!.outerHTML).toBe("
1
"); + + parent.state.hasPortal = false; + await nextTick(); + expect(outside.innerHTML).toBe(""); + expect(parent.el!.outerHTML).toBe("
1
"); + + parent.state.val = 2; + await nextTick(); + expect(outside.innerHTML).toBe(""); + expect(parent.el!.outerHTML).toBe("
1
"); + + parent.state.hasPortal = true; + await nextTick(); + expect(outside.innerHTML).toBe("
2
"); + expect(parent.el!.outerHTML).toBe("
1
"); + }); + + test("with target in template (before portal)", async () => { + class Parent extends Component { + static components = { Portal }; + static template = xml` +
+
+ 1 + +

2

+
+
`; + } + + const parent = new Parent(); + await parent.mount(fixture); + expect(parent.el!.innerHTML).toBe( + '

2

1' + ); + }); + + test("with target in template (after portal)", async () => { + class Parent extends Component { + static components = { Portal }; + static template = xml` +
+ 1 + +

2

+
+
+
`; + } + + const parent = new Parent(); + await parent.mount(fixture); + expect(parent.el!.innerHTML).toBe( + '1

2

' + ); + }); + + test("portal with target not in dom", async () => { + const consoleError = console.error; + console.error = jest.fn(() => {}); + + class Parent extends Component { + static components = { Portal }; + static template = xml` +
+ +
2
+
+
`; + } + + const parent = new Parent(); + let error; + try { + await parent.mount(fixture); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.message).toBe('Could not find any match for "#does-not-exist"'); + expect(console.error).toBeCalledTimes(0); + expect(fixture.innerHTML).toBe(`
`); + console.error = consoleError; + }); + + test("portal with child and props", async () => { + const steps: string[] = []; + class Child extends Component { + static template = xml``; + mounted() { + steps.push("mounted"); + expect(outside.innerHTML).toBe("1"); + } + patched() { + steps.push("patched"); + expect(outside.innerHTML).toBe("2"); + } + } + class Parent extends Component { + static components = { Portal, Child }; + static template = xml` +
+ + + +
`; + state = useState({ val: 1 }); + } + + const parent = new Parent(); + await parent.mount(fixture); + expect(outside.innerHTML).toBe("1"); + expect(parent.el!.innerHTML).toBe(""); + + parent.state.val = 2; + await nextTick(); + expect(outside.innerHTML).toBe("2"); + expect(parent.el!.innerHTML).toBe(""); + expect(steps).toEqual(["mounted", "patched"]); + }); + + test("portal with only text as content", async () => { + const consoleError = console.error; + console.error = jest.fn(() => {}); + + class Parent extends Component { + static components = { Portal }; + static template = xml` +
+ + + +
`; + } + + const parent = new Parent(); + let error; + try { + await parent.mount(fixture); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.message).toBe("Portal must have exactly one non-text child (has 0)"); + expect(console.error).toBeCalledTimes(0); + expect(fixture.innerHTML).toBe(`
`); + console.error = consoleError; + }); + + test("portal with no content", async () => { + const consoleError = console.error; + console.error = jest.fn(() => {}); + + class Parent extends Component { + static components = { Portal }; + static template = xml` +
+ + + +
`; + } + + const parent = new Parent(); + let error; + try { + await parent.mount(fixture); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.message).toBe("Portal must have exactly one non-text child (has 0)"); + expect(console.error).toBeCalledTimes(0); + expect(fixture.innerHTML).toBe(`
`); + console.error = consoleError; + }); + + test("portal with many children", async () => { + const consoleError = console.error; + console.error = jest.fn(() => {}); + + class Parent extends Component { + static components = { Portal }; + static template = xml` +
+ +
1
+

2

+
+
`; + } + const parent = new Parent(); + let error; + try { + await parent.mount(fixture); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.message).toBe("Portal must have exactly one non-text child (has 2)"); + expect(console.error).toBeCalledTimes(0); + expect(fixture.innerHTML).toBe(`
`); + console.error = consoleError; + }); + + test("portal with dynamic body", async () => { + class Parent extends Component { + static components = { Portal }; + static template = xml` +
+ + +
+ +
`; + state = useState({ val: "ab" }); + } + + const parent = new Parent(); + await parent.mount(fixture); + + expect(outside.innerHTML).toBe(`ab`); + + parent.state.val = ""; + await nextTick(); + expect(outside.innerHTML).toBe(`
`); + }); + + test("portal could have dynamically no content", async () => { + const consoleError = console.error; + console.error = jest.fn(() => {}); + + class Parent extends Component { + static components = { Portal }; + static template = xml` +
+ + + +
`; + state = { val: "ab" }; + } + const parent = new Parent(); + await parent.mount(fixture); + + expect(outside.innerHTML).toBe(`ab`); + + let error; + try { + parent.state.val = ""; + await parent.render(); + } catch (e) { + error = e; + } + expect(outside.innerHTML).toBe(``); + + expect(error).toBeDefined(); + expect(error.message).toBe("Portal must have exactly one non-text child (has 0)"); + + expect(console.error).toBeCalledTimes(0); + console.error = consoleError; + }); + + test("lifecycle hooks of portal sub component are properly called", async () => { + const steps: any[] = []; + + class Child extends Component { + static template = xml``; + mounted() { + steps.push("child:mounted"); + } + willPatch() { + steps.push("child:willPatch"); + } + patched() { + steps.push("child:patched"); + } + willUnmount() { + steps.push("child:willUnmount"); + } + } + class Parent extends Component { + static components = { Portal, Child }; + static template = xml` +
+ + + +
`; + state = useState({ hasChild: false, val: 1 }); + mounted() { + steps.push("parent:mounted"); + } + willPatch() { + steps.push("parent:willPatch"); + } + patched() { + steps.push("parent:patched"); + } + willUnmount() { + steps.push("parent:willUnmount"); + } + } + const parent = new Parent(); + await parent.mount(fixture); + expect(steps).toEqual(["parent:mounted"]); + + parent.state.hasChild = true; + await nextTick(); + expect(steps).toEqual([ + "parent:mounted", + "parent:willPatch", + "child:mounted", + "parent:patched" + ]); + + parent.state.val = 2; + await nextTick(); + expect(steps).toEqual([ + "parent:mounted", + "parent:willPatch", + "child:mounted", + "parent:patched", + "parent:willPatch", + "child:willPatch", + "child:patched", + "parent:patched" + ]); + + parent.state.hasChild = false; + await nextTick(); + expect(steps).toEqual([ + "parent:mounted", + "parent:willPatch", + "child:mounted", + "parent:patched", + "parent:willPatch", + "child:willPatch", + "child:patched", + "parent:patched", + "parent:willPatch", + "child:willUnmount", + "parent:patched" + ]); + }); + + test("portal destroys on crash", async () => { + class Child extends Component { + static template = xml``; + state = {}; + } + class Parent extends Component { + static components = { Portal, Child }; + static template = xml` +
+ + + +
`; + state = { error: false }; + } + + const parent = new Parent(); + await parent.mount(fixture); + parent.state.error = true; + + let error; + try { + await parent.render(); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.message).toBe("Cannot read property 'crash' of undefined"); + }); +}); + +describe("Portal: Events handling", () => { + test("events triggered on movable pure node are handled", async () => { + class Parent extends Component { + static components = { Portal }; + static template = xml` +
+ + + +
`; + state = useState({ val: "ab" }); + + _onCustom() { + this.state.val = "triggered"; + } + } + const parent = new Parent(); + await parent.mount(fixture); + + expect(outside.innerHTML).toBe(`ab`); + outside.querySelector("#trigger-me")!.dispatchEvent(new Event("custom")); + await nextTick(); + expect(outside.innerHTML).toBe(`triggered`); + }); + + test("events triggered on movable owl components are redirected", async () => { + let childInst: Component | null = null; + class Child extends Component { + static template = xml` + `; + + constructor(parent, props) { + super(parent, props); + childInst = this; + } + + _onCustom() { + this.trigger("custom-portal"); + } + } + class Parent extends Component { + static components = { Portal, Child }; + static template = xml` +
+ + + +
`; + state = useState({ val: "ab" }); + + _onCustomPortal() { + this.state.val = "triggered"; + } + } + const parent = new Parent(); + await parent.mount(fixture); + + expect(outside.innerHTML).toBe(`ab`); + childInst!.trigger("custom"); + await nextTick(); + expect(outside.innerHTML).toBe(`triggered`); + }); + + test("events triggered on contained movable owl components are redirected", async () => { + const steps: string[] = []; + let childInst: Component | null = null; + class Child extends Component { + static template = xml` + `; + + constructor(parent, props) { + super(parent, props); + childInst = this; + } + + _onCustom() { + this.trigger("custom-portal"); + } + } + class Parent extends Component { + static components = { Portal, Child }; + static template = xml` +
+ +
+ +
+
+
`; + + _handled(ev) { + steps.push(ev.type); + } + } + const parent = new Parent(); + await parent.mount(fixture); + + childInst!.trigger("custom"); + await nextTick(); + + // This is expected because trigger is synchronous + expect(steps).toMatchObject(["custom-portal", "custom"]); + }); + + test("Dom events are not mapped", async () => { + let childInst: Component | null = null; + const steps: string[] = []; + class Child extends Component { + static template = xml` + `; + + constructor(parent, props) { + super(parent, props); + childInst = this; + } + } + class Parent extends Component { + static components = { Portal, Child }; + static template = xml` +
+ + + +
`; + + _handled(ev) { + steps.push(ev.type as string); + } + } + const bodyListener = ev => { + steps.push(`body: ${ev.type}`); + }; + document.body.addEventListener("click", bodyListener); + + const parent = new Parent(); + await parent.mount(fixture); + childInst!.el!.click(); + + expect(steps).toEqual(["body: click"]); + document.body.removeEventListener("click", bodyListener); + }); + + test("Nested portals event propagation", async () => { + const outside2 = document.createElement("div"); + outside2.setAttribute("id", "outside2"); + fixture.appendChild(outside2); + + const steps: Array = []; + let childInst: Component | null = null; + class Child2 extends Component { + static template = xml`
child2
`; + constructor(parent, props) { + super(parent, props); + childInst = this; + } + } + class Child extends Component { + static components = { Portal, Child2 }; + static template = xml` + + + `; + } + class Parent extends Component { + static components = { Portal, Child }; + static template = xml` +
+ + + +
`; + + _handled(ev) { + steps.push(`${ev.type} from ${ev.originalComponent.constructor.name}`); + } + } + + const parent = new Parent(); + await parent.mount(fixture); + + childInst!.trigger("custom"); + expect(steps).toEqual(["custom from Child2"]); + }); + + test("portal's parent's env is not polluted", async () => { + class Child extends Component { + static template = xml` + `; + } + class Parent extends Component { + static components = { Portal, Child, }; + static template = xml` +
+ + + +
`; + } + const parent = new Parent(); + const parentEnv = Object.assign({}, parent.env); + await parent.mount(fixture); + expect(parentEnv).toStrictEqual(parent.env); + }); + + test("Portal composed with t-slot", async () => { + const steps: Array = []; + let childInst: Component | null = null; + class Child2 extends Component { + static template = xml`
child2
`; + constructor(parent, props) { + super(parent, props); + childInst = this; + } + } + class Child extends Component { + static components = { Portal, Child2 }; + static template = xml` + + + `; + } + class Parent extends Component { + static components = { Child, Child2 }; + static template = xml` +
+ + + +
`; + + _handled(ev) { + steps.push(ev.type as string); + } + } + + const parent = new Parent(); + await parent.mount(fixture); + + childInst!.trigger("custom"); + expect(steps).toEqual(["custom"]); + }); +}); + +describe("Portal: UI/UX", () => { + test("focus is kept across re-renders", async () => { + class Child extends Component { + static template = xml` + `; + } + class Parent extends Component { + static components = { Portal, Child }; + static template = xml` +
+ + + +
`; + state = useState({ val: "ab" }); + } + const parent = new Parent(); + await parent.mount(fixture); + const input = document.querySelector("#target-me"); + expect(input!.nodeName).toBe("INPUT"); + expect((input as HTMLInputElement).placeholder).toBe("ab"); + + (input as HTMLInputElement).focus(); + expect(document.activeElement === input).toBeTruthy(); + + parent.state.val = "bc"; + await nextTick(); + const inputReRendered = document.querySelector("#target-me"); + expect(inputReRendered!.nodeName).toBe("INPUT"); + expect((inputReRendered as HTMLInputElement).placeholder).toBe("bc"); + expect(document.activeElement === inputReRendered).toBeTruthy(); + }); +}); diff --git a/tools/playground/samples.js b/tools/playground/samples.js index 7ac567b7..d02526d6 100644 --- a/tools/playground/samples.js +++ b/tools/playground/samples.js @@ -1444,6 +1444,111 @@ const FORM_XML = ` `; +const PORTAL_COMPONENTS = ` +// This shows the expected use case of Portal +// which is to implement something similar +// to bootstrap modal +const { Component, useState } = owl; +const { Portal } = owl.misc; + +class Modal extends Component {} +Modal.components = { Portal }; + +class Dialog extends Component {} +Dialog.components = { Modal }; + +class Interstellar extends Component {} + +// Main root component +class App extends Component { + state = useState({ + name: 'Portal used for Dialog (Modal)', + dialog: false, + text: 'Hello !', + }); +} +App.components = { Dialog , Interstellar }; + +// Application setup +const app = new App(); +app.mount(document.body); +`; + +const PORTAL_XML = ` + + + +
+
+
+ +
+
+
+
+ + + +
+ +
+
+
+ +
+

This is a subComponent

+

The events it triggers will go through the Portal and be teleported + on the other side of the wormhole it has created

+ +
+ +
+
+ + +
+ +
+
+ +`; + +const PORTAL_CSS = ` +.owl-modal-supercontainer { + position: static; +} +.owl-modal-backdrop { + position: fixed; + top: 0; + left:0; + background-color: #000000; + opacity: 0.5; + width: 100vw; + height: 100vh; + z-index: 1000; +} +.owl-modal-container { + opacity:1; + z-index: 1050; + position: fixed; + top: 0; + left:0; + width: 100%; + height: 100%; +} +.owl-dialog-body { + max-width: 500px; + margin: 0 auto; + position: relative; + text-align: center; + padding: 2rem; + background-color: #FFFFFF; + max-height: 100%; +} +.owl-interstellar { + border: groove; +}` + const WMS = `// This example is slightly more complex than usual. We demonstrate // here a way to manage sub windows in Owl, declaratively. This is still just a // demonstration. Managing windows can be as complex as we want. For example, @@ -1770,5 +1875,11 @@ export const SAMPLES = [ code: ASYNC_COMPONENTS, xml: ASYNC_COMPONENTS_XML, css: ASYNC_COMPONENTS_CSS - } + }, + { + description: "Portal (Dialog)", + code: PORTAL_COMPONENTS, + xml: PORTAL_XML, + css: PORTAL_CSS, + }, ];