blob: 91c3608f69fa7b1f7fea110361f7678ad261fce2 [file] [log] [blame]
// Copyright (C) 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
type Style = string | Partial<CSSStyleDeclaration>;
export interface MountOptions {
// Optionally specify an element in which to place our portal.
// Defaults to body.
container?: Element;
}
export interface PortalAttrs {
// Space delimited class list forwarded to our portal element.
className?: string;
// Inline styles forwarded to our portal element.
style?: Style;
// Called before our portal is created, allowing customization of where in the
// DOM the portal is mounted.
// The dom parameter is a dummy element representing where the portal would be
// located if it were rendered into the normal tree hierarchy.
onBeforeContentMount?: (dom: Element) => MountOptions;
// Called after our portal is created and its content rendered.
onContentMount?: (portalElement: HTMLElement) => void;
// Called after our portal's content is updated.
onContentUpdate?: (portalElement: HTMLElement) => void;
// Called before our portal is removed.
onContentUnmount?: (portalElement: HTMLElement) => void;
}
// A portal renders children into a a div outside of the normal hierarchy of the
// parent component, usually in order to stack elements on top of others.
// Useful for creating overlays, dialogs, and popups.
export class Portal implements m.ClassComponent<PortalAttrs> {
private portalElement?: HTMLElement;
private containerElement?: Element;
private contentComponent: m.Component;
constructor({children}: m.CVnode<PortalAttrs>) {
// Create a temporary component that we can mount in oncreate, and unmount
// in onremove, but inject the new portal content (children) into it each
// render cycle. This is initialized here rather than in oncreate to avoid
// having to make it optional or use assertExists().
this.contentComponent = {view: () => children};
}
view() {
// Dummy element renders nothing but permits DOM access in lifecycle hooks.
return m('span', {style: {display: 'none'}});
}
oncreate({attrs, dom}: m.CVnodeDOM<PortalAttrs>) {
const {
onContentMount = () => {},
onBeforeContentMount = (): MountOptions => ({}),
} = attrs;
const {container = document.body} = onBeforeContentMount(dom);
this.containerElement = container;
this.portalElement = document.createElement('div');
container.appendChild(this.portalElement);
this.applyPortalProps(attrs);
m.mount(this.portalElement, this.contentComponent);
onContentMount(this.portalElement);
}
onbeforeupdate({children}: m.CVnode<PortalAttrs>) {
// Update the mounted content's view function to return the latest portal
// content passed in via children, without changing the component itself.
this.contentComponent.view = () => children;
}
onupdate({attrs}: m.CVnodeDOM<PortalAttrs>) {
const {onContentUpdate = () => {}} = attrs;
if (this.portalElement) {
this.applyPortalProps(attrs);
onContentUpdate(this.portalElement);
}
}
private applyPortalProps(attrs: PortalAttrs) {
if (this.portalElement) {
this.portalElement.className = attrs.className ?? '';
Object.assign(this.portalElement.style, attrs.style);
}
}
onremove({attrs}: m.CVnodeDOM<PortalAttrs>) {
const {onContentUnmount = () => {}} = attrs;
const container = this.containerElement ?? document.body;
if (this.portalElement) {
if (container.contains(this.portalElement)) {
onContentUnmount(this.portalElement);
// Rendering null ensures previous vnodes are removed properly.
m.mount(this.portalElement, null);
container.removeChild(this.portalElement);
}
}
}
}