├── .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 |
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 |
33 | In the example below, the portal's container is styled directly using a
34 | modifyContainer function.
35 |
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
53 | This component is a modified version of the <name-tag> component that is
54 | developed in
55 | Lit's interactive tutorial.
58 |
Hello, ${this.name}
60 | 61 |
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 |
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 |
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 |
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 |
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 |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 |${label} content
65 | 66 |
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 |
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 |
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 |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 |You can provide a placeholder value to render until the content promise resolves.
83 |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 |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 |
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 |
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 |${this.timeString}
106 | ${portal(html`${this.timeString}
`, this.singlePortalTarget)} 107 |
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 |
${this.timeString}
127 | ${portal(html`${this.timeString}
`, this.multiPortalTarget)} 128 |153 | Finally, as you would expect, a Lit component can be rendered through a portal and still 154 | work. 155 |
156 |
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 |
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 |
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 |
The button below will enable a portal that contains modal content.
46 | 47 | ${when(this.enableModalPortal, () => 48 | portal( 49 | html`
52 | This is the modal. It is a <div> with fixed positioning, zero
53 | insets, and a slightly transparent background color.
54 |
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 |
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 |
Putting all of this together, we can create a confirmation dialog component.
100 | 103 | ${portal( 104 | html`
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 |
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 |
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 |
To be used in Lit templates.
2 | 3 |${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${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 |OptionalmodifyWhen 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.
OptionalplaceholderWhen provided, placeholder will be immediately rendered in the portal.
7 | Assuming that content is a promise, it will replace the placeholder once it resolves.
portal content
`, document.body)} 63 | `; 64 | } 65 | } 66 | ``` 67 | 68 | When the `Portal content
`, this.portalTarget)} 206 |The portal isn't rendered before this paragraph, but in the following div.
207 | 208 |
The acceptable types used to specify a portal target.
2 |