1// Copyright (C) 2023 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import m from 'mithril'; 16 17type Style = string | Partial<CSSStyleDeclaration>; 18 19export interface MountOptions { 20 // Optionally specify an element in which to place our portal. 21 // Defaults to body. 22 container?: Element; 23} 24 25export interface PortalAttrs { 26 // Space delimited class list forwarded to our portal element. 27 className?: string; 28 // Inline styles forwarded to our portal element. 29 style?: Style; 30 // Called before our portal is created, allowing customization of where in the 31 // DOM the portal is mounted. 32 // The dom parameter is a dummy element representing where the portal would be 33 // located if it were rendered into the normal tree hierarchy. 34 onBeforeContentMount?: (dom: Element) => MountOptions; 35 // Called after our portal is created and its content rendered. 36 onContentMount?: (portalElement: HTMLElement) => void; 37 // Called after our portal's content is updated. 38 onContentUpdate?: (portalElement: HTMLElement) => void; 39 // Called before our portal is removed. 40 onContentUnmount?: (portalElement: HTMLElement) => void; 41} 42 43// A portal renders children into a a div outside of the normal hierarchy of the 44// parent component, usually in order to stack elements on top of others. 45// Useful for creating overlays, dialogs, and popups. 46export class Portal implements m.ClassComponent<PortalAttrs> { 47 private portalElement?: HTMLElement; 48 private containerElement?: Element; 49 private contentComponent: m.Component; 50 51 constructor({children}: m.CVnode<PortalAttrs>) { 52 // Create a temporary component that we can mount in oncreate, and unmount 53 // in onremove, but inject the new portal content (children) into it each 54 // render cycle. This is initialized here rather than in oncreate to avoid 55 // having to make it optional or use assertExists(). 56 this.contentComponent = {view: () => children}; 57 } 58 59 view() { 60 // Dummy element renders nothing but permits DOM access in lifecycle hooks. 61 return m('span', {style: {display: 'none'}}); 62 } 63 64 oncreate({attrs, dom}: m.CVnodeDOM<PortalAttrs>) { 65 const { 66 onContentMount = () => {}, 67 onBeforeContentMount = (): MountOptions => ({}), 68 } = attrs; 69 70 const {container = document.body} = onBeforeContentMount(dom); 71 this.containerElement = container; 72 73 this.portalElement = document.createElement('div'); 74 container.appendChild(this.portalElement); 75 this.applyPortalProps(attrs); 76 77 m.mount(this.portalElement, this.contentComponent); 78 79 onContentMount(this.portalElement); 80 } 81 82 onbeforeupdate({children}: m.CVnode<PortalAttrs>) { 83 // Update the mounted content's view function to return the latest portal 84 // content passed in via children, without changing the component itself. 85 this.contentComponent.view = () => children; 86 } 87 88 onupdate({attrs}: m.CVnodeDOM<PortalAttrs>) { 89 const {onContentUpdate = () => {}} = attrs; 90 if (this.portalElement) { 91 this.applyPortalProps(attrs); 92 onContentUpdate(this.portalElement); 93 } 94 } 95 96 private applyPortalProps(attrs: PortalAttrs) { 97 if (this.portalElement) { 98 this.portalElement.className = attrs.className ?? ''; 99 Object.assign(this.portalElement.style, attrs.style); 100 } 101 } 102 103 onremove({attrs}: m.CVnodeDOM<PortalAttrs>) { 104 const {onContentUnmount = () => {}} = attrs; 105 const container = this.containerElement ?? document.body; 106 if (this.portalElement) { 107 if (container.contains(this.portalElement)) { 108 onContentUnmount(this.portalElement); 109 // Rendering null ensures previous vnodes are removed properly. 110 m.mount(this.portalElement, null); 111 container.removeChild(this.portalElement); 112 } 113 } 114 } 115} 116