lit-modal-portal
Version:
A custom portal directive for the Lit framework to render content elsewhere in the DOM
255 lines (183 loc) • 11.1 kB
Markdown
# lit-modal-portal
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.
Its main goals are:
1. to provide an API that is similar to React's [`createPortal`](https://react.dev/reference/react-dom/createPortal) function, and
2. to rely on the existing Lit API wherever possible.
This package also supports _asynchronous_ portal content.
## :warning: Notice on version 0.6
This package was heavily altered between versions 0.4 and 0.6.
Changes include:
- Add support for Lit v3 and fixed dependency declaration for v0.5. (Thanks, [klasjersevi](https://github.com/klasjersevi).)
- Removed the following code:
- Dependency of the [immutable](https://www.npmjs.com/package/immutable) package.
- The `<modal-portal>` component and the singleton `modalController`.
- All pre-made components, such as the `<confirm-modal>`.
- Refactor the `portal` directive to use Lit's `render` function.
- 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.
- This simplifies usage of the package and expands the potential use cases.
## Installation and Usage
You can install `lit-modal-portal` via NPM.
```
npm install lit-modal-portal
```
Suppose we have the following Lit application:
```html
<!-- index.html -->
<!doctype html>
<html>
<head>
<title>lit-modal-portal Usage Example</title>
<!-- Your bundle/script -->
<script type="module" src="main.js"></script>
</head>
<body>
<!-- Your custom element -->
<app-root></app-root>
</body>
</html>
```
```js
// index.ts (source code for main.js)
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { portal } from 'lit-modal-portal';
@customElement('app-root')
export class AppRoot extends LitElement {
render() {
return html`
<h1>lit-modal-portal Usage Example</h1>
${portal(html`<p>portal content</p>`, document.body)}
`;
}
}
```
When the `<app-root>` 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 `<div>` and append that container to `document.body`.
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.
## API
```ts
type TargetOrSelector = Node | string;
type PortalOptions = {
placeholder?: unknown;
modifyContainer?: (container: HTMLElement) => void;
};
portal(
content: unknown | Promise<unknown>,
targetOrSelector: TargetOrSelector | Promise<TargetOrSelector>,
options?: PortalOptions,
): DirectiveResult<typeof PortalDirective>
```
Parameters:
- `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).
> Any renderable value typically a [`TemplateResult`](https://lit.dev/docs/api/templates/#TemplateResult)
> created by evaluating a template tag like [`html`](https://lit.dev/docs/api/templates/#html) or [`svg`](https://lit.dev/docs/api/templates/#svg).
- `targetOrSelector`: An element or a string that identifies the portal's target.
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.
If no element is found with the selector, then an error is thrown.
- `options`: Configuration parameters for the portal.
- `placeholder`: A value that will be rendered while the `content` is resolving.
- `modifyContainer`: A function that will be called with the portal's container provided as an argument.
This allows you to programmatically control the container before the portal renders.
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.
Both the `content` and the `targetOrSelector` parameters may be promises.
The `targetOrSelector` must resolve before the portal renders.
If the `content` is a promise, then an optional `placeholder` may be provided.
If no `placeholder` is provided, then the portal will not render until the `content` resolves.
See [the docs](https://nicholas-wilcox.github.io/lit-modal-portal/classes/PortalDirective.html#render) for more information on how the `portal` directive works.
## Advanced Usage
### Modals and dialogs
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.
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,
which can be accessed using [Lit's `ref` directive](https://lit.dev/docs/templates/directives/#referencing-rendered-dom).
Consider the following:
```ts
// example-app.ts
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { ref, createRef } from 'lit/directives/ref.js';
import { portal } from 'lit-modal-portal';
import './lit-dialog';
@customElement('example-app')
export class ExampleApp extends LitElement {
dialogRef = createRef<HTMLDialogElement>();
render() {
return html`
<h1>lit-modal-portal Dialog Example</h1>
<button @click=${() => this.dialogRef.value?.showModal()}>Show Dialog</button>
${portal(html`<lit-dialog .dialogRef=${this.dialogRef}></lit-dialog>`, document.body)}
`;
}
}
```
```ts
// lit-dialog.ts
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { ref, createRef, Ref } from 'lit/directives/ref.js';
@customElement('lit-dialog')
export class LitDialog extends LitElement {
@property({ attribute: false })
dialogRef: Ref<HTMLDialogElement> = createRef();
render() {
return html`
<dialog ${ref(this.dialogRef)}>
<p>This is the dialog</p>
<button @click=${() => this.dialogRef.value?.close()}>Close Dialog</button>
</dialog>
`;
}
}
```
In this example, we have a `<lit-dialog>` component that accepts a `dialogRef` from the parent `<example-app>`.
This allows the parent to open the dialog and the child to imperatively close it on a button's `@click` event.
This basic pattern can be extended as necessary. Examples include:
- Listening to the dialog's `close` event, which would trigger if the dialog was closed with the Escape key.
- Adding styles to the `<lit-dialog>` component.
- Adding callback function properties to `<lit-dialog>`.
- Using slotted content in the dialog component's template.
### Targeting elements in the Shadow DOM
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:
1. The `querySelector` method does not penetrate through the shadow root, so running queries on the `document` node won't return anything.
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_.
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).
```ts
import { LitElement, html } from 'lit';
import { customElement, queryAsync } from 'lit/decorators.js';
import { portal } from 'lit-modal-portal';
@customElement('example-component')
export class ExampleComponent extends LitElement {
@queryAsync('#portal-target')
portalTarget: Promise<HTMLElement>;
render() {
return html`<div>
${portal(html`<p>Portal content</p>`, this.portalTarget)}
<p>The portal isn't rendered before this paragraph, but in the following div.</p>
<div id="portal-target"></div>
</div>`;
}
}
```
In this example, `this.portalTarget` is a promise that resolves to the `<div id="portal-target>` element after the `<example-component>` renders.
See [Lit's documentation](https://lit.dev/docs/components/shadow-dom/) for more information on components and the Shadow DOM.
### Styling portal content
Another consquence of the Shadow DOM is that only [inherited CSS properties](https://lit.dev/docs/components/styles/#inheritance) affect elements inside a shadow root. Coupled with the fact that a portal serves to render content in a different location, this makes it difficult for a component that uses the `portal` directive to style the portal's content.
**It is recommended that the content of a portal should be another Lit component that can own its CSS.** Alternatively, the [`styleMap` directive](https://lit.dev/docs/templates/directives/#stylemap) can be used in the template provided to the `portal` directive.
See [Lit's documentation](https://lit.dev/docs/components/styles/#shadow-dom) for more information on working with CSS styles and the Shadow DOM.
## Documentation
More in-depth documentation for this package is included in the repo, under the `/docs` directory.
It is also [hosted on GitHub Pages](https://nicholas-wilcox.github.io/lit-modal-portal/index.html).
The documentation is generated using [TypeDoc](https://typedoc.org/).
Please note the separate `docs.tsconfig.json` and `src/docs.index.ts` files if you plan to make changes related to the documentation.
## Development
For demonstration and testing purposes, you can start a local development server by running `npm run dev`.
There are multiple examples of the `portal` directive that explain its features and supported usage.
The default host is `localhost:8000`, and you may override the port number by setting the PORT environment variable.
The development server is [Modern Web's server](https://modern-web.dev/docs/dev-server/overview/),
running in watch mode, so you can see code changes reloaded into the browser automatically.
Note the middleware in `web-dev-server.config.mjs` that rewrites requests for the root so that `dev/index.html` is served.
### Testing
There are some tests for the `portal` directive in `src/portal.test.ts`. They use [Modern Web's test runner](https://modern-web.dev/guides/test-runner/getting-started/) and the [Open Web Components `@open-wc/testing`](https://open-wc.org/guides/developing-components/testing/) framework.
You can execute the tests by running `npm run test`.
## Contributing and Bug Reports
Currently, there is no standard procedure for contributing to this project.
You are absolutely welcome to fork the repository and make a pull request,
and to file issues if you encounter problems while using it.