go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/generic_libs/components/expandable_entry/expandable_entry.tsx (about)

     1  // Copyright 2022 The LUCI Authors.
     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  
    15  import { MobxLitElement } from '@adobe/lit-mobx';
    16  import { ChevronRight, ExpandMore } from '@mui/icons-material';
    17  import { Box, SxProps, Theme } from '@mui/material';
    18  import { css, html } from 'lit';
    19  import { customElement } from 'lit/decorators.js';
    20  import { styleMap } from 'lit/directives/style-map.js';
    21  import { makeObservable, observable } from 'mobx';
    22  import { createContext, useContext } from 'react';
    23  
    24  const ExpandedContext = createContext(false);
    25  
    26  export interface ExpandableEntryHeaderProps {
    27    readonly onToggle: (expand: boolean) => void;
    28    readonly sx?: SxProps<Theme>;
    29    readonly children: React.ReactNode;
    30  }
    31  
    32  /**
    33   * Renders the header of an <ExpandableEntry />.
    34   */
    35  export function ExpandableEntryHeader({
    36    onToggle,
    37    sx,
    38    children,
    39  }: ExpandableEntryHeaderProps) {
    40    const expanded = useContext(ExpandedContext);
    41  
    42    return (
    43      <Box
    44        onClick={() => onToggle(!expanded)}
    45        sx={{
    46          display: 'grid',
    47          gridTemplateColumns: '24px 1fr',
    48          gridTemplateRows: '24px',
    49          gridGap: '5px',
    50          cursor: 'pointer',
    51          lineHeight: '24px',
    52          overflow: 'hidden',
    53          whiteSpace: 'nowrap',
    54          ...sx,
    55        }}
    56      >
    57        {expanded ? <ExpandMore /> : <ChevronRight />}
    58        {children}
    59      </Box>
    60    );
    61  }
    62  
    63  export interface ExpandableEntryBodyProps {
    64    /**
    65     * Configure whether the content ruler should be rendered.
    66     * * visible: the default option. Renders the content ruler.
    67     * * invisible: hide the content ruler but keep the indentation.
    68     * * none: hide the content ruler and don't keep the indentation.
    69     */
    70    readonly ruler?: 'visible' | 'invisible' | 'none';
    71    readonly children: React.ReactNode;
    72  }
    73  
    74  /**
    75   * Renders the body of an <ExpandableEntry />.
    76   * The content is hidden when the entry is collapsed.
    77   */
    78  export function ExpandableEntryBody({
    79    ruler,
    80    children,
    81  }: ExpandableEntryBodyProps) {
    82    const expanded = useContext(ExpandedContext);
    83    ruler = ruler || 'visible';
    84  
    85    return (
    86      <Box
    87        sx={{
    88          display: 'grid',
    89          gridTemplateColumns: ruler === 'none' ? '1fr' : '24px 1fr',
    90          gridGap: '5px',
    91        }}
    92      >
    93        <Box
    94          sx={{
    95            display: ruler === 'none' ? 'none' : '',
    96            visibility: ruler === 'invisible' ? 'hidden' : '',
    97            borderLeft: '1px solid var(--divider-color)',
    98            width: '0px',
    99            marginLeft: '11.5px',
   100          }}
   101        ></Box>
   102        {expanded ? children : <></>}
   103      </Box>
   104    );
   105  }
   106  
   107  export interface ExpandableEntryProps {
   108    readonly expanded: boolean;
   109    /**
   110     * The first child should be an <ExpandableEntryHeader />.
   111     * The second child should be an <ExpandableEntryBody />.
   112     */
   113    readonly children: [JSX.Element, JSX.Element];
   114  }
   115  
   116  /**
   117   * Renders an expandable entry.
   118   */
   119  export function ExpandableEntry({ expanded, children }: ExpandableEntryProps) {
   120    return (
   121      <Box>
   122        <ExpandedContext.Provider value={expanded}>
   123          {children}
   124        </ExpandedContext.Provider>
   125      </Box>
   126    );
   127  }
   128  
   129  /**
   130   * Renders an expandable entry.
   131   */
   132  // Keep a separate implementation instead of wrapping the React component so
   133  // 1. we can catch events originated from shadow-dom, and
   134  // 2. the rendering performance is as good as possible (there could be > 10,000
   135  // entries rendered on the screen).
   136  @customElement('milo-expandable-entry')
   137  export class ExpandableEntryElement extends MobxLitElement {
   138    /**
   139     * Configure whether the content ruler should be rendered.
   140     * * visible: the default option. Renders the content ruler.
   141     * * invisible: hide the content ruler but keep the indentation.
   142     * * none: hide the content ruler and don't keep the indentation.
   143     */
   144    @observable.ref contentRuler: 'visible' | 'invisible' | 'none' = 'visible';
   145  
   146    onToggle = (_isExpanded: boolean) => {
   147      /* do nothing by default */
   148    };
   149  
   150    @observable.ref private _expanded = false;
   151    get expanded() {
   152      return this._expanded;
   153    }
   154    set expanded(isExpanded) {
   155      if (isExpanded === this._expanded) {
   156        return;
   157      }
   158      this._expanded = isExpanded;
   159      this.onToggle(this._expanded);
   160    }
   161  
   162    constructor() {
   163      super();
   164      makeObservable(this);
   165    }
   166  
   167    protected render() {
   168      return html`
   169        <div
   170          id="expandable-header"
   171          @click=${() => (this.expanded = !this.expanded)}
   172        >
   173          <mwc-icon>${this.expanded ? 'expand_more' : 'chevron_right'}</mwc-icon>
   174          <slot name="header"></slot>
   175        </div>
   176        <div
   177          id="body"
   178          style=${styleMap({
   179            'grid-template-columns':
   180              this.contentRuler === 'none' ? '1fr' : '24px 1fr',
   181          })}
   182        >
   183          <div
   184            id="content-ruler"
   185            style=${styleMap({
   186              display: this.contentRuler === 'none' ? 'none' : '',
   187              visibility: this.contentRuler === 'invisible' ? 'hidden' : '',
   188            })}
   189          ></div>
   190          <slot
   191            name="content"
   192            style=${styleMap({ display: this.expanded ? '' : 'none' })}
   193          ></slot>
   194        </div>
   195      `;
   196    }
   197  
   198    static styles = css`
   199      :host {
   200        display: block;
   201        --header-height: 24px;
   202      }
   203  
   204      #expandable-header {
   205        display: grid;
   206        grid-template-columns: 24px 1fr;
   207        grid-template-rows: var(--header-height);
   208        grid-gap: 5px;
   209        cursor: pointer;
   210        line-height: 24px;
   211        overflow: hidden;
   212        white-space: nowrap;
   213      }
   214  
   215      #body {
   216        display: grid;
   217        grid-template-columns: 24px 1fr;
   218        grid-gap: 5px;
   219      }
   220      #content-ruler {
   221        border-left: 1px solid var(--divider-color);
   222        width: 0px;
   223        margin-left: 11.5px;
   224      }
   225    `;
   226  }