go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/components/tooltip/tooltip.ts (about) 1 // Copyright 2021 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 { css, html } from 'lit'; 17 import { customElement } from 'lit/decorators.js'; 18 import { action, makeObservable, observable } from 'mobx'; 19 20 export interface ShowTooltipEventDetail { 21 tooltip: HTMLElement; 22 // The location around which the tooltip should be displayed. 23 targetRect: DOMRectReadOnly; 24 // The gap between the tooltip and the targetRect. 25 gapSize: number; 26 } 27 28 export type ShowTooltipEvent = CustomEvent<ShowTooltipEventDetail>; 29 30 export interface HideTooltipEventDetail { 31 // Hide the tooltip after `delay` ms. Default value is 0. 32 // When the tooltip is lingering, you can hover over it to stop it from 33 // disappearing. 34 delay?: number; 35 } 36 37 export type HideTooltipEvent = CustomEvent<HideTooltipEventDetail>; 38 39 /** 40 * A global listener for displaying instant tooltip. It should be added to 41 * somewhere close to the root of the DOM tree. 42 * 43 * After mounting this to DOM, you can 44 * 1. show a tooltip via 'show-tooltip' event. 45 * 2. hide the tooltip via 'hide-tooltip' event. 46 */ 47 // Comparing to a sub-component, a global tooltip implementation 48 // 1. makes it easier to ensure there's at most one active tooltip. 49 // 2. is not constrained by ancestors' overflow setting. 50 @customElement('milo-tooltip') 51 export class TooltipElement extends MobxLitElement { 52 @observable.ref private tooltip?: HTMLElement; 53 @observable.ref private targetRect?: DOMRectReadOnly; 54 @observable.ref private gapSize?: number; 55 56 private hideTooltipTimeout = 0; 57 58 private onShowTooltip = action((event: Event) => { 59 window.clearTimeout(this.hideTooltipTimeout); 60 61 const e = event as CustomEvent<ShowTooltipEventDetail>; 62 this.tooltip = e.detail.tooltip; 63 this.targetRect = e.detail.targetRect; 64 this.gapSize = e.detail.gapSize; 65 66 this.style.display = 'block'; 67 68 // Hide the element until we decided where to render it. 69 this.style.visibility = 'hidden'; 70 71 // Reset the position so it's easier to calculate the new position. 72 this.style.left = '0'; 73 this.style.top = '0'; 74 }); 75 76 private onHideTooltip = (event: Event) => { 77 window.clearTimeout(this.hideTooltipTimeout); 78 79 const e = event as CustomEvent<HideTooltipEventDetail>; 80 this.hideTooltipTimeout = window.setTimeout( 81 this.hideTooltip, 82 e.detail.delay || 0, 83 ); 84 }; 85 86 private hideTooltip = action(() => { 87 this.style.display = 'none'; 88 this.tooltip = undefined; 89 this.targetRect = undefined; 90 this.gapSize = undefined; 91 }); 92 93 constructor() { 94 super(); 95 makeObservable(this); 96 this.addEventListener('mouseover', () => 97 window.clearTimeout(this.hideTooltipTimeout), 98 ); 99 this.addEventListener('mouseout', this.hideTooltip); 100 } 101 102 connectedCallback() { 103 super.connectedCallback(); 104 window.addEventListener('show-tooltip', this.onShowTooltip); 105 window.addEventListener('hide-tooltip', this.onHideTooltip); 106 } 107 108 disconnectedCallback() { 109 super.disconnectedCallback(); 110 window.removeEventListener('show-tooltip', this.onShowTooltip); 111 window.removeEventListener('hide-tooltip', this.onHideTooltip); 112 } 113 114 protected render() { 115 return html`${this.tooltip}`; 116 } 117 118 protected updated() { 119 if (!this.tooltip || !this.targetRect || this.gapSize === undefined) { 120 return; 121 } 122 123 const selfRect = this.getBoundingClientRect(); 124 125 const offsets = [ 126 // Bottom (left-aligned). 127 [ 128 this.targetRect.left + window.scrollX, 129 this.targetRect.bottom + this.gapSize + window.scrollY, 130 ], 131 // Bottom (right-aligned). 132 [ 133 this.targetRect.right - selfRect.width + window.scrollX, 134 this.targetRect.bottom + this.gapSize + window.scrollY, 135 ], 136 // Top (left-aligned). 137 [ 138 this.targetRect.left + window.scrollX, 139 this.targetRect.top - selfRect.height - this.gapSize + window.scrollY, 140 ], 141 // Top (right-aligned). 142 [ 143 this.targetRect.right - selfRect.width + window.scrollX, 144 this.targetRect.top - selfRect.height - this.gapSize + window.scrollY, 145 ], 146 // Right (top-aligned). 147 [ 148 this.targetRect.right + this.gapSize + window.scrollX, 149 this.targetRect.top + window.scrollY, 150 ], 151 // Right (bottom-aligned). 152 [ 153 this.targetRect.right + this.gapSize + window.scrollX, 154 this.targetRect.bottom - selfRect.height + window.scrollY, 155 ], 156 // Left (top-aligned). 157 [ 158 this.targetRect.left - selfRect.width - this.gapSize + window.scrollX, 159 this.targetRect.top + window.scrollY, 160 ], 161 // Left (bottom-aligned). 162 [ 163 this.targetRect.left - selfRect.width - this.gapSize + window.scrollX, 164 this.targetRect.bottom - selfRect.height + window.scrollY, 165 ], 166 // Bottom-right. 167 [ 168 this.targetRect.right + this.gapSize + window.scrollX, 169 this.targetRect.bottom + this.gapSize + window.scrollY, 170 ], 171 // Bottom-left. 172 [ 173 this.targetRect.left - selfRect.width - this.gapSize + window.scrollX, 174 this.targetRect.bottom + this.gapSize + window.scrollY, 175 ], 176 // Top-right. 177 [ 178 this.targetRect.right + this.gapSize + window.scrollX, 179 this.targetRect.top - selfRect.height - this.gapSize + window.scrollY, 180 ], 181 // Top-left. 182 [ 183 this.targetRect.left - selfRect.width - this.gapSize + window.scrollX, 184 this.targetRect.top - selfRect.height - this.gapSize + window.scrollY, 185 ], 186 ]; 187 188 // Show the tooltip at the bottom by default. 189 let selectedOffset = offsets[0]; 190 191 // Find a place that can render the tooltip without overflowing the browser 192 // window. 193 for (const [dx, dy] of offsets) { 194 if (dx + selfRect.left < 0) { 195 continue; 196 } 197 if (dx + selfRect.right > window.innerWidth) { 198 continue; 199 } 200 if (dy + selfRect.top < 0) { 201 continue; 202 } 203 if (dy + selfRect.bottom > window.innerHeight) { 204 continue; 205 } 206 selectedOffset = [dx, dy]; 207 break; 208 } 209 210 this.style.left = selectedOffset[0] + 'px'; 211 this.style.top = selectedOffset[1] + 'px'; 212 this.style.visibility = 'visible'; 213 } 214 215 static styles = css` 216 :host { 217 display: none; 218 position: absolute; 219 background: white; 220 border-radius: 4px; 221 padding: 5px; 222 box-shadow: 223 rgb(0 0 0 / 20%) 0px 5px 5px -3px, 224 rgb(0 0 0 / 14%) 0px 8px 10px 1px, 225 rgb(0 0 0 / 12%) 0px 3px 14px 2px; 226 z-index: 999; 227 } 228 `; 229 } 230 231 declare global { 232 // eslint-disable-next-line @typescript-eslint/no-namespace 233 namespace JSX { 234 interface IntrinsicElements { 235 'milo-tooltip': Record<string, never>; 236 } 237 } 238 }