@justeattakeaway/pie-tooltip 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +344 -15
- package/custom-elements.json +644 -3
- package/dist/index.d.ts +153 -2
- package/dist/index.js +390 -18
- package/dist/react.d.ts +159 -3
- package/dist/react.js +25 -7
- package/package.json +6 -4
- package/src/defs.ts +129 -3
- package/src/index.ts +691 -9
- package/src/react.ts +11 -3
- package/src/tooltip.scss +408 -2
package/src/index.ts
CHANGED
|
@@ -1,28 +1,710 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
nothing,
|
|
3
|
+
unsafeCSS,
|
|
4
|
+
type PropertyValues,
|
|
5
|
+
type TemplateResult,
|
|
6
|
+
} from 'lit';
|
|
3
7
|
import { PieElement } from '@justeattakeaway/pie-webc-core/src/internals/PieElement';
|
|
8
|
+
import {
|
|
9
|
+
property, query, queryAssignedElements, state,
|
|
10
|
+
} from 'lit/decorators.js';
|
|
11
|
+
import { classMap } from 'lit/directives/class-map.js';
|
|
12
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
13
|
+
import { html, unsafeStatic } from 'lit/static-html.js';
|
|
4
14
|
import {
|
|
5
15
|
safeCustomElement,
|
|
16
|
+
validPropertyValues,
|
|
6
17
|
} from '@justeattakeaway/pie-webc-core';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
componentClass,
|
|
21
|
+
componentSelector,
|
|
22
|
+
defaultProps,
|
|
23
|
+
headingLevels,
|
|
24
|
+
ON_TOOLTIP_CLOSE_EVENT,
|
|
25
|
+
ON_TOOLTIP_OPEN_EVENT,
|
|
26
|
+
positions,
|
|
27
|
+
sizes,
|
|
28
|
+
types,
|
|
29
|
+
variants,
|
|
30
|
+
type TooltipMode,
|
|
31
|
+
type TooltipProps,
|
|
32
|
+
} from './defs';
|
|
7
33
|
import styles from './tooltip.scss?inline';
|
|
8
|
-
import { type TooltipProps } from './defs';
|
|
9
34
|
|
|
10
|
-
|
|
35
|
+
import '@justeattakeaway/pie-icon-button';
|
|
36
|
+
import '@justeattakeaway/pie-icons-webc/dist/IconClose.js';
|
|
37
|
+
|
|
11
38
|
export * from './defs';
|
|
12
39
|
|
|
13
|
-
const
|
|
40
|
+
const headingId = 'pie-tooltip-heading';
|
|
41
|
+
|
|
42
|
+
// Returns true if the element establishes a containing block through a property other than
|
|
43
|
+
// `position` — i.e. for both absolute and fixed descendants, not just absolute.
|
|
44
|
+
const createsContainingBlock = (styles: CSSStyleDeclaration): boolean => {
|
|
45
|
+
const isSet = (value: string | undefined) => !!value && value !== 'none';
|
|
46
|
+
|
|
47
|
+
const containingBlockValues = [
|
|
48
|
+
styles.transform,
|
|
49
|
+
styles.perspective,
|
|
50
|
+
styles.filter,
|
|
51
|
+
styles.backdropFilter,
|
|
52
|
+
styles.translate,
|
|
53
|
+
styles.rotate,
|
|
54
|
+
styles.scale,
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
if (containingBlockValues.some(isSet)) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// `size` and `style` containment do not establish a containing block; everything else does.
|
|
62
|
+
if (/\b(paint|layout|content|strict)\b/.test(styles.contain)) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (styles.containerType && styles.containerType !== 'normal') {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// will-change pre-establishes the containing block before the property is applied.
|
|
71
|
+
return /\b(transform|perspective|filter|backdrop-filter|contain|translate|rotate|scale)\b/.test(styles.willChange);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const flattenedAncestors = (element: Element): Array<Element> => {
|
|
75
|
+
const { documentElement } = element.ownerDocument;
|
|
76
|
+
const ancestors: Array<Element> = [];
|
|
77
|
+
let current = element;
|
|
78
|
+
|
|
79
|
+
while (current !== documentElement) {
|
|
80
|
+
const parentNode: Node | null = current.assignedSlot ?? current.parentNode;
|
|
81
|
+
const parent = parentNode instanceof ShadowRoot ? parentNode.host : parentNode;
|
|
82
|
+
|
|
83
|
+
if (!(parent instanceof Element)) {
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
ancestors.push(parent);
|
|
88
|
+
current = parent;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return ancestors;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// The ancestors that clip the trigger. Overflow on the root element and the body is left out:
|
|
95
|
+
// that is the viewport clip, which every element is subject to and which no panel can escape.
|
|
96
|
+
const collectClippingAncestors = (element: Element): Array<Element> => {
|
|
97
|
+
const { documentElement, body } = element.ownerDocument;
|
|
98
|
+
|
|
99
|
+
return flattenedAncestors(element).filter((ancestor) => {
|
|
100
|
+
if (ancestor === documentElement || ancestor === body) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const styles = getComputedStyle(ancestor);
|
|
105
|
+
|
|
106
|
+
return styles.overflowX !== 'visible' || styles.overflowY !== 'visible';
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// The region an element clips its descendants to: its padding box, minus any scrollbar.
|
|
111
|
+
// `clientLeft`/`clientTop` are the border widths and `clientWidth`/`clientHeight` the padding box,
|
|
112
|
+
// so together they convert the border-box rect the browser reports into the clip region.
|
|
113
|
+
const getClipRect = (element: Element): DOMRect => {
|
|
114
|
+
const { left, top } = element.getBoundingClientRect();
|
|
115
|
+
const {
|
|
116
|
+
clientLeft, clientTop, clientWidth, clientHeight,
|
|
117
|
+
} = element;
|
|
118
|
+
|
|
119
|
+
return new DOMRect(left + clientLeft, top + clientTop, clientWidth, clientHeight);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Intersects two rects. Returns `null` when they do not overlap.
|
|
123
|
+
const intersectRects = (a: DOMRect, b: DOMRect): DOMRect | null => {
|
|
124
|
+
const left = Math.max(a.left, b.left);
|
|
125
|
+
const top = Math.max(a.top, b.top);
|
|
126
|
+
const right = Math.min(a.right, b.right);
|
|
127
|
+
const bottom = Math.min(a.bottom, b.bottom);
|
|
128
|
+
|
|
129
|
+
if (right <= left || bottom <= top) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return new DOMRect(left, top, right - left, bottom - top);
|
|
134
|
+
};
|
|
14
135
|
|
|
15
136
|
/**
|
|
16
137
|
* @tagname pie-tooltip
|
|
138
|
+
* @event {Event} pie-tooltip-open - When a configured trigger asks for the panel. Set `isOpen` to `true` in response.
|
|
139
|
+
* @event {Event} pie-tooltip-close - When the close button is clicked, or a configured trigger asks to dismiss the panel. Set `isOpen` to `false` in response.
|
|
140
|
+
* @slot content - The descriptive content of the panel. Must not contain focusable elements.
|
|
141
|
+
* @slot action - An optional slot for interactive content such as a `pie-button`. Filling this slot switches the panel to a non-modal dialog.
|
|
17
142
|
*/
|
|
18
143
|
@safeCustomElement('pie-tooltip')
|
|
19
144
|
export class PieTooltip extends PieElement implements TooltipProps {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
145
|
+
@property({ type: String })
|
|
146
|
+
public trigger: TooltipProps['trigger'];
|
|
147
|
+
|
|
148
|
+
@property({ type: Boolean })
|
|
149
|
+
public isOpen = defaultProps.isOpen;
|
|
150
|
+
|
|
151
|
+
@property({ type: String })
|
|
152
|
+
@validPropertyValues(componentSelector, positions, defaultProps.position)
|
|
153
|
+
public position = defaultProps.position;
|
|
154
|
+
|
|
155
|
+
@property({ type: String })
|
|
156
|
+
@validPropertyValues(componentSelector, sizes, defaultProps.size)
|
|
157
|
+
public size = defaultProps.size;
|
|
158
|
+
|
|
159
|
+
@property({ type: String })
|
|
160
|
+
@validPropertyValues(componentSelector, variants, defaultProps.variant)
|
|
161
|
+
public variant = defaultProps.variant;
|
|
162
|
+
|
|
163
|
+
@property({ type: String })
|
|
164
|
+
@validPropertyValues(componentSelector, types, defaultProps.type)
|
|
165
|
+
public type = defaultProps.type;
|
|
166
|
+
|
|
167
|
+
@property({ type: Boolean })
|
|
168
|
+
public isDismissible = defaultProps.isDismissible;
|
|
169
|
+
|
|
170
|
+
@property({ type: String })
|
|
171
|
+
public heading: TooltipProps['heading'];
|
|
172
|
+
|
|
173
|
+
@property({ type: String })
|
|
174
|
+
@validPropertyValues(componentSelector, headingLevels, defaultProps.headingLevel)
|
|
175
|
+
public headingLevel = defaultProps.headingLevel;
|
|
176
|
+
|
|
177
|
+
@property({ type: Object })
|
|
178
|
+
public aria: TooltipProps['aria'];
|
|
179
|
+
|
|
180
|
+
@property({ type: Array })
|
|
181
|
+
public triggers: TooltipProps['triggers'] = [];
|
|
182
|
+
|
|
183
|
+
@queryAssignedElements({ slot: 'action' }) private _assignedActionElements!: Array<HTMLElement>;
|
|
184
|
+
|
|
185
|
+
@query('.c-tooltip-origin') private _originElement!: HTMLElement | null;
|
|
186
|
+
|
|
187
|
+
@state() private _hasActionContent: boolean | undefined;
|
|
188
|
+
|
|
189
|
+
@state() private _isPositioned = false;
|
|
190
|
+
|
|
191
|
+
@state() private _isAnchorVisible = true;
|
|
192
|
+
|
|
193
|
+
private _clippedTriggerElement: Element | null = null;
|
|
194
|
+
|
|
195
|
+
private _triggerClippers: Array<Element> = [];
|
|
196
|
+
|
|
197
|
+
private _triggerTrackingController: AbortController | undefined;
|
|
198
|
+
private _interactionController: AbortController | undefined;
|
|
199
|
+
|
|
200
|
+
private _directionObserver: MutationObserver | undefined;
|
|
201
|
+
|
|
202
|
+
private _triggerObserver: ResizeObserver | undefined;
|
|
203
|
+
|
|
204
|
+
private _reanchorFrame = 0;
|
|
205
|
+
|
|
206
|
+
private _shouldResolveOverlayMode = false;
|
|
207
|
+
|
|
208
|
+
private _hoverCloseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
23
209
|
|
|
24
|
-
// Renders a `CSSResult` generated from SCSS by Vite
|
|
25
210
|
static styles = unsafeCSS(styles);
|
|
211
|
+
|
|
212
|
+
private get _mode (): TooltipMode | undefined {
|
|
213
|
+
if (this._hasActionContent === undefined) {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return this._hasActionContent ? 'dialog' : 'tooltip';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
protected firstUpdated (): void {
|
|
221
|
+
this.resolveMode();
|
|
222
|
+
this.projectOverTrigger();
|
|
223
|
+
this._rebuildInteractionListeners();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
protected updated (changedProperties: PropertyValues<this>): void {
|
|
227
|
+
const anchoringProperties: Array<keyof PieTooltip> = ['trigger', 'isOpen', 'position', 'size'];
|
|
228
|
+
|
|
229
|
+
if (this.isOpen && changedProperties.has('isOpen')) {
|
|
230
|
+
this.resolveOverlayMode();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (anchoringProperties.some((prop) => changedProperties.has(prop))) {
|
|
234
|
+
this.projectOverTrigger();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Tracking is bound to a specific trigger element and a specific ancestor chain, so a new
|
|
238
|
+
// trigger needs it rebuilt rather than left in place. `startTrackingTrigger` early-returns
|
|
239
|
+
// while a controller exists, so the stop has to come first.
|
|
240
|
+
if (this.isOpen && changedProperties.has('trigger')) {
|
|
241
|
+
this.stopTrackingTrigger();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (this.isOpen) {
|
|
245
|
+
this.startTrackingTrigger();
|
|
246
|
+
} else {
|
|
247
|
+
this.stopTrackingTrigger();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (changedProperties.has('triggers') || changedProperties.has('trigger')) {
|
|
251
|
+
this._rebuildInteractionListeners();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
public disconnectedCallback (): void {
|
|
256
|
+
this._teardownInteractionListeners();
|
|
257
|
+
this.stopTrackingTrigger();
|
|
258
|
+
super.disconnectedCallback();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Listens for scroll (capture — catches any ancestor), resize, and dir-attribute changes to
|
|
262
|
+
// re-anchor the panel. Multiple events per frame coalesce into one requestAnimationFrame call.
|
|
263
|
+
private startTrackingTrigger (): void {
|
|
264
|
+
if (this._triggerTrackingController) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const controller = new AbortController();
|
|
269
|
+
const { signal } = controller;
|
|
270
|
+
|
|
271
|
+
const handleViewportChange = () => {
|
|
272
|
+
if (this._reanchorFrame) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
this._reanchorFrame = requestAnimationFrame(() => {
|
|
277
|
+
this._reanchorFrame = 0;
|
|
278
|
+
|
|
279
|
+
if (this._shouldResolveOverlayMode) {
|
|
280
|
+
this._shouldResolveOverlayMode = false;
|
|
281
|
+
this.resolveOverlayMode();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
this.projectOverTrigger();
|
|
285
|
+
});
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// Flagged rather than resolved immediately so window-drag cannot walk ancestors more than once per frame.
|
|
289
|
+
const handleResize = () => {
|
|
290
|
+
this._shouldResolveOverlayMode = true;
|
|
291
|
+
handleViewportChange();
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
window.addEventListener('scroll', handleViewportChange, { capture: true, passive: true, signal });
|
|
295
|
+
window.addEventListener('resize', handleResize, { passive: true, signal });
|
|
296
|
+
|
|
297
|
+
// `scroll` is not composed, so its path stops at the shadow root it happened in and a
|
|
298
|
+
// listener on `window` never sees it. A scroll container inside another component's
|
|
299
|
+
// shadow root (`pie-modal`'s, for one) therefore needs its own listener, or the panel
|
|
300
|
+
// detaches from the trigger as soon as that container scrolls.
|
|
301
|
+
const shadowRoots = new Set<ShadowRoot>();
|
|
302
|
+
|
|
303
|
+
flattenedAncestors(this).forEach((ancestor) => {
|
|
304
|
+
const root = ancestor.getRootNode();
|
|
305
|
+
|
|
306
|
+
if (root instanceof ShadowRoot) {
|
|
307
|
+
shadowRoots.add(root);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
shadowRoots.forEach((root) => {
|
|
312
|
+
root.addEventListener('scroll', handleViewportChange, { capture: true, passive: true, signal });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// A trigger inside a container that was `display: none` when the panel opened has no box
|
|
316
|
+
// to measure. `pie-modal` calls `showModal()` from an async `firstUpdated`, so this is the
|
|
317
|
+
// normal case for a tooltip inside a modal that is open on load.
|
|
318
|
+
this._triggerObserver = new ResizeObserver(() => {
|
|
319
|
+
// Geometry changed, so the ancestor chain may have too. Flagged rather than resolved
|
|
320
|
+
// here so the walk runs at most once per frame.
|
|
321
|
+
this._shouldResolveOverlayMode = true;
|
|
322
|
+
handleViewportChange();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const triggerElement = this._getTriggerElement();
|
|
326
|
+
|
|
327
|
+
if (triggerElement) {
|
|
328
|
+
this._triggerObserver.observe(triggerElement);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
this._directionObserver = new MutationObserver(handleViewportChange);
|
|
332
|
+
this._directionObserver.observe(this.ownerDocument.documentElement, {
|
|
333
|
+
attributeFilter: ['dir'],
|
|
334
|
+
subtree: true,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
this._triggerTrackingController = controller;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private stopTrackingTrigger (): void {
|
|
341
|
+
this._triggerTrackingController?.abort();
|
|
342
|
+
this._triggerTrackingController = undefined;
|
|
343
|
+
|
|
344
|
+
this._directionObserver?.disconnect();
|
|
345
|
+
this._directionObserver = undefined;
|
|
346
|
+
|
|
347
|
+
this._triggerObserver?.disconnect();
|
|
348
|
+
this._triggerObserver = undefined;
|
|
349
|
+
|
|
350
|
+
this._shouldResolveOverlayMode = false;
|
|
351
|
+
|
|
352
|
+
if (this._reanchorFrame) {
|
|
353
|
+
cancelAnimationFrame(this._reanchorFrame);
|
|
354
|
+
this._reanchorFrame = 0;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private resolveMode (): void {
|
|
359
|
+
this._hasActionContent = this._assignedActionElements.length > 0;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Picks between `absolute` (browser handles scrolling, but clipped by overflow ancestors) and
|
|
363
|
+
// `fixed` (escapes overflow clips, but must re-offset on every scroll). Switches to `fixed`
|
|
364
|
+
// only when it escapes a clip that `absolute` would not.
|
|
365
|
+
//
|
|
366
|
+
// An overflow ancestor clips a positioned box only if it is that box's containing block or an
|
|
367
|
+
// ancestor of it; a clipper strictly inside the containing block does not clip. So each
|
|
368
|
+
// clipper is counted against a mode only once that mode's containing block has been reached.
|
|
369
|
+
private resolveOverlayMode (): void {
|
|
370
|
+
let isAtOrAboveAbsoluteContainingBlock = false;
|
|
371
|
+
let isAtOrAboveFixedContainingBlock = false;
|
|
372
|
+
let absoluteClippers = 0;
|
|
373
|
+
let fixedClippers = 0;
|
|
374
|
+
|
|
375
|
+
const { documentElement, body } = this.ownerDocument;
|
|
376
|
+
|
|
377
|
+
// Resolved on the same triggers as the overlay mode, because both answers change only
|
|
378
|
+
// when the ancestor chain does.
|
|
379
|
+
this._refreshTriggerClippers();
|
|
380
|
+
|
|
381
|
+
flattenedAncestors(this).forEach((element) => {
|
|
382
|
+
const styles = getComputedStyle(element);
|
|
383
|
+
|
|
384
|
+
// `display: contents` generates no box, so it can neither be a containing block nor
|
|
385
|
+
// clip. `display: none` is deliberately not skipped: its computed values still
|
|
386
|
+
// describe the box it will generate, so the answer holds once it is shown.
|
|
387
|
+
if (styles.display === 'contents') {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const isContainingBlock = createsContainingBlock(styles);
|
|
392
|
+
|
|
393
|
+
if (isContainingBlock || styles.position !== 'static') {
|
|
394
|
+
isAtOrAboveAbsoluteContainingBlock = true;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (isContainingBlock) {
|
|
398
|
+
isAtOrAboveFixedContainingBlock = true;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const clips = styles.overflowX !== 'visible' || styles.overflowY !== 'visible';
|
|
402
|
+
|
|
403
|
+
// Overflow on the root element and on the body propagates to the viewport, which no
|
|
404
|
+
// positioning scheme escapes. Counting it can only make `fixed` look no better than
|
|
405
|
+
// `absolute`, which is what happens while a `pie-modal` is open, because its scroll
|
|
406
|
+
// lock sets `overflow: hidden` on the body.
|
|
407
|
+
const propagatesOverflowToViewport = element === documentElement || element === body;
|
|
408
|
+
|
|
409
|
+
// Counted after the containing-block flags so an ancestor that is both the containing
|
|
410
|
+
// block and the clipper is counted correctly.
|
|
411
|
+
if (clips && !propagatesOverflowToViewport) {
|
|
412
|
+
if (isAtOrAboveAbsoluteContainingBlock) {
|
|
413
|
+
absoluteClippers += 1;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (isAtOrAboveFixedContainingBlock) {
|
|
417
|
+
fixedClippers += 1;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// The fixed containing block is always at or above the absolute one, so the clippers that
|
|
423
|
+
// apply to a fixed box are a subset of those that apply to an absolute one. A lower count
|
|
424
|
+
// therefore always means a strictly better escape, never a worse one.
|
|
425
|
+
this.style.position = fixedClippers < absoluteClippers ? 'fixed' : '';
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Measures the trigger relative to the origin marker (which sits at the containing block's
|
|
429
|
+
// origin) and writes CSS custom properties. Self-referential: correct for any containing
|
|
430
|
+
// block. Physical values; shadow-root CSS uses logical properties for RTL mirroring.
|
|
431
|
+
private projectOverTrigger (): void {
|
|
432
|
+
this._isPositioned = false;
|
|
433
|
+
|
|
434
|
+
const triggerElement = this._getTriggerElement();
|
|
435
|
+
|
|
436
|
+
if (!triggerElement || !this._originElement) {
|
|
437
|
+
['top', 'left', 'width', 'height'].forEach((suffix) => {
|
|
438
|
+
this.style.removeProperty(`--tooltip-anchor-${suffix}`);
|
|
439
|
+
});
|
|
440
|
+
this.style.removeProperty('--tooltip-container-inline-size');
|
|
441
|
+
this._isAnchorVisible = true;
|
|
442
|
+
this._isPositioned = true;
|
|
443
|
+
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (triggerElement !== this._clippedTriggerElement) {
|
|
448
|
+
this._refreshTriggerClippers();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Anchoring to the trigger's full box would point the panel at a part of the trigger that
|
|
452
|
+
// has been scrolled out of sight, leaving whatever sits over it — a pinned modal footer,
|
|
453
|
+
// for one — between the two. Anchoring to the visible part instead keeps the arrow on the
|
|
454
|
+
// edge the reader can actually see.
|
|
455
|
+
const visibleRect = this._triggerClippers.reduce<DOMRect | null>(
|
|
456
|
+
(rect, clipper) => (rect ? intersectRects(rect, getClipRect(clipper)) : null),
|
|
457
|
+
triggerElement.getBoundingClientRect(),
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
// Nothing of the trigger is left to point at, so there is nothing to describe either.
|
|
461
|
+
this._isAnchorVisible = visibleRect !== null;
|
|
462
|
+
|
|
463
|
+
if (!visibleRect) {
|
|
464
|
+
this._isPositioned = true;
|
|
465
|
+
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const originRect = this._originElement.getBoundingClientRect();
|
|
470
|
+
const {
|
|
471
|
+
top, left, width, height,
|
|
472
|
+
} = visibleRect;
|
|
473
|
+
|
|
474
|
+
this.style.setProperty('--tooltip-anchor-top', `${top - originRect.top}px`);
|
|
475
|
+
this.style.setProperty('--tooltip-anchor-left', `${left - originRect.left}px`);
|
|
476
|
+
this.style.setProperty('--tooltip-anchor-width', `${width}px`);
|
|
477
|
+
this.style.setProperty('--tooltip-anchor-height', `${height}px`);
|
|
478
|
+
|
|
479
|
+
const container = triggerElement.parentElement;
|
|
480
|
+
const containerInlineSize = container ? container.getBoundingClientRect().width : width;
|
|
481
|
+
|
|
482
|
+
this.style.setProperty('--tooltip-container-inline-size', `${containerInlineSize}px`);
|
|
483
|
+
this._isPositioned = true;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Cached because `projectOverTrigger` runs on every re-anchoring frame, and collecting these
|
|
487
|
+
// costs a computed style per ancestor. Refreshed when the trigger changes and whenever the
|
|
488
|
+
// overlay mode is resolved, which is the same cadence as an ancestor chain actually changing.
|
|
489
|
+
private _refreshTriggerClippers (): void {
|
|
490
|
+
const triggerElement = this._getTriggerElement();
|
|
491
|
+
|
|
492
|
+
this._clippedTriggerElement = triggerElement;
|
|
493
|
+
this._triggerClippers = triggerElement ? collectClippingAncestors(triggerElement) : [];
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private handleCloseButtonClick (): void {
|
|
497
|
+
this._requestClose();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private _getTriggerElement (): Element | null {
|
|
501
|
+
return this.trigger ? this.ownerDocument.getElementById(this.trigger) : null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private _requestOpen (): void {
|
|
505
|
+
if (this.isOpen) return;
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* @ignore
|
|
509
|
+
*/
|
|
510
|
+
this.dispatchEvent(new Event(ON_TOOLTIP_OPEN_EVENT, { bubbles: true, composed: true }));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private _requestClose (): void {
|
|
514
|
+
if (!this.isOpen) return;
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* @ignore
|
|
518
|
+
*/
|
|
519
|
+
this.dispatchEvent(new Event(ON_TOOLTIP_CLOSE_EVENT, { bubbles: true, composed: true }));
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private _startHoverCloseTimer (): void {
|
|
523
|
+
if (this._hoverCloseTimer !== undefined) return;
|
|
524
|
+
this._hoverCloseTimer = setTimeout(() => {
|
|
525
|
+
this._hoverCloseTimer = undefined;
|
|
526
|
+
this._requestClose();
|
|
527
|
+
}, 100);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private _cancelHoverCloseTimer (): void {
|
|
531
|
+
if (this._hoverCloseTimer !== undefined) {
|
|
532
|
+
clearTimeout(this._hoverCloseTimer);
|
|
533
|
+
this._hoverCloseTimer = undefined;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private _rebuildInteractionListeners (): void {
|
|
538
|
+
this._teardownInteractionListeners();
|
|
539
|
+
|
|
540
|
+
if (!this.triggers?.length) return;
|
|
541
|
+
|
|
542
|
+
const controller = new AbortController();
|
|
543
|
+
const { signal } = controller;
|
|
544
|
+
this._interactionController = controller;
|
|
545
|
+
|
|
546
|
+
const triggerEl = this._getTriggerElement();
|
|
547
|
+
|
|
548
|
+
this.ownerDocument.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
549
|
+
if (e.key === 'Escape' && this.isOpen) {
|
|
550
|
+
this._requestClose();
|
|
551
|
+
}
|
|
552
|
+
}, { signal });
|
|
553
|
+
|
|
554
|
+
if (!triggerEl) return;
|
|
555
|
+
|
|
556
|
+
if (this.triggers.includes('hover')) {
|
|
557
|
+
triggerEl.addEventListener('mouseenter', () => {
|
|
558
|
+
this._cancelHoverCloseTimer();
|
|
559
|
+
this._requestOpen();
|
|
560
|
+
}, { signal });
|
|
561
|
+
|
|
562
|
+
triggerEl.addEventListener('mouseleave', () => {
|
|
563
|
+
this._startHoverCloseTimer();
|
|
564
|
+
}, { signal });
|
|
565
|
+
|
|
566
|
+
// panel mouseenter/leave for the hover bridge (includes the bridge pseudo-element)
|
|
567
|
+
const panel = this.renderRoot.querySelector('.c-tooltip');
|
|
568
|
+
if (panel) {
|
|
569
|
+
panel.addEventListener('mouseenter', () => {
|
|
570
|
+
this._cancelHoverCloseTimer();
|
|
571
|
+
}, { signal });
|
|
572
|
+
|
|
573
|
+
panel.addEventListener('mouseleave', () => {
|
|
574
|
+
this._startHoverCloseTimer();
|
|
575
|
+
}, { signal });
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (this.triggers.includes('focus')) {
|
|
580
|
+
// open on focus, close on blur unless focus moved into panel action content
|
|
581
|
+
triggerEl.addEventListener('focusin', () => {
|
|
582
|
+
this._requestOpen();
|
|
583
|
+
}, { signal });
|
|
584
|
+
|
|
585
|
+
triggerEl.addEventListener('focusout', (e: Event) => {
|
|
586
|
+
const related = (e as FocusEvent).relatedTarget as Node | null;
|
|
587
|
+
// relatedTarget is retargeted to the shadow host when focus moves into shadow DOM
|
|
588
|
+
const staysInside = related && (this.contains(related) || related === this);
|
|
589
|
+
if (!staysInside) {
|
|
590
|
+
this._requestClose();
|
|
591
|
+
}
|
|
592
|
+
}, { signal });
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (this.triggers.includes('click')) {
|
|
596
|
+
triggerEl.addEventListener('click', (e: Event) => {
|
|
597
|
+
e.stopPropagation();
|
|
598
|
+
if (this.isOpen) {
|
|
599
|
+
this._requestClose();
|
|
600
|
+
} else {
|
|
601
|
+
this._requestOpen();
|
|
602
|
+
}
|
|
603
|
+
}, { signal });
|
|
604
|
+
|
|
605
|
+
// Light-dismiss: click anywhere outside the panel and trigger
|
|
606
|
+
this.ownerDocument.addEventListener('click', (e: Event) => {
|
|
607
|
+
const target = e.composedPath()[0] as Node;
|
|
608
|
+
const isInsidePanel = this.contains(target) || this.shadowRoot?.contains(target);
|
|
609
|
+
const isInsideTrigger = triggerEl.contains(target) || target === triggerEl;
|
|
610
|
+
if (!isInsidePanel && !isInsideTrigger && this.isOpen) {
|
|
611
|
+
this._requestClose();
|
|
612
|
+
}
|
|
613
|
+
}, { signal });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private _teardownInteractionListeners (): void {
|
|
618
|
+
this._interactionController?.abort();
|
|
619
|
+
this._interactionController = undefined;
|
|
620
|
+
this._cancelHoverCloseTimer();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private renderHeading (): TemplateResult {
|
|
624
|
+
const tag = unsafeStatic(this.headingLevel);
|
|
625
|
+
|
|
626
|
+
return html`<${tag}
|
|
627
|
+
id="${headingId}"
|
|
628
|
+
class="${componentClass}-heading"
|
|
629
|
+
data-test-id="${headingId}">${this.heading}</${tag}>`;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private renderCloseButton (): TemplateResult {
|
|
633
|
+
return html`
|
|
634
|
+
<pie-icon-button
|
|
635
|
+
class="${componentClass}-close"
|
|
636
|
+
data-test-id="${componentSelector}-close"
|
|
637
|
+
size="xsmall"
|
|
638
|
+
variant="${this.variant === 'inverse' ? 'ghost-secondary' : 'ghost-inverse'}"
|
|
639
|
+
.aria="${{ label: this.aria?.close }}"
|
|
640
|
+
@click="${this.handleCloseButtonClick}">
|
|
641
|
+
<icon-close></icon-close>
|
|
642
|
+
</pie-icon-button>`;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
render () {
|
|
646
|
+
const {
|
|
647
|
+
aria,
|
|
648
|
+
heading,
|
|
649
|
+
isDismissible,
|
|
650
|
+
isOpen,
|
|
651
|
+
position,
|
|
652
|
+
size,
|
|
653
|
+
type,
|
|
654
|
+
variant,
|
|
655
|
+
_mode: mode,
|
|
656
|
+
} = this;
|
|
657
|
+
|
|
658
|
+
const isIconType = type === 'icon';
|
|
659
|
+
|
|
660
|
+
const layerClasses = {
|
|
661
|
+
[`${componentClass}-layer`]: true,
|
|
662
|
+
[`${componentClass}-layer--${position}`]: true,
|
|
663
|
+
[`${componentClass}-layer--type-${type}`]: true,
|
|
664
|
+
'is-open': !!isOpen,
|
|
665
|
+
'is-positioned': this._isPositioned,
|
|
666
|
+
'is-anchorVisible': this._isAnchorVisible,
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const panelClasses = {
|
|
670
|
+
[componentClass]: true,
|
|
671
|
+
[`${componentClass}--${position}`]: true,
|
|
672
|
+
[`${componentClass}--${variant}`]: true,
|
|
673
|
+
[`${componentClass}--type-${type}`]: true,
|
|
674
|
+
[`${componentClass}--size-${size}`]: !isIconType,
|
|
675
|
+
'is-dismissible': !!isDismissible,
|
|
676
|
+
'has-action': mode === 'dialog',
|
|
677
|
+
'has-heading': !!heading,
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const isDialog = mode === 'dialog';
|
|
681
|
+
|
|
682
|
+
return html`
|
|
683
|
+
<div class="${componentClass}-origin" data-test-id="${componentSelector}-origin"></div>
|
|
684
|
+
<div class="${componentClass}-anchor" data-test-id="${componentSelector}-anchor">
|
|
685
|
+
<div class="${classMap(layerClasses)}" data-test-id="${componentSelector}-layer">
|
|
686
|
+
<div
|
|
687
|
+
class="${classMap(panelClasses)}"
|
|
688
|
+
data-test-id="${componentSelector}"
|
|
689
|
+
role="${ifDefined(mode)}"
|
|
690
|
+
aria-hidden="${!isOpen}"
|
|
691
|
+
aria-labelledby="${isDialog && heading ? headingId : nothing}"
|
|
692
|
+
aria-label="${isDialog && !heading && aria?.label ? aria.label : nothing}">
|
|
693
|
+
${isIconType ? nothing : html`<div class="${componentClass}-arrow" data-test-id="${componentSelector}-arrow"></div>`}
|
|
694
|
+
<div class="${componentClass}-body">
|
|
695
|
+
${heading ? this.renderHeading() : nothing}
|
|
696
|
+
<div class="${componentClass}-content" data-test-id="${componentSelector}-content">
|
|
697
|
+
<slot name="content"></slot>
|
|
698
|
+
</div>
|
|
699
|
+
</div>
|
|
700
|
+
<div class="${componentClass}-action" data-test-id="${componentSelector}-action">
|
|
701
|
+
<slot name="action" @slotchange="${this.resolveMode}"></slot>
|
|
702
|
+
</div>
|
|
703
|
+
${isDismissible ? this.renderCloseButton() : nothing}
|
|
704
|
+
</div>
|
|
705
|
+
</div>
|
|
706
|
+
</div>`;
|
|
707
|
+
}
|
|
26
708
|
}
|
|
27
709
|
|
|
28
710
|
declare global {
|