# 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.

> [!TIP]
> This package is unfortunately stuck with the name `lit-modal-portal`.
>
> However, you may be better off with the standard behavior of the `<dialog>` element if you wish to create a confirmation modal dialog or something like that.
> See below for more information on [modals and dialogs](#modals-and-dialogs).

## 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

Modals are a long dicussed topic in web development. 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';

@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>
      <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 component with a `dialogRef` attached to a `<dialog>` element.
This allows the app to imperatively open and close the dialog on a button's `@click` event.

> [!NOTE]
> This example does not use the `lit-modal-portal` package. You should consider if a package such as this is necessary for your use case, as you might be better off with the standard `<dialog>` behavior.

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 or with the "Close Dialog" button.
- Adding styles to the `<dialog>` element.
- Creating a separate Lit component to manage tie `<dialog>` element.

### 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.

### Modifying portal containers

A portal renders its content inside a container `<div>`. You can access and manipulate this element by supplying a `modifyContainer` function to the options for the `portal` directive. For example, the following code adds styles to the container:

```ts
portal(
  html`<p>This is the portal content. Its container has a red border and some padding.</p>`,
  document.body,
  {
    modifyContainer: (c) => {
      c.style.border = '2px solid red';
      c.style.padding = '0.5rem';
    },
  },
);
```

Credit to [SimeonC](https://github.com/SimeonC) for suggesting this feature and even providing the code changes to make it possible.

### Delegating portals

You may wish to create a reusable component that implements certain behaviors around portalling while letting a parent component define the content that is sent through the portal. For example, consider a child component that dynamically renders content either in-place or through a portal, based on reactive state.

There are many issues with using slotted content for this purpose. (See [this GitHub issue](https://github.com/nicholas-wilcox/lit-modal-portal/issues/6) for more context.) Instead, you should declare a property on the child component and pass a Lit template through the parent component. You may refer to [the example code](https://github.com/nicholas-wilcox/lit-modal-portal/blob/main/dev/demo-delegation.ts) in the `dev/` directory of this repository for an example.

## 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.
