xref: /aosp_15_r20/external/perfetto/ui/src/widgets/portal.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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