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 }