├── .prettierrc.json ├── src ├── index.ts ├── docs.index.ts ├── portal.ts └── portal.test.ts ├── docs ├── assets │ ├── hierarchy.js │ ├── navigation.js │ ├── search.js │ ├── highlight.css │ ├── icons.svg │ └── icons.js ├── .nojekyll ├── types │ └── TargetOrSelector.html ├── functions │ └── portal.html ├── modules.html ├── interfaces │ └── PortalOptions.html ├── classes │ └── PortalDirective.html └── index.html ├── typedoc.json ├── .gitignore ├── .prettierignore ├── web-test-runner.config.mjs ├── dev ├── tsconfig.json ├── index.html ├── confirmation-dialog.ts ├── index.ts ├── demo-modify-container.ts ├── example-component.ts ├── demo-intro.ts ├── demo-delegation.ts ├── demo-intro-example.ts ├── demo-async.ts ├── demo-reactive.ts └── demo-modals.ts ├── docs.tsconfig.json ├── web-dev-server.config.mjs ├── tsconfig.json ├── LICENSE.txt ├── esbuild.config.js ├── package.json ├── CHANGELOG.md └── README.md /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { portal } from './portal'; 2 | 3 | export { portal }; 4 | -------------------------------------------------------------------------------- /docs/assets/hierarchy.js: -------------------------------------------------------------------------------- 1 | window.hierarchyData = "eJyrVirKzy8pVrKKjtVRKkpNy0lNLsnMzytWsqqurQUAmx4Kpg==" -------------------------------------------------------------------------------- /src/docs.index.ts: -------------------------------------------------------------------------------- 1 | export { portal, TargetOrSelector, PortalOptions, PortalDirective } from './portal'; 2 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["src/docs.index.ts"], 3 | "name": "Lit Modal Portal Documentation", 4 | "out": "docs", 5 | "tsconfig": "docs.tsconfig.json" 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.swp 3 | *.tsbuildinfo 4 | 5 | # build output 6 | /lib/ 7 | /index.* 8 | /modal-portal.* 9 | /modal-controller.* 10 | /portal.* 11 | /lit-modal-portal-* 12 | /coverage 13 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.swp 3 | *.tsbuildinfo 4 | /CHANGELOG.md 5 | 6 | docs 7 | 8 | # build output 9 | /lib/ 10 | /index.* 11 | /modal-portal.* 12 | /modal-controller.* 13 | /portal.* 14 | /lit-modal-portal-* 15 | -------------------------------------------------------------------------------- /docs/assets/navigation.js: -------------------------------------------------------------------------------- 1 | window.navigationData = "eJxdzsEKwjAQBNB/2XMwttiqOXuvoDfxEOLWBtMkJKso0n8Xqpg255l9s6c3ED4JBOxdIGl2OqAi/UBg4CV1IEAZGSNGnhUWHfUGGNy0vYAoys3AMqvxpJ2NSdKWMLRS/bFfY06VVT2hjjJckZpwQIOKXEgavTxGnueZtdyui6qceH4cTkp7t2p8gn+T+X29Gs4fwOFkLQ==" -------------------------------------------------------------------------------- /web-test-runner.config.mjs: -------------------------------------------------------------------------------- 1 | import { esbuildPlugin } from '@web/dev-server-esbuild'; 2 | 3 | export default { 4 | nodeResolve: true, 5 | files: ['src/**/*.test.ts'], 6 | plugins: [esbuildPlugin({ ts: true, target: 'auto', tsconfig: './tsconfig.json' })], 7 | }; 8 | -------------------------------------------------------------------------------- /dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | // For use by typescript-language-server 2 | { 3 | "compilerOptions": { 4 | "experimentalDecorators": true, 5 | "target": "es2019", 6 | "moduleResolution": "node", 7 | "plugins": [ 8 | { 9 | "name": "ts-lit-plugin" 10 | } 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Lit Modal Portal 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /docs.tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts"], 3 | "exclude": ["src/**/*.test.ts"], 4 | "compilerOptions": { 5 | "moduleResolution": "node", 6 | "experimentalDecorators": true, 7 | "target": "es2019", 8 | "preserveWatchOutput": true, 9 | "skipLibCheck": true 10 | }, 11 | "watchOptions": { 12 | "excludeDirectories": ["**/node_modules"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /web-dev-server.config.mjs: -------------------------------------------------------------------------------- 1 | import { env } from 'node:process'; 2 | import { esbuildPlugin } from '@web/dev-server-esbuild'; 3 | 4 | export default { 5 | nodeResolve: true, 6 | port: Number(env['PORT']) || 8000, 7 | plugins: [esbuildPlugin({ ts: true, target: 'auto', tsconfig: './tsconfig.json' })], 8 | middleware: [ 9 | // Based on https://modern-web.dev/docs/dev-server/middleware/#rewriting-request-urls 10 | function rewriteIndex(context, next) { 11 | if (context.url === '/' || context.url === '/index.html') { 12 | context.url = '/dev/index.html'; 13 | } 14 | 15 | return next(); 16 | }, 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /docs/assets/search.js: -------------------------------------------------------------------------------- 1 | window.searchData = "eJydlk2PmzAQQP/L7NWb8A3h2l562kqterFQhWDStUpsZDtpV4j/XgEBx8BmSW/IM+/NYI0NDUjxR0FKG/jNeAlpFBDg+QkhhVpInVdA4CwrSOF45oVmgqv9ENi96lMXLapcKVSQArRk1HjOIXZDb3J9z+Uv1C/yG1ZYaCEnq36rUe3n4fvuMJq8X/tWXuq+sUnKuEZ5zAtUeyvhrtZ1vJt3r/ICX0VVotxmfbKJmwoE6lwi14tu3yl9EiU7vn0SXOeMby4/UMUN9WALXjLb1M9MYqHZBacOrsb9LP7BIARGLJHfbug939OUe+89TI/vVCyZKgTnWGgst9WdEaa6VuUzU8+1FHoMPtqNxAebsYH/7CUjwHiJfyFt4IJSMcEhBW/n7w5A4MiwKrsbYGiSQCFOp85EoBTFuX/Mrmk/+sPZJQ/ZewcIdYib7A6xm2WEjnAf6BdGh1npQRcIdddAdwG6FugBod4a6C1AzwJ9INRfA/0F6FtgAIQGa2CwAAMLDIHQcA0MF2BogREQGq2B0QKMLDAGQuM1MF6AsQUmQGiyBiYLcFjpZ+qCUmP5ZZgtSmeHrYGf17GLxwPQQAxp07ZmyNKmvZmzLtYVW1xmxhUYV7DJZd3LxuMbj7/Nc/0aGoVjFM4DitLcq8YVGlf4gEuMHz5j8ozJ22Sy7hjjSYwn2ejhsz2OjCLapND9b4CQavpLMDLXyNyPZRmBmtVYMY6Q0qxt/wHhM/RV"; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts"], 3 | "exclude": ["src/docs.index.ts", "src/lib/docs.index.ts"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "declaration": true, 8 | "declarationMap": true, 9 | "emitDeclarationOnly": true, 10 | "removeComments": true, 11 | "outDir": "./", 12 | "esModuleInterop": true, 13 | "experimentalDecorators": true, 14 | "lib": ["esnext", "DOM", "DOM.Iterable"], 15 | "target": "es2019", 16 | "plugins": [{ "name": "ts-lit-plugin" }], 17 | "preserveWatchOutput": true, 18 | "useDefineForClassFields": false, 19 | "types": ["mocha"] 20 | }, 21 | "watchOptions": { 22 | "excludeDirectories": ["**/node_modules"] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /dev/confirmation-dialog.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from 'lit'; 2 | import { customElement, property } from 'lit/decorators.js'; 3 | import { Ref, ref, createRef } from 'lit/directives/ref.js'; 4 | 5 | @customElement('confirmation-dialog') 6 | export default class ConfirmationDialog extends LitElement { 7 | @property({ attribute: false }) 8 | dialogRef: Ref = createRef(); 9 | 10 | render() { 11 | return html` 12 | { 15 | this.dispatchEvent(new CloseEvent('close', { ...e })); 16 | }} 17 | > 18 |

This is the confirmation dialog

19 | 20 | 21 |
22 | `; 23 | } 24 | } 25 | 26 | declare global { 27 | interface HTMLElementTagNameMap { 28 | 'confirmation-dialog': ConfirmationDialog; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2024 Nicholas Wilcox . 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /esbuild.config.js: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import parser from 'yargs-parser'; 3 | 4 | function isBooleanAndTrue(value) { 5 | return typeof value === 'boolean' && Boolean(value); 6 | } 7 | 8 | const localeTimeStringOptions = { 9 | hour: '2-digit', 10 | minute: '2-digit', 11 | second: '2-digit', 12 | }; 13 | 14 | function getNowAsString() { 15 | return new Date().toLocaleTimeString([], localeTimeStringOptions); 16 | } 17 | 18 | const argv = parser(process.argv.slice(2)); 19 | const shouldWatch = isBooleanAndTrue(argv.w) || isBooleanAndTrue(argv.watch); 20 | 21 | const plugins = [ 22 | { 23 | name: 'on-rebuild-plugin', 24 | setup(build) { 25 | if (shouldWatch) { 26 | build.onEnd((result) => { 27 | const timeString = getNowAsString(); 28 | if (result.errors.length) console.log(`${timeString}: watch build failed`); 29 | else console.log(`${timeString}: watch build succeeded`); 30 | }); 31 | } 32 | }, 33 | }, 34 | ]; 35 | 36 | const context = await esbuild.context({ 37 | entryPoints: ['src/index.ts', 'src/portal.ts'], 38 | outdir: './', 39 | bundle: false, 40 | format: 'esm', 41 | sourcemap: true, 42 | plugins, 43 | }); 44 | 45 | if (shouldWatch) { 46 | await context.watch(); 47 | console.log('watching...'); 48 | process.on('SIGINT', () => context.dispose()); 49 | } else { 50 | await context.rebuild(); 51 | await context.dispose(); 52 | } 53 | -------------------------------------------------------------------------------- /dev/index.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit'; 2 | import { customElement, state } from 'lit/decorators.js'; 3 | import { createRef, Ref } from 'lit/directives/ref.js'; 4 | 5 | import './demo-intro'; 6 | import './demo-reactive'; 7 | import './demo-modals'; 8 | import './demo-async'; 9 | import './demo-modify-container'; 10 | import './demo-delegation'; 11 | 12 | @customElement('app-root') 13 | export class AppRoot extends LitElement { 14 | static styles = [ 15 | css` 16 | :host { 17 | font-size: 1.125rem; 18 | font-family: sans-serif; 19 | line-height: 1.5; 20 | } 21 | 22 | #wrapper { 23 | max-width: 80ch; 24 | } 25 | `, 26 | ]; 27 | 28 | @state() 29 | showModal4: boolean = false; 30 | 31 | dialogRef: Ref = createRef(); 32 | 33 | @state() 34 | currentTime: Date = new Date(); 35 | 36 | get timeString(): string { 37 | return this.currentTime.toLocaleTimeString([], { timeStyle: 'medium' }); 38 | } 39 | 40 | connectedCallback(): void { 41 | super.connectedCallback(); 42 | } 43 | 44 | disconnectedCallback(): void { 45 | super.disconnectedCallback(); 46 | } 47 | 48 | render() { 49 | return html` 50 |
51 |

lit-modal-portal Demo

52 |
53 | 54 | 55 | 56 | 57 | 58 | 59 |
60 | `; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /dev/demo-modify-container.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit'; 2 | import { customElement, queryAsync } from 'lit/decorators.js'; 3 | import { portal } from '../src/portal'; 4 | 5 | @customElement('demo-modify-container') 6 | export class DemoModifyContainer extends LitElement { 7 | static styles = [ 8 | css` 9 | .portal-target { 10 | border: 1px solid black; 11 | padding: 0.25rem; 12 | } 13 | 14 | h3 { 15 | margin-top: 0; 16 | margin-bottom: 0.25rem; 17 | line-height: 1; 18 | } 19 | `, 20 | ]; 21 | 22 | @queryAsync('#portal-target') 23 | portalTarget: Promise; 24 | 25 | render() { 26 | return html`

Modifying Portal Containers

27 |

28 | Portals wrap their content in a container <div>. If you need to make 29 | adjustments to a portal's container, you may provide a modifyContainer function 30 | to the portal directive's options. 31 |

32 |

33 | In the example below, the portal's container is styled directly using a 34 | modifyContainer function. 35 |

36 |
37 |

Portal target

38 |
39 | ${portal( 40 | html`

This is the portal content. Its container has a red border and some padding.

`, 41 | this.portalTarget, 42 | { 43 | modifyContainer: (c) => { 44 | c.style.border = '2px solid red'; 45 | c.style.padding = '0.5rem'; 46 | }, 47 | }, 48 | )}`; 49 | } 50 | } 51 | 52 | declare global { 53 | interface HTMLElementTagNameMap { 54 | 'demo-modify-container': DemoModifyContainer; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lit-modal-portal", 3 | "version": "0.8.0", 4 | "description": "A custom portal directive for the Lit framework to render content elsewhere in the DOM", 5 | "keywords": [ 6 | "Lit", 7 | "modal", 8 | "portal", 9 | "custom element", 10 | "directive" 11 | ], 12 | "homepage": "https://github.com/nicholas-wilcox/lit-modal-portal#readme", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/nicholas-wilcox/lit-modal-portal.git" 16 | }, 17 | "bugs": { 18 | "url": "https://github.com/nicholas-wilcox/lit-modal-portal/issues" 19 | }, 20 | "license": "MIT", 21 | "author": "Nicholas Wilcox", 22 | "files": [ 23 | "/README.md", 24 | "/CHANGELOG.md", 25 | "/LICENSE.txt", 26 | "/index.{js,js.map,d.ts,d.ts.map}", 27 | "/portal.{js,js.map,d.ts,d.ts.map}" 28 | ], 29 | "main": "./index.js", 30 | "type": "module", 31 | "scripts": { 32 | "build": "npm run clean && npm run tsc && npm run esbuild", 33 | "build:watch": "concurrently \"npm:tsc:watch\" \"npm:esbuild:watch\"", 34 | "clean": "rimraf --glob ./index.* ./portal.*", 35 | "dev": "wds --watch", 36 | "docs": "typedoc", 37 | "docs:watch": "typedoc --watch", 38 | "esbuild": "node esbuild.config.js", 39 | "esbuild:watch": "node esbuild.config.js -w", 40 | "lint": "prettier -w .", 41 | "tsc": "tsc", 42 | "tsc:watch": "tsc -w", 43 | "test": "web-test-runner --puppeteer" 44 | }, 45 | "devDependencies": { 46 | "@open-wc/testing": "^4.0.0", 47 | "@types/mocha": "^10.0.6", 48 | "@web/dev-server": "^0.4.3", 49 | "@web/dev-server-esbuild": "^1.0.2", 50 | "@web/test-runner": "^0.20.0", 51 | "@web/test-runner-puppeteer": "^0.18.0", 52 | "concurrently": "^9.1.2", 53 | "esbuild": "^0.25.0", 54 | "prettier": "^3.2.5", 55 | "rimraf": "^6.0.1", 56 | "sinon": "^19.0.2", 57 | "ts-lit-plugin": "^2.0.2", 58 | "typedoc": "^0.27.7", 59 | "typescript": "^5.7.3", 60 | "yargs-parser": "^21.0.1" 61 | }, 62 | "peerDependencies": { 63 | "lit": ">=2" 64 | }, 65 | "types": "./index.d.ts" 66 | } 67 | -------------------------------------------------------------------------------- /dev/example-component.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, css, html, PropertyValueMap } from 'lit'; 2 | import { customElement, state } from 'lit/decorators.js'; 3 | 4 | @customElement('example-component') 5 | export class ExampleComponent extends LitElement { 6 | static styles = [ 7 | css` 8 | #wrapper { 9 | border: 1px solid black; 10 | padding: 0.25rem; 11 | } 12 | 13 | h3 { 14 | margin: 0; 15 | } 16 | `, 17 | ]; 18 | 19 | @state() 20 | name: string = 'Your name here'; 21 | 22 | constructor() { 23 | super(); 24 | console.log('ExampleComponent::constructor()'); 25 | } 26 | 27 | connectedCallback() { 28 | super.connectedCallback(); 29 | console.log('ExampleComponent::connectedCallback()'); 30 | } 31 | 32 | disconnectedCallback() { 33 | super.disconnectedCallback(); 34 | console.log('ExampleComponent::disconnectedCallback()'); 35 | } 36 | 37 | firstUpdated(_changedProperties: PropertyValueMap | Map) { 38 | console.log('ExampleComponent::firstUpdated()'); 39 | } 40 | 41 | updated(_changedProperties: PropertyValueMap | Map) { 42 | console.log('ExampleComponent::updated()'); 43 | } 44 | 45 | willUpdate(_changedProperties: PropertyValueMap | Map) { 46 | console.log('ExampleComponent::willUpdate()'); 47 | } 48 | 49 | render() { 50 | return html`
51 |

Example component

52 |

53 | This component is a modified version of the <name-tag> component that is 54 | developed in 55 | Lit's interactive tutorial. 58 |

59 |

Hello, ${this.name}

60 | 61 |
`; 62 | } 63 | 64 | changeName(event: Event) { 65 | const input = event.target as HTMLInputElement; 66 | this.name = input.value; 67 | } 68 | } 69 | 70 | declare global { 71 | interface HTMLElementTagNameMap { 72 | 'example-component': ExampleComponent; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /dev/demo-intro.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from 'lit'; 2 | import { customElement } from 'lit/decorators.js'; 3 | 4 | import './demo-intro-example'; 5 | 6 | @customElement('demo-intro') 7 | export class DemoIntro extends LitElement { 8 | render() { 9 | return html` 10 |

Introduction

11 |

12 | The lit-modal-portal package provides a portal directive. This 13 | directive works like 14 | React's createPortal function, in the sense that you provide the content and the target DOM node you want the content 17 | rendered in. When the portal directive renders, it will create a container 18 | <div> as a child of the target. The content provided to the directive 19 | will be rendered in this container using 20 | Lit's render function. 23 |

24 |

25 | The portal directive is a custom, asynchronous directive (see 26 | Lit documentation). This means it has a lifecycle and can automatically remove the content (and its 29 | container) whenever the directive itself is removed. 30 |

31 |

32 | In the component below, there are three buttons that toggle on (i.e. set to 33 | true) some reactive boolean flags in the component's internal state. Each flag 34 | is used to optionally render a portal, and the content of each portal contains a button to 35 | turn the respective boolean flag back to off. Each portal targets the same 36 | root, which is a <div> that is also in the component. 37 |

38 | 39 |

40 | This results in a stack. A portal's content is added to (or removed from) the stack when the 41 | respective portal directive is included (or excluded) from the template. 42 |

43 | `; 44 | } 45 | } 46 | 47 | declare global { 48 | interface HTMLElementTagNameMap { 49 | 'demo-intro': DemoIntro; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /dev/demo-delegation.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, css } from 'lit'; 2 | import { customElement, property, state, queryAsync } from 'lit/decorators.js'; 3 | import { when } from 'lit/directives/when.js'; 4 | import { portal } from '../src/portal'; 5 | 6 | @customElement('dynamic-portal') 7 | export class DynamicPortal extends LitElement { 8 | static styles = [ 9 | css` 10 | .dynamic-portal { 11 | border: 1px solid black; 12 | padding: 0.25rem; 13 | } 14 | `, 15 | ]; 16 | 17 | @property({ attribute: false }) 18 | portalContent: unknown; 19 | 20 | @property({ attribute: false }) 21 | portalTarget: Promise; 22 | 23 | @state() 24 | enablePortal = false; 25 | 26 | render() { 27 | return html` 28 |
29 |

30 | This component manages a reactive boolean value that is toggled by the button below. By 31 | default, this component will render its provided content in-place. When the portal is 32 | enabled, the content will be rendered in the provided target with a portal. 33 |

34 | 35 | ${when( 36 | this.enablePortal, 37 | () => 38 | portal( 39 | html` 40 | ${this.portalContent} 41 |

Rendered in a portal.

42 | `, 43 | this.portalTarget, 44 | ), 45 | () => html` 46 | ${this.portalContent} 47 |

Rendered in-place.

48 | `, 49 | )} 50 |
51 | `; 52 | } 53 | } 54 | 55 | @customElement('demo-delegation') 56 | export class DemoDelegation extends LitElement { 57 | static styles = [ 58 | css` 59 | .portal-target { 60 | border: 1px solid black; 61 | padding: 0.25rem; 62 | margin-top: 0.5rem; 63 | } 64 | 65 | h3 { 66 | margin-top: 0; 67 | margin-bottom: 0.25rem; 68 | line-height: 1; 69 | } 70 | `, 71 | ]; 72 | 73 | @queryAsync('#portal-target') 74 | portalTarget: Promise; 75 | 76 | render() { 77 | return html` 78 |

Delegating portals

79 |

80 | You can create components that define certain portalling behaviors but use values provided 81 | by a parent component for the portal's content and target. 82 |

83 | This is the portal content.

`} 86 | >
87 |
88 |

Portal target

89 |
90 | `; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /dev/demo-intro-example.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit'; 2 | import { customElement, queryAsync, state } from 'lit/decorators.js'; 3 | import { when } from 'lit/directives/when.js'; 4 | import { styleMap } from 'lit/directives/style-map.js'; 5 | import { portal } from '../src/portal'; 6 | 7 | @customElement('demo-intro-example') 8 | export class DemoIntroExample extends LitElement { 9 | static styles = [ 10 | css` 11 | #wrapper { 12 | display: flex; 13 | flex-wrap: wrap; 14 | border: 1px solid black; 15 | align-items: stretch; 16 | gap: 0.5rem; 17 | padding: 0.25rem; 18 | } 19 | 20 | #buttons { 21 | display: flex; 22 | flex-direction: column; 23 | gap: 0.5rem; 24 | align-items: flex-start; 25 | } 26 | 27 | #portal-target { 28 | border: 1px solid black; 29 | padding: 0.25rem; 30 | display: flex; 31 | flex-direction: column; 32 | gap: 0.25rem; 33 | } 34 | 35 | #portal-target h3 { 36 | margin-top: 0; 37 | margin-bottom: 0.25rem; 38 | line-height: 1; 39 | } 40 | `, 41 | ]; 42 | 43 | @state() 44 | enablePortal1 = false; 45 | 46 | @state() 47 | enablePortal2 = false; 48 | 49 | @state() 50 | enablePortal3 = false; 51 | 52 | @queryAsync('#portal-target') 53 | portalTarget: Promise; 54 | 55 | portalContentStyles = { 56 | borderWidth: '2px', 57 | borderStyle: 'solid', 58 | padding: '0.25rem', 59 | }; 60 | 61 | portalContent(label: string, borderColor: string, closeCallback: Function) { 62 | return html` 63 |
64 |

${label} content

65 | 66 |
67 | `; 68 | } 69 | 70 | render() { 71 | return html` 72 |
73 |
74 | 75 | ${when(this.enablePortal1, () => 76 | portal( 77 | this.portalContent('Portal 1', 'red', () => (this.enablePortal1 = false)), 78 | this.portalTarget, 79 | ), 80 | )} 81 | 82 | 83 | ${when(this.enablePortal2, () => 84 | portal( 85 | this.portalContent('Portal 2', 'blue', () => (this.enablePortal2 = false)), 86 | this.portalTarget, 87 | ), 88 | )} 89 | 90 | 91 | ${when(this.enablePortal3, () => 92 | portal( 93 | this.portalContent('Portal 3', 'green', () => (this.enablePortal3 = false)), 94 | this.portalTarget, 95 | ), 96 | )} 97 |
98 |
99 |

Portal target

100 |
101 |
102 | `; 103 | } 104 | } 105 | 106 | declare global { 107 | interface HTMLElementTagNameMap { 108 | 'demo-intro-example': DemoIntroExample; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /docs/assets/highlight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-hl-0: #001080; 3 | --dark-hl-0: #9CDCFE; 4 | --light-hl-1: #000000; 5 | --dark-hl-1: #D4D4D4; 6 | --light-hl-2: #008000; 7 | --dark-hl-2: #6A9955; 8 | --light-hl-3: #800000; 9 | --dark-hl-3: #808080; 10 | --light-hl-4: #800000; 11 | --dark-hl-4: #569CD6; 12 | --light-hl-5: #E50000; 13 | --dark-hl-5: #9CDCFE; 14 | --light-hl-6: #000000FF; 15 | --dark-hl-6: #D4D4D4; 16 | --light-hl-7: #0000FF; 17 | --dark-hl-7: #CE9178; 18 | --light-hl-8: #AF00DB; 19 | --dark-hl-8: #C586C0; 20 | --light-hl-9: #A31515; 21 | --dark-hl-9: #CE9178; 22 | --light-hl-10: #795E26; 23 | --dark-hl-10: #DCDCAA; 24 | --light-hl-11: #0000FF; 25 | --dark-hl-11: #569CD6; 26 | --light-hl-12: #267F99; 27 | --dark-hl-12: #4EC9B0; 28 | --light-code-background: #FFFFFF; 29 | --dark-code-background: #1E1E1E; 30 | } 31 | 32 | @media (prefers-color-scheme: light) { :root { 33 | --hl-0: var(--light-hl-0); 34 | --hl-1: var(--light-hl-1); 35 | --hl-2: var(--light-hl-2); 36 | --hl-3: var(--light-hl-3); 37 | --hl-4: var(--light-hl-4); 38 | --hl-5: var(--light-hl-5); 39 | --hl-6: var(--light-hl-6); 40 | --hl-7: var(--light-hl-7); 41 | --hl-8: var(--light-hl-8); 42 | --hl-9: var(--light-hl-9); 43 | --hl-10: var(--light-hl-10); 44 | --hl-11: var(--light-hl-11); 45 | --hl-12: var(--light-hl-12); 46 | --code-background: var(--light-code-background); 47 | } } 48 | 49 | @media (prefers-color-scheme: dark) { :root { 50 | --hl-0: var(--dark-hl-0); 51 | --hl-1: var(--dark-hl-1); 52 | --hl-2: var(--dark-hl-2); 53 | --hl-3: var(--dark-hl-3); 54 | --hl-4: var(--dark-hl-4); 55 | --hl-5: var(--dark-hl-5); 56 | --hl-6: var(--dark-hl-6); 57 | --hl-7: var(--dark-hl-7); 58 | --hl-8: var(--dark-hl-8); 59 | --hl-9: var(--dark-hl-9); 60 | --hl-10: var(--dark-hl-10); 61 | --hl-11: var(--dark-hl-11); 62 | --hl-12: var(--dark-hl-12); 63 | --code-background: var(--dark-code-background); 64 | } } 65 | 66 | :root[data-theme='light'] { 67 | --hl-0: var(--light-hl-0); 68 | --hl-1: var(--light-hl-1); 69 | --hl-2: var(--light-hl-2); 70 | --hl-3: var(--light-hl-3); 71 | --hl-4: var(--light-hl-4); 72 | --hl-5: var(--light-hl-5); 73 | --hl-6: var(--light-hl-6); 74 | --hl-7: var(--light-hl-7); 75 | --hl-8: var(--light-hl-8); 76 | --hl-9: var(--light-hl-9); 77 | --hl-10: var(--light-hl-10); 78 | --hl-11: var(--light-hl-11); 79 | --hl-12: var(--light-hl-12); 80 | --code-background: var(--light-code-background); 81 | } 82 | 83 | :root[data-theme='dark'] { 84 | --hl-0: var(--dark-hl-0); 85 | --hl-1: var(--dark-hl-1); 86 | --hl-2: var(--dark-hl-2); 87 | --hl-3: var(--dark-hl-3); 88 | --hl-4: var(--dark-hl-4); 89 | --hl-5: var(--dark-hl-5); 90 | --hl-6: var(--dark-hl-6); 91 | --hl-7: var(--dark-hl-7); 92 | --hl-8: var(--dark-hl-8); 93 | --hl-9: var(--dark-hl-9); 94 | --hl-10: var(--dark-hl-10); 95 | --hl-11: var(--dark-hl-11); 96 | --hl-12: var(--dark-hl-12); 97 | --code-background: var(--dark-code-background); 98 | } 99 | 100 | .hl-0 { color: var(--hl-0); } 101 | .hl-1 { color: var(--hl-1); } 102 | .hl-2 { color: var(--hl-2); } 103 | .hl-3 { color: var(--hl-3); } 104 | .hl-4 { color: var(--hl-4); } 105 | .hl-5 { color: var(--hl-5); } 106 | .hl-6 { color: var(--hl-6); } 107 | .hl-7 { color: var(--hl-7); } 108 | .hl-8 { color: var(--hl-8); } 109 | .hl-9 { color: var(--hl-9); } 110 | .hl-10 { color: var(--hl-10); } 111 | .hl-11 { color: var(--hl-11); } 112 | .hl-12 { color: var(--hl-12); } 113 | pre, code { background: var(--code-background); } 114 | -------------------------------------------------------------------------------- /dev/demo-async.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit'; 2 | import { customElement, queryAsync } from 'lit/decorators.js'; 3 | import { portal } from '../src/portal'; 4 | 5 | @customElement('demo-async') 6 | export class DemoAsync extends LitElement { 7 | static styles = [ 8 | css` 9 | .wrapper { 10 | display: flex; 11 | flex-wrap: wrap; 12 | border: 1px solid black; 13 | align-items: stretch; 14 | gap: 0.5rem; 15 | padding: 0.25rem; 16 | } 17 | 18 | h3 { 19 | margin-top: 0; 20 | margin-bottom: 0.25rem; 21 | line-height: 1; 22 | } 23 | 24 | .portal-target { 25 | flex-basis: 0; 26 | flex-grow: 1; 27 | border: 1px solid black; 28 | padding: 0.25rem; 29 | } 30 | `, 31 | ]; 32 | 33 | @queryAsync('#portal-target') 34 | portalTarget: Promise; 35 | 36 | @queryAsync('#second-portal-target') 37 | secondPortalTarget: Promise; 38 | 39 | async getAsynchronousContent(content: unknown) { 40 | const sleepPromise = new Promise((resolve) => setTimeout(resolve, 3000)); 41 | return sleepPromise.then(() => content); 42 | } 43 | 44 | render() { 45 | return html` 46 |

Asynchronous Content

47 |

48 | You can provide a 49 | Promise 53 | to the portal directive, both for the content of the portal as well as the 54 | target. 55 |

56 |

57 | Using a promise for the target can be useful when the target is part of a Lit component that 58 | may have not yet rendered or can change. The examples earlier in this demo used Lit's 59 | queryAsync 62 | decorator for exactly these reasons. 63 |

64 |

65 | Using a promise for the portal content is useful when the content relies on asynchronous 66 | data, such as that received from an HTTPRequest. 67 |

68 |
69 |

70 | This example renders one portal immediately when the page loads and another portal after a 71 | few seconds. You may refresh this webpage in order to repeat the example. 72 |

73 | ${portal(html`

This portal renders immediately.

`, this.portalTarget)} 74 | ${portal( 75 | this.getAsynchronousContent(html`

This portal renders three seconds later.

`), 76 | this.portalTarget, 77 | )} 78 |
79 |

Portal target

80 |
81 |
82 |

You can provide a placeholder value to render until the content promise resolves.

83 |
84 |

85 | This example is similar to the previous one, except the second portal is also rendered 86 | immediately with placeholder content. Like before, you may refresh the page to observe the 87 | placeholder being replaced with the other content. 88 |

89 | ${portal(html`

This portal renders immediately.

`, this.secondPortalTarget)} 90 | ${portal( 91 | this.getAsynchronousContent( 92 | html`

93 | This portal rendered first with a placeholder and was updated after three seconds. 94 |

`, 95 | ), 96 | this.secondPortalTarget, 97 | { 98 | placeholder: html`

Placeholder

`, 99 | }, 100 | )} 101 |
102 |

Portal target

103 |
104 |

105 | Observe that the portal with a placeholder appears above the other portal. This behaviour 106 | is unexpected and possibly results from a race condition, since both portals have the same 107 | target. 108 |

109 |
110 | `; 111 | } 112 | } 113 | 114 | declare global { 115 | interface HTMLElementTagNameMap { 116 | 'demo-async': DemoAsync; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Added 11 | - Notes about creating components that delegate portal behavior and render generic content provided by parent components. 12 | 13 | ## [0.8.0] - 2025-05-03 14 | 15 | ### Added 16 | - Add `modifyContainer` function to `portal` directive options. 17 | 18 | ### Fixed 19 | - Fixed bugs in esbuild.config.js 20 | 21 | ## [0.7.1] - 2025-02-16 22 | 23 | ### Fixed 24 | - Fix typo in typedoc 25 | - Typos in README 26 | 27 | ### Changed 28 | - Updated dev dependencies 29 | 30 | ## [0.7.0] - 2024-04-17 31 | 32 | ### Fixed 33 | - Added `TargetOrSelector` type alias to typedoc. 34 | 35 | ### Changed 36 | - Improved how portals can be cleaned up during tests. 37 | - The disconnected method on the `portal` directive now checks that the portal container is in the target's children. 38 | If not, then a warning will be printed that the container was already removed from the target. 39 | 40 | The prevalence of this issue is unknown, but it was encountered while working with components 41 | in portals rendered using `open-wc`'s `fixture` helper. 42 | - Refactor lambdas out of tests to support setting the Mocha timeout limit. 43 | - Refactor the `portal` directive's `value` argument to be named `content`. 44 | 45 | ### Added 46 | - Tests for Lit lifecycle methods called on components that are in portals. 47 | - Support for asynchronous portal content, with tests and demo code. 48 | - Portals with asynchronous content can render a placeholder while the content resolves. 49 | 50 | ## [0.6.2] - 2024-04-09 51 | 52 | ### Changed 53 | - Refactored type of portal target to be `Node` instead of `HTMLElement`. 54 | 55 | ## [0.6.1] - 2024-04-07 56 | 57 | ### Changed 58 | - Removed the "(currently unreleased)" text from the README notice about version 0.6 59 | 60 | ## [0.6.0] - 2024-04-07 61 | 62 | Major refactor. 63 | 64 | ### Added 65 | - Some tests! Can you even believe that? 66 | 67 | ### Changed 68 | - Almost everything, but especially the `portal` directive 69 | 70 | ### Removed 71 | - Almost everything else, notably the `modalController` and the `` component. 72 | 73 | ## [0.5.0-pre] - 2024-03-27 74 | Thanks to [klasjersevi](https://github.com/klasjersevi) 75 | 76 | ### Changed 77 | - Updated dependencies. 78 | - Explicitly set tsconfig in esbuild usage. 79 | 80 | ### Fixed 81 | - Set Lit to be a peer dependency. 82 | 83 | ## [0.4.1] - 2022-06-16 84 | ### Fixed 85 | - Example code in README.md 86 | 87 | ## [0.4.0] - 2022-06-16 88 | ### Added 89 | - Demo video in README.md 90 | 91 | ### Removed 92 | - Minified build 93 | 94 | ## [0.3.1] - 2022-06-13 95 | ### Changed 96 | - Refactored the modules and exports again to permit usage of the minified build. 97 | 98 | ### Added 99 | - All the things that should be in a README. 100 | 101 | ## [0.3.0] - 2022-06-13 102 | ### Changed 103 | - Reconfigured `index` and `lib` modules to actually work for both docs and exports. 104 | 105 | ## [0.2.1] - 2022-06-10 106 | ### Changed 107 | - Set immutable-js to be a regular dependency. 108 | 109 | ### Fixed 110 | - The links in the changelog, lol. 111 | 112 | ## [0.2.0] - 2022-06-10 113 | First release tag. 114 | 115 | [Unreleased]: https://github.com/nicholas-wilcox/lit-modal-portal/compare/v0.8.0...HEAD 116 | [0.8.0]: https://github.com/nicholas-wilcox/lit-modal-portal/compare/v0.7.1...v0.8.0 117 | [0.7.1]: https://github.com/nicholas-wilcox/lit-modal-portal/compare/v0.7.0...v0.7.1 118 | [0.7.0]: https://github.com/nicholas-wilcox/lit-modal-portal/compare/v0.6.2...v0.7.0 119 | [0.6.2]: https://github.com/nicholas-wilcox/lit-modal-portal/compare/v0.6.1...v0.6.2 120 | [0.6.1]: https://github.com/nicholas-wilcox/lit-modal-portal/compare/v0.6.0...v0.6.1 121 | [0.6.0]: https://github.com/nicholas-wilcox/lit-modal-portal/compare/v0.5.0-pre...v0.6.0 122 | [0.5.0-pre]: https://github.com/nicholas-wilcox/lit-modal-portal/compare/v0.4.1...v0.5.0-pre 123 | [0.4.1]: https://github.com/nicholas-wilcox/lit-modal-portal/compare/v0.4.0...v0.4.1 124 | [0.4.0]: https://github.com/nicholas-wilcox/lit-modal-portal/compare/v0.3.1...v0.4.0 125 | [0.3.1]: https://github.com/nicholas-wilcox/lit-modal-portal/compare/v0.3.0...v0.3.1 126 | [0.3.0]: https://github.com/nicholas-wilcox/lit-modal-portal/compare/v0.2.1...v0.3.0 127 | [0.2.1]: https://github.com/nicholas-wilcox/lit-modal-portal/compare/v0.2.0...v0.2.1 128 | [0.2.0]: https://github.com/nicholas-wilcox/lit-modal-portal/releases/tag/v0.2.0 129 | -------------------------------------------------------------------------------- /docs/types/TargetOrSelector.html: -------------------------------------------------------------------------------- 1 | TargetOrSelector | Lit Modal Portal Documentation
TargetOrSelector: Node | string

The acceptable types used to specify a portal target.

2 |
3 | -------------------------------------------------------------------------------- /dev/demo-reactive.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit'; 2 | import { customElement, state, queryAsync } from 'lit/decorators.js'; 3 | import { when } from 'lit/directives/when.js'; 4 | import { portal } from '../src/portal'; 5 | 6 | import './example-component'; 7 | 8 | @customElement('demo-reactive') 9 | export class DemoReactive extends LitElement { 10 | static styles = [ 11 | css` 12 | .wrapper { 13 | display: flex; 14 | flex-wrap: wrap; 15 | border: 1px solid black; 16 | align-items: stretch; 17 | gap: 0.5rem; 18 | padding: 0.25rem; 19 | } 20 | 21 | .current-time, 22 | .portal-target { 23 | border: 1px solid black; 24 | padding: 0.25rem; 25 | } 26 | 27 | h3 { 28 | margin-top: 0; 29 | margin-bottom: 0.25rem; 30 | line-height: 1; 31 | } 32 | 33 | .multi-portal-target-wrapper { 34 | flex-grow: 1; 35 | } 36 | .multi-portal-target-subwrapper { 37 | display: flex; 38 | flex-wrap: wrap; 39 | gap: 0.25rem; 40 | } 41 | 42 | .multi-portal-target-subwrapper .portal-target { 43 | flex-basis: 0; 44 | flex-grow: 1; 45 | } 46 | 47 | #component-portal-target { 48 | flex-basis: 100%; 49 | } 50 | `, 51 | ]; 52 | 53 | @state() 54 | timeString = new Date().toLocaleTimeString(); 55 | 56 | @state() 57 | multiPortalTargetIndex = 0; 58 | 59 | intervalId = undefined; 60 | 61 | @queryAsync('#single-portal-target') 62 | singlePortalTarget: Promise; 63 | 64 | @queryAsync('.portal-target[data-current-portal-target=true]') 65 | multiPortalTarget: Promise; 66 | 67 | @state() 68 | showComponentPortal = false; 69 | 70 | @queryAsync('#component-portal-target') 71 | componentPortalTarget: Promise; 72 | 73 | connectedCallback() { 74 | super.connectedCallback(); 75 | this.intervalId = setInterval(() => { 76 | this.timeString = new Date().toLocaleTimeString(); 77 | this.multiPortalTargetIndex = (this.multiPortalTargetIndex + 1) % 3; 78 | }, 1000); 79 | } 80 | 81 | disconnectedCallback() { 82 | super.disconnectedCallback(); 83 | clearInterval(this.intervalId); 84 | } 85 | 86 | render() { 87 | return html` 88 |

Reactive Updates

89 |

90 | When the component that consumes the portal directive re-renders and changes 91 | the content of the portal, the directive will re-render the new content in the same 92 | container. In other words, portals are reactive. 93 |

94 |

95 | This section of the demo is a component that manages an 96 | interval 99 | to update its internal reactive state once per second. The current time is rendered both 100 | directly in the component's template and also in the content of a portal. 101 |

102 |
103 |
104 |

Current time

105 |

${this.timeString}

106 | ${portal(html`

${this.timeString}

`, this.singlePortalTarget)} 107 |
108 |
109 |

Portal target

110 |
111 |
112 | 113 | 114 |

115 | In addition to updating the content of a portal, 116 | you can also update a portal's target. This will remove the portal's content from 117 | the old target and append it to the new target. (Credit to 118 | ronak-lm's lit-portal package 121 | for designing and implementing this behavior before I did.) 122 |

123 |
124 |
125 |

Current time

126 |

${this.timeString}

127 | ${portal(html`

${this.timeString}

`, this.multiPortalTarget)} 128 |
129 |
130 |

Portal targets

131 |
132 |
137 |
142 |
147 |
148 |
149 |
150 | 151 | 152 |

153 | Finally, as you would expect, a Lit component can be rendered through a portal and still 154 | work. 155 |

156 |
157 | ${when(this.showComponentPortal, () => 158 | portal(html``, this.componentPortalTarget), 159 | )} 160 |

161 | Right above this paragraph's <p> tag is a portal directive 162 | that renders an example component in the following portal target <div>. 163 |

164 |

165 | The portal can be toggled using the button below this text. You can observe that the 166 | components lifecycle methods are called appropriately by looking at the console logs. 167 |

168 | 171 |
172 |

Portal target

173 |
174 |
175 | `; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /dev/demo-modals.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from 'lit'; 2 | import { StyleInfo, styleMap } from 'lit/directives/style-map.js'; 3 | import { customElement, state } from 'lit/decorators.js'; 4 | import { ref, createRef } from 'lit/directives/ref.js'; 5 | import { when } from 'lit/directives/when.js'; 6 | import { portal } from '../src/portal'; 7 | import './confirmation-dialog'; 8 | 9 | const modalStyles: StyleInfo = { 10 | position: 'fixed', 11 | background: 'hsl(0deg 0% 0% / 0.4)', 12 | inset: 0, 13 | padding: '1rem', 14 | }; 15 | 16 | const modalContentStyles: StyleInfo = { 17 | background: 'white', 18 | padding: '1rem', 19 | }; 20 | 21 | @customElement('demo-modals') 22 | export class DemoModals extends LitElement { 23 | @state() 24 | enableModalPortal = false; 25 | 26 | dialogRef = createRef(); 27 | 28 | confirmationDialogRef = createRef(); 29 | 30 | @state() 31 | confirmationResponse: unknown; 32 | 33 | render() { 34 | return html` 35 |

Modals

36 |

37 | The portal directive can be used to display modal content in the same way as 38 | React's createPortal function. In other words, simply target 39 | document.body. (Although you may prefer to create a dedicated 40 | <div> within the <body> and target that element 41 | instead.) 42 |

43 | 44 | 45 |

The button below will enable a portal that contains modal content.

46 | 47 | ${when(this.enableModalPortal, () => 48 | portal( 49 | html`
50 |
51 |

52 | This is the modal. It is a <div> with fixed positioning, zero 53 | insets, and a slightly transparent background color. 54 |

55 | 56 |
57 |
`, 58 | document.body, 59 | ), 60 | )} 61 | 62 | 63 |

64 | Alternatively, you can use the portal directive with 65 | <dialog> elements. Rather than conditionally including a portal based on 66 | a boolean flag, you can simply render the <dialog> in the portal and 67 | imperatively call the 68 | HTMLDialogElement.showModal() method. 73 |

74 |

75 | The button below does exactly that by using a 76 | Lit ref 81 | that is attached to the <dialog> element used in the portal. 82 |

83 | 84 | ${portal( 85 | html` 86 |

87 | This is a paragraph in a <dialog> element. You are seeing it because 88 | its showModal function was called. 89 |

90 |

91 | You may close the dialog by clicking the button below or by pressing the Escape key. 92 |

93 | 94 |
`, 95 | document.body, 96 | )} 97 | 98 |

Confirmation Dialogs

99 |

Putting all of this together, we can create a confirmation dialog component.

100 | 103 | ${portal( 104 | html` { 106 | this.confirmationResponse = this.confirmationDialogRef.value?.returnValue; 107 | }} 108 | .dialogRef=${this.confirmationDialogRef} 109 | >`, 110 | document.body, 111 | )} 112 | ${when( 113 | this.confirmationResponse, 114 | () => html`Return value: ${this.confirmationResponse}`, 115 | )} 116 |

117 | The component shown with the button above accepts a Lit ref as a property. This means that 118 | the parent component can call the dialog's showModal method to open the dialog. 119 | The <confirmation-dialog> component that contains the dialog has two 120 | buttons that each close it, but with a different return value. (See 121 | MDN documentation 126 | on the HTMLDialogElement.close() method.) 127 |

128 |

129 | Additionally, the 130 | <confirmation-dialog> component listens to and forwards the 131 | <dialog>'s close event. This allows the parent component to 132 | react and update based on the return value. Note that closing the dialog with the Escape key 133 | does not change the returnValue attribute of the dialog. 134 |

135 |

136 | On a final note, consider that there are many different ways that you might want to 137 | implement and use a modal. You may want to avoid using a <dialog> tag, or 138 | you may prefer a component that accepts callback functions over a component that dispatches 139 | events. For these reasons, I've decided to focus on the utility of the 140 | portal directive and not provide any modal components in this package's 141 | exports. 142 |

143 | `; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/portal.ts: -------------------------------------------------------------------------------- 1 | import { render as litRender, nothing } from 'lit'; 2 | import { directive } from 'lit/directive.js'; 3 | import { AsyncDirective } from 'lit/async-directive.js'; 4 | 5 | /** 6 | * The acceptable types used to specify a portal target. 7 | */ 8 | export type TargetOrSelector = Node | string; 9 | 10 | /** 11 | * @property placeholder - When provided, `placeholder` will be immediately rendered in the portal. 12 | * Assuming that `content` is a promise, it will replace the placeholder once it resolves. 13 | * 14 | * @property modifyContainer - When provided, the `modifyContainer` function will be called with 15 | * the portal's container given as the argument. This allows you to programmatically control the 16 | * container before the portal renders. 17 | */ 18 | export interface PortalOptions { 19 | placeholder?: unknown; 20 | modifyContainer?: (container: HTMLElement) => void; 21 | } 22 | 23 | /** 24 | * Utility function to get an HTMLElement by reference or by a document query selector. 25 | */ 26 | function getTarget(targetOrSelector: TargetOrSelector): Node { 27 | let target = targetOrSelector; 28 | // Treat the argument as a query selector if it's a string. 29 | if (typeof target === 'string') { 30 | target = document.querySelector(target) as Node; 31 | if (target === null) { 32 | throw Error(`Could not locate portal target with selector "${targetOrSelector}".`); 33 | } 34 | } 35 | 36 | return target; 37 | } 38 | 39 | /** 40 | * A directive to render a Lit template somewhere in the DOM. 41 | * 42 | * See [Lit docs on Custom Directives](https://lit.dev/docs/templates/custom-directives/). 43 | */ 44 | export class PortalDirective extends AsyncDirective { 45 | private containerId = `portal-${self.crypto.randomUUID()}`; 46 | private container: HTMLElement | undefined; 47 | private target: Node | undefined; 48 | 49 | /** 50 | * Main render function for the directive. 51 | * 52 | * For clarity's sake, here is the outline of the function body:: 53 | * 54 | * - Resolve `targetOrSelector` to an element. 55 | * 56 | * - If the directive's `container` property is `undefined`, 57 | * - then create the container element and store it in the property. 58 | * 59 | * - If `modifyContainer` is provided in the `options`, 60 | * - then call `modifyContainer(container)`. 61 | * 62 | * - If the target has changed from one element to another, 63 | * - then migrate `container` to the new target and reassign the directive's `target` property. 64 | * 65 | * - If the directive's `target` property is `undefined`, 66 | * - then store the target element in the property. 67 | * 68 | * - If a `placeholder` is provided in the `options`, 69 | * - then append `container` to `target` (if necessary) and render `placeholder` in `container`. 70 | * 71 | * - Resolve `content` (awaited). 72 | * 73 | * - Append `container` to `target` (if necessary) and render `content` in `container`. 74 | * 75 | * The steps are organized this way to balance the initalization and refreshing of crucial properties 76 | * like `container` and `target` while ensuring that `container` isn't added to the DOM until 77 | * the directive is about to render something (either `placeholder` or `content`). 78 | * 79 | * @param content - The content of the portal. 80 | * This parameter is passed as the `value` parameter in [Lit's `render` function](https://lit.dev/docs/api/templates/#render). 81 | * 82 | * The `content` parameter can be a promise, which will be rendered in the portal once it resolves. 83 | * 84 | * @param targetOrSelector - The "target" for the portal. 85 | * If the value is a string, then it is treated as a query selector and passed to `document.querySelector()` in order to locate the portal target. 86 | * If no element is found with the selector, then an error is thrown. 87 | * 88 | * @param options - See {@link PortalOptions}. 89 | * 90 | * @returns This function always returns Lit's [`nothing`](https://lit.dev/docs/api/templates/#nothing) value, 91 | * because nothing ever renders where the portal is used. 92 | */ 93 | render( 94 | content: unknown | Promise, 95 | targetOrSelector: TargetOrSelector | Promise, 96 | options?: PortalOptions, 97 | ) { 98 | // Resolve targetOrSelector first, because nothing can happen without that. 99 | Promise.resolve(targetOrSelector).then(async (targetOrSelector) => { 100 | if (!targetOrSelector) { 101 | throw Error( 102 | "Target was falsy. Are you using a Lit ref before its value is defined? If so, try using Lit's @queryAsync decorator instead (https://lit.dev/docs/api/decorators/#queryAsync).", 103 | ); 104 | } 105 | const newTarget = getTarget(targetOrSelector); 106 | 107 | // Create container if it doesn't already exist. 108 | if (!this.container) { 109 | const newContainer = document.createElement('div'); 110 | newContainer.id = this.containerId; 111 | if (options?.modifyContainer) { 112 | options.modifyContainer(newContainer); 113 | } 114 | this.container = newContainer; 115 | } 116 | 117 | // If we are getting a new target, then migrate the container. 118 | if (this.target && this.target !== newTarget) { 119 | this.target?.removeChild(this.container); 120 | newTarget.appendChild(this.container); 121 | this.target = newTarget; 122 | } 123 | 124 | // Set the target if it's undefined 125 | if (!this.target) { 126 | this.target = newTarget; 127 | 128 | // Render the placeholder if it's provided 129 | if (options?.placeholder) { 130 | // Only append the container to the target if we are about to render. 131 | if (!this.target.contains(this.container)) { 132 | this.target.appendChild(this.container); 133 | } 134 | litRender(options.placeholder, this.container); 135 | } 136 | } 137 | 138 | const resolvedContent = await Promise.resolve(content); 139 | 140 | // Add the container to the target if it isn't included already. 141 | if (!this.target.contains(this.container)) { 142 | this.target.appendChild(this.container); 143 | } 144 | 145 | litRender(resolvedContent, this.container); 146 | }); 147 | 148 | return nothing; 149 | } 150 | 151 | /** Remove container from target when the directive is disconnected. */ 152 | protected disconnected(): void { 153 | if (this.target?.contains(this.container)) { 154 | this.target?.removeChild(this.container); 155 | } else { 156 | console.warn( 157 | 'portal directive was disconnected after the portal container was removed from the target.', 158 | ); 159 | } 160 | } 161 | 162 | /** Append container to target when the directive is reconnected. */ 163 | protected reconnected(): void { 164 | this.target?.appendChild(this.container); 165 | } 166 | } 167 | 168 | /** 169 | * To be used in Lit templates. 170 | * 171 | * See {@link PortalDirective.render | PortalDirective.render} 172 | */ 173 | export const portal = directive(PortalDirective); 174 | -------------------------------------------------------------------------------- /docs/functions/portal.html: -------------------------------------------------------------------------------- 1 | portal | Lit Modal Portal Documentation

To be used in Lit templates.

2 |

See PortalDirective.render

3 |
4 | -------------------------------------------------------------------------------- /docs/modules.html: -------------------------------------------------------------------------------- 1 | Lit Modal Portal Documentation

Lit Modal Portal Documentation

Classes

PortalDirective

Interfaces

PortalOptions

Type Aliases

TargetOrSelector

Functions

portal
2 | -------------------------------------------------------------------------------- /src/portal.test.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, PropertyValueMap } from 'lit'; 2 | import { ref, createRef, Ref } from 'lit/directives/ref.js'; 3 | import { when } from 'lit/directives/when.js'; 4 | import { customElement, state, property } from 'lit/decorators.js'; 5 | import { expect, fixture, nextFrame } from '@open-wc/testing'; 6 | import { portal, PortalOptions } from './portal'; 7 | import sinon, { spy } from 'sinon'; 8 | 9 | @customElement('example-component-manager') 10 | export class ExampleComponentManager extends LitElement { 11 | @state() 12 | enablePortal = false; 13 | 14 | @property({ attribute: false }) 15 | buttonRef: Ref; 16 | 17 | @property({ attribute: false }) 18 | exampleComponentRef: Ref; 19 | 20 | render() { 21 | return html` 22 | 26 | ${when(this.enablePortal, () => 27 | portal(html``, document.body), 28 | )} 29 | `; 30 | } 31 | } 32 | 33 | @customElement('example-component') 34 | export class ExampleComponent extends LitElement { 35 | // If there is a better way to test the lifecycle methods than through a side-effect spy, 36 | // then I would like to hear about it. 37 | static lifecycleSpy = spy(); 38 | 39 | constructor() { 40 | super(); 41 | ExampleComponent.lifecycleSpy('constructor'); 42 | } 43 | 44 | connectedCallback(): void { 45 | super.connectedCallback(); 46 | ExampleComponent.lifecycleSpy('connectedCallback'); 47 | } 48 | 49 | protected willUpdate( 50 | _changedProperties: PropertyValueMap | Map, 51 | ): void { 52 | ExampleComponent.lifecycleSpy('willUpdate'); 53 | } 54 | 55 | protected firstUpdated( 56 | _changedProperties: PropertyValueMap | Map, 57 | ): void { 58 | ExampleComponent.lifecycleSpy('firstUpdated'); 59 | } 60 | 61 | protected updated(_changedProperties: PropertyValueMap | Map): void { 62 | ExampleComponent.lifecycleSpy('updated'); 63 | } 64 | 65 | disconnectedCallback(): void { 66 | super.disconnectedCallback(); 67 | ExampleComponent.lifecycleSpy('disconnectedCallback'); 68 | } 69 | 70 | render() { 71 | return html`

${PORTAL_CONTENT}

`; 72 | } 73 | } 74 | 75 | const PORTAL_CONTENT = 'portal content'; 76 | const PORTAL_CONTAINER_ID_REGEX = /portal(-[0-9a-f])+/; 77 | 78 | async function createPortalFixture(portalTarget: HTMLElement | string, options?: PortalOptions) { 79 | await fixture(html`${portal(html`

${PORTAL_CONTENT}

`, portalTarget, options)}`); 80 | } 81 | 82 | async function createPortalFixtureWithToggledComponent() { 83 | const buttonRef: Ref = createRef(); 84 | const exampleComponentRef: Ref = createRef(); 85 | const clickButtonAsync = async () => { 86 | buttonRef.value.click(); 87 | await nextFrame(); 88 | }; 89 | 90 | await fixture(html` 91 | 95 | `); 96 | 97 | return { exampleComponentRef, clickButtonAsync }; 98 | } 99 | 100 | async function createAsynchronousPortalFixture( 101 | portalTarget: HTMLElement | string, 102 | delay = 1000, 103 | placeholder?: unknown, 104 | ) { 105 | const sleepPromise = new Promise((resolve) => setTimeout(resolve, delay)); 106 | await fixture( 107 | html`${portal( 108 | sleepPromise.then(() => html`

${PORTAL_CONTENT}

`), 109 | portalTarget, 110 | { placeholder }, 111 | )}`, 112 | ); 113 | } 114 | 115 | describe('portal', async function () { 116 | const getPortalsInDocumentBody = () => document.body.querySelectorAll('[id|=portal]'); 117 | 118 | afterEach(function () { 119 | getPortalsInDocumentBody().forEach((portal) => portal.remove()); 120 | sinon.restore(); 121 | ExampleComponent.lifecycleSpy.resetHistory(); 122 | }); 123 | 124 | it('creates a portal in document.body', async function () { 125 | await createPortalFixture(document.body); 126 | 127 | expect(document.body.lastElementChild).to.be.instanceof(HTMLDivElement); 128 | expect(document.body.lastElementChild.id).match(PORTAL_CONTAINER_ID_REGEX); 129 | expect((document.body.lastElementChild as HTMLDivElement).innerText).to.equal(PORTAL_CONTENT); 130 | }); 131 | 132 | it('creates a portal in a div using a query selector', async function () { 133 | const portalTarget = document.createElement('div'); 134 | const portalTargetId = `portal-target`; 135 | portalTarget.id = portalTargetId; 136 | 137 | document.body.appendChild(portalTarget); 138 | await createPortalFixture('#portal-target'); 139 | 140 | expect(portalTarget.lastElementChild).to.be.instanceof(HTMLDivElement); 141 | expect(portalTarget.lastElementChild.id).match(PORTAL_CONTAINER_ID_REGEX); 142 | expect((portalTarget.lastElementChild as HTMLDivElement).innerText).to.equal(PORTAL_CONTENT); 143 | }); 144 | 145 | it('modifies the portal container', async function () { 146 | const portalTarget = document.createElement('div'); 147 | const portalTargetId = `portal-target`; 148 | portalTarget.id = portalTargetId; 149 | 150 | const className = 'modified-classname'; 151 | document.body.appendChild(portalTarget); 152 | await createPortalFixture('#portal-target', { 153 | modifyContainer: (div) => div.classList.add(className), 154 | }); 155 | 156 | expect(portalTarget.lastElementChild).to.be.instanceof(HTMLDivElement); 157 | expect(portalTarget.lastElementChild.id).match(PORTAL_CONTAINER_ID_REGEX); 158 | expect(portalTarget.lastElementChild.classList.contains(className)); 159 | }); 160 | 161 | describe('Lit lifecycle methods for components in portals', async function () { 162 | let clickButtonAsync: Function; 163 | 164 | beforeEach(async function () { 165 | ({ clickButtonAsync } = await createPortalFixtureWithToggledComponent()); 166 | expect(ExampleComponent.lifecycleSpy).to.have.not.been.called; 167 | }); 168 | 169 | it("calls a component's constructor method", async function () { 170 | await clickButtonAsync(); 171 | expect(ExampleComponent.lifecycleSpy.getCall(0).calledWith('constructor')); 172 | await clickButtonAsync(); 173 | }); 174 | 175 | it("calls a component's connectedCallback method", async function () { 176 | await clickButtonAsync(); 177 | expect(ExampleComponent.lifecycleSpy.getCall(1).calledWith('connectedCallback')); 178 | await clickButtonAsync(); 179 | }); 180 | 181 | it("calls a component's willUpdate method", async function () { 182 | await clickButtonAsync(); 183 | expect(ExampleComponent.lifecycleSpy.getCall(2).calledWith('willUpdate')); 184 | await clickButtonAsync(); 185 | }); 186 | 187 | it("calls a component's firstUpdated method", async function () { 188 | await clickButtonAsync(); 189 | expect(ExampleComponent.lifecycleSpy.getCall(3).calledWith('firstUpdated')); 190 | await clickButtonAsync(); 191 | }); 192 | 193 | it("calls a component's updated method", async function () { 194 | await clickButtonAsync(); 195 | expect(ExampleComponent.lifecycleSpy.getCall(4).calledWith('updated')); 196 | await clickButtonAsync(); 197 | }); 198 | 199 | it("calls a component's disconnectedCallback method", async function () { 200 | await clickButtonAsync(); 201 | await clickButtonAsync(); 202 | expect(ExampleComponent.lifecycleSpy).callCount(6); 203 | expect(ExampleComponent.lifecycleSpy.getCall(5).calledWith('disconnectedCallback')); 204 | }); 205 | }); 206 | 207 | describe('Asynchronous behavior', async function () { 208 | it("doesn't render a portal with asynchronous content until it resolves", async function () { 209 | this.timeout(2000); 210 | 211 | await createAsynchronousPortalFixture(document.body, 1000); 212 | expect(getPortalsInDocumentBody()).to.be.empty; 213 | 214 | await new Promise((resolve) => setTimeout(resolve, 1500)); 215 | expect(getPortalsInDocumentBody()).to.not.be.empty; 216 | }); 217 | 218 | it('renders a placeholder while asynchronous content resolves', async function () { 219 | this.timeout(2000); 220 | 221 | await createAsynchronousPortalFixture(document.body, 1000, html`

placeholder

`); 222 | const portals = getPortalsInDocumentBody(); 223 | expect(portals).to.not.be.empty; 224 | expect((portals[0] as HTMLElement).innerText).to.equal('placeholder'); 225 | 226 | await new Promise((resolve) => setTimeout(resolve, 1500)); 227 | expect((portals[0] as HTMLElement).innerText).to.equal(PORTAL_CONTENT); 228 | }); 229 | }); 230 | 231 | describe('test helpers', async function () { 232 | describe('afterEach', function () { 233 | it('expects no portals on document.body before adding a portal', async function () { 234 | expect(getPortalsInDocumentBody()).to.be.empty; 235 | await createPortalFixture(document.body); 236 | expect(getPortalsInDocumentBody()).to.not.be.empty; 237 | }); 238 | 239 | it('expects (again) no portals on document.body before adding a portal', async function () { 240 | expect(getPortalsInDocumentBody()).to.be.empty; 241 | await createPortalFixture(document.body); 242 | expect(getPortalsInDocumentBody()).to.not.be.empty; 243 | }); 244 | }); 245 | 246 | describe('createPortalFixtureWithToggledComponent', async function () { 247 | it('creates and removes a portal with the returned values', async function () { 248 | const { clickButtonAsync } = await createPortalFixtureWithToggledComponent(); 249 | 250 | await clickButtonAsync(); 251 | expect(getPortalsInDocumentBody()).to.not.be.empty; 252 | 253 | await clickButtonAsync(); 254 | expect(getPortalsInDocumentBody()).to.be.empty; 255 | }); 256 | }); 257 | }); 258 | }); 259 | 260 | declare global { 261 | interface HTMLElementTagNameMap { 262 | 'example-component-manager': ExampleComponentManager; 263 | 'example-component': ExampleComponent; 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /docs/interfaces/PortalOptions.html: -------------------------------------------------------------------------------- 1 | PortalOptions | Lit Modal Portal Documentation
interface PortalOptions {
    modifyContainer?: (container: HTMLElement) => void;
    placeholder?: unknown;
}

Properties

Properties

modifyContainer?: (container: HTMLElement) => void

When provided, the modifyContainer function will be called with 4 | the portal's container given as the argument. This allows you to programmatically control the 5 | container before the portal renders.

6 |
placeholder?: unknown

When provided, placeholder will be immediately rendered in the portal. 7 | Assuming that content is a promise, it will replace the placeholder once it resolves.

8 |
9 | -------------------------------------------------------------------------------- /docs/assets/icons.svg: -------------------------------------------------------------------------------- 1 | MMNEPVFCICPMFPCPTTAAATR -------------------------------------------------------------------------------- /docs/assets/icons.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | addIcons(); 3 | function addIcons() { 4 | if (document.readyState === "loading") return document.addEventListener("DOMContentLoaded", addIcons); 5 | const svg = document.body.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "svg")); 6 | svg.innerHTML = `MMNEPVFCICPMFPCPTTAAATR`; 7 | svg.style.display = "none"; 8 | if (location.protocol === "file:") updateUseElements(); 9 | } 10 | 11 | function updateUseElements() { 12 | document.querySelectorAll("use").forEach(el => { 13 | if (el.getAttribute("href").includes("#icon-")) { 14 | el.setAttribute("href", el.getAttribute("href").replace(/.*#/, "#")); 15 | } 16 | }); 17 | } 18 | })() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lit-modal-portal 2 | 3 | The `lit-modal-portal` package provides a [custom Lit directive](https://lit.dev/docs/templates/custom-directives/), `portal`, that renders a Lit template elsewhere in the DOM. 4 | Its main goals are: 5 | 6 | 1. to provide an API that is similar to React's [`createPortal`](https://react.dev/reference/react-dom/createPortal) function, and 7 | 2. to rely on the existing Lit API wherever possible. 8 | 9 | This package also supports _asynchronous_ portal content. 10 | 11 | ## :warning: Notice on version 0.6 12 | 13 | This package was heavily altered between versions 0.4 and 0.6. 14 | Changes include: 15 | 16 | - Add support for Lit v3 and fixed dependency declaration for v0.5. (Thanks, [klasjersevi](https://github.com/klasjersevi).) 17 | - Removed the following code: 18 | - Dependency of the [immutable](https://www.npmjs.com/package/immutable) package. 19 | - The `` component and the singleton `modalController`. 20 | - All pre-made components, such as the ``. 21 | - Refactor the `portal` directive to use Lit's `render` function. 22 | - This was primarily inspired by [ronak-lm](https://github.com/ronak-lm)'s [lit-portal](https://www.npmjs.com/package/lit-portal) package, which more closely resembles React's portal API than previous versions of this package. 23 | - This simplifies usage of the package and expands the potential use cases. 24 | 25 | ## Installation and Usage 26 | 27 | You can install `lit-modal-portal` via NPM. 28 | 29 | ``` 30 | npm install lit-modal-portal 31 | ``` 32 | 33 | Suppose we have the following Lit application: 34 | 35 | ```html 36 | 37 | 38 | 39 | 40 | lit-modal-portal Usage Example 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | ``` 50 | 51 | ```js 52 | // index.ts (source code for main.js) 53 | import { LitElement, html } from 'lit'; 54 | import { customElement } from 'lit/decorators.js'; 55 | import { portal } from 'lit-modal-portal'; 56 | 57 | @customElement('app-root') 58 | export class AppRoot extends LitElement { 59 | render() { 60 | return html` 61 |

lit-modal-portal Usage Example

62 | ${portal(html`

portal content

`, document.body)} 63 | `; 64 | } 65 | } 66 | ``` 67 | 68 | When the `` component renders, it will activate the `portal` directive, which will return `nothing` but use Lit's API to asynchronously render the content in a container `
` and append that container to `document.body`. 69 | 70 | When the portal's content is updated, the directive will re-render the new content in the same container. Additionally, if the target changes, then the container will be removed from the old target and appended to the new target. 71 | 72 | ## API 73 | 74 | ```ts 75 | type TargetOrSelector = Node | string; 76 | 77 | type PortalOptions = { 78 | placeholder?: unknown; 79 | modifyContainer?: (container: HTMLElement) => void; 80 | }; 81 | 82 | portal( 83 | content: unknown | Promise, 84 | targetOrSelector: TargetOrSelector | Promise, 85 | options?: PortalOptions, 86 | ): DirectiveResult 87 | ``` 88 | 89 | Parameters: 90 | 91 | - `content`: The content of the portal. This parameter is passed as the `value` parameter in [Lit's `render` function](https://lit.dev/docs/api/templates/#render). 92 | 93 | > Any renderable value typically a [`TemplateResult`](https://lit.dev/docs/api/templates/#TemplateResult) 94 | > created by evaluating a template tag like [`html`](https://lit.dev/docs/api/templates/#html) or [`svg`](https://lit.dev/docs/api/templates/#svg). 95 | 96 | - `targetOrSelector`: An element or a string that identifies the portal's target. 97 | 98 | If the value is a string, then it is treated as a query selector and passed to `document.querySelector()` in order to locate the portal target. 99 | If no element is found with the selector, then an error is thrown. 100 | 101 | - `options`: Configuration parameters for the portal. 102 | 103 | - `placeholder`: A value that will be rendered while the `content` is resolving. 104 | 105 | - `modifyContainer`: A function that will be called with the portal's container provided as an argument. 106 | This allows you to programmatically control the container before the portal renders. 107 | 108 | This function will always return [Lit's `nothing` value](https://lit.dev/docs/api/templates/#nothing), because nothing is supposed to render where the portal is used. 109 | 110 | Both the `content` and the `targetOrSelector` parameters may be promises. 111 | The `targetOrSelector` must resolve before the portal renders. 112 | 113 | If the `content` is a promise, then an optional `placeholder` may be provided. 114 | If no `placeholder` is provided, then the portal will not render until the `content` resolves. 115 | 116 | See [the docs](https://nicholas-wilcox.github.io/lit-modal-portal/classes/PortalDirective.html#render) for more information on how the `portal` directive works. 117 | 118 | ## Advanced Usage 119 | 120 | ### Modals and dialogs 121 | 122 | This package no longer provides modal components. Instead, it focuses on a directive that is simple to use in different ways and encourages users to implement their own modals. 123 | 124 | One recommended approach is to use the [`dialog`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog) element and its [`showModal`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal) method, 125 | which can be accessed using [Lit's `ref` directive](https://lit.dev/docs/templates/directives/#referencing-rendered-dom). 126 | 127 | Consider the following: 128 | 129 | ```ts 130 | // example-app.ts 131 | import { LitElement, html } from 'lit'; 132 | import { customElement } from 'lit/decorators.js'; 133 | import { ref, createRef } from 'lit/directives/ref.js'; 134 | import { portal } from 'lit-modal-portal'; 135 | 136 | import './lit-dialog'; 137 | 138 | @customElement('example-app') 139 | export class ExampleApp extends LitElement { 140 | dialogRef = createRef(); 141 | 142 | render() { 143 | return html` 144 |

lit-modal-portal Dialog Example

145 | 146 | ${portal(html``, document.body)} 147 | `; 148 | } 149 | } 150 | ``` 151 | 152 | ```ts 153 | // lit-dialog.ts 154 | import { LitElement, html } from 'lit'; 155 | import { customElement, property } from 'lit/decorators.js'; 156 | import { ref, createRef, Ref } from 'lit/directives/ref.js'; 157 | 158 | @customElement('lit-dialog') 159 | export class LitDialog extends LitElement { 160 | @property({ attribute: false }) 161 | dialogRef: Ref = createRef(); 162 | 163 | render() { 164 | return html` 165 | 166 |

This is the dialog

167 | 168 |
169 | `; 170 | } 171 | } 172 | ``` 173 | 174 | In this example, we have a `` component that accepts a `dialogRef` from the parent ``. 175 | This allows the parent to open the dialog and the child to imperatively close it on a button's `@click` event. 176 | 177 | This basic pattern can be extended as necessary. Examples include: 178 | 179 | - Listening to the dialog's `close` event, which would trigger if the dialog was closed with the Escape key. 180 | - Adding styles to the `` component. 181 | - Adding callback function properties to ``. 182 | - Using slotted content in the dialog component's template. 183 | 184 | ### Targeting elements in the Shadow DOM 185 | 186 | Using a DOM node in a Lit component as a target for a portal is tricky (and perhaps useless or inadvisable), for a number of reasons: 187 | 188 | 1. The `querySelector` method does not penetrate through the shadow root, so running queries on the `document` node won't return anything. 189 | 2. The `portal` directive is _asynchronous_, so if it renders at the same time as the component's first render, _then the target might not even exist yet_. 190 | 191 | We cannot simply call `querySelector` on a different render root, such as a component's shadow root, because it might be empty. However, this is still possible to accomplish safely with the use of [Lit's `queryAsync` decorator](https://lit.dev/docs/api/decorators/#queryAsync). 192 | 193 | ```ts 194 | import { LitElement, html } from 'lit'; 195 | import { customElement, queryAsync } from 'lit/decorators.js'; 196 | import { portal } from 'lit-modal-portal'; 197 | 198 | @customElement('example-component') 199 | export class ExampleComponent extends LitElement { 200 | @queryAsync('#portal-target') 201 | portalTarget: Promise; 202 | 203 | render() { 204 | return html`
205 | ${portal(html`

Portal content

`, this.portalTarget)} 206 |

The portal isn't rendered before this paragraph, but in the following div.

207 |
208 |
`; 209 | } 210 | } 211 | ``` 212 | 213 | In this example, `this.portalTarget` is a promise that resolves to the `