@justeattakeaway/pie-tooltip 0.2.0 → 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 +190 -7
- package/custom-elements.json +186 -1
- package/dist/index.d.ts +32 -2
- package/dist/index.js +256 -132
- package/dist/react.d.ts +33 -2
- package/dist/react.js +16 -12
- package/package.json +6 -6
- package/src/defs.ts +13 -1
- package/src/index.ts +325 -29
- package/src/react.ts +3 -1
- package/src/tooltip.scss +46 -2
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
defaultProps,
|
|
23
23
|
headingLevels,
|
|
24
24
|
ON_TOOLTIP_CLOSE_EVENT,
|
|
25
|
+
ON_TOOLTIP_OPEN_EVENT,
|
|
25
26
|
positions,
|
|
26
27
|
sizes,
|
|
27
28
|
types,
|
|
@@ -70,9 +71,72 @@ const createsContainingBlock = (styles: CSSStyleDeclaration): boolean => {
|
|
|
70
71
|
return /\b(transform|perspective|filter|backdrop-filter|contain|translate|rotate|scale)\b/.test(styles.willChange);
|
|
71
72
|
};
|
|
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
|
+
};
|
|
135
|
+
|
|
73
136
|
/**
|
|
74
137
|
* @tagname pie-tooltip
|
|
75
|
-
* @event {Event} 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.
|
|
76
140
|
* @slot content - The descriptive content of the panel. Must not contain focusable elements.
|
|
77
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.
|
|
78
142
|
*/
|
|
@@ -113,6 +177,9 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
113
177
|
@property({ type: Object })
|
|
114
178
|
public aria: TooltipProps['aria'];
|
|
115
179
|
|
|
180
|
+
@property({ type: Array })
|
|
181
|
+
public triggers: TooltipProps['triggers'] = [];
|
|
182
|
+
|
|
116
183
|
@queryAssignedElements({ slot: 'action' }) private _assignedActionElements!: Array<HTMLElement>;
|
|
117
184
|
|
|
118
185
|
@query('.c-tooltip-origin') private _originElement!: HTMLElement | null;
|
|
@@ -121,14 +188,25 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
121
188
|
|
|
122
189
|
@state() private _isPositioned = false;
|
|
123
190
|
|
|
191
|
+
@state() private _isAnchorVisible = true;
|
|
192
|
+
|
|
193
|
+
private _clippedTriggerElement: Element | null = null;
|
|
194
|
+
|
|
195
|
+
private _triggerClippers: Array<Element> = [];
|
|
196
|
+
|
|
124
197
|
private _triggerTrackingController: AbortController | undefined;
|
|
198
|
+
private _interactionController: AbortController | undefined;
|
|
125
199
|
|
|
126
200
|
private _directionObserver: MutationObserver | undefined;
|
|
127
201
|
|
|
202
|
+
private _triggerObserver: ResizeObserver | undefined;
|
|
203
|
+
|
|
128
204
|
private _reanchorFrame = 0;
|
|
129
205
|
|
|
130
206
|
private _shouldResolveOverlayMode = false;
|
|
131
207
|
|
|
208
|
+
private _hoverCloseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
209
|
+
|
|
132
210
|
static styles = unsafeCSS(styles);
|
|
133
211
|
|
|
134
212
|
private get _mode (): TooltipMode | undefined {
|
|
@@ -142,6 +220,7 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
142
220
|
protected firstUpdated (): void {
|
|
143
221
|
this.resolveMode();
|
|
144
222
|
this.projectOverTrigger();
|
|
223
|
+
this._rebuildInteractionListeners();
|
|
145
224
|
}
|
|
146
225
|
|
|
147
226
|
protected updated (changedProperties: PropertyValues<this>): void {
|
|
@@ -155,14 +234,26 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
155
234
|
this.projectOverTrigger();
|
|
156
235
|
}
|
|
157
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
|
+
|
|
158
244
|
if (this.isOpen) {
|
|
159
245
|
this.startTrackingTrigger();
|
|
160
246
|
} else {
|
|
161
247
|
this.stopTrackingTrigger();
|
|
162
248
|
}
|
|
249
|
+
|
|
250
|
+
if (changedProperties.has('triggers') || changedProperties.has('trigger')) {
|
|
251
|
+
this._rebuildInteractionListeners();
|
|
252
|
+
}
|
|
163
253
|
}
|
|
164
254
|
|
|
165
255
|
public disconnectedCallback (): void {
|
|
256
|
+
this._teardownInteractionListeners();
|
|
166
257
|
this.stopTrackingTrigger();
|
|
167
258
|
super.disconnectedCallback();
|
|
168
259
|
}
|
|
@@ -203,6 +294,40 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
203
294
|
window.addEventListener('scroll', handleViewportChange, { capture: true, passive: true, signal });
|
|
204
295
|
window.addEventListener('resize', handleResize, { passive: true, signal });
|
|
205
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
|
+
|
|
206
331
|
this._directionObserver = new MutationObserver(handleViewportChange);
|
|
207
332
|
this._directionObserver.observe(this.ownerDocument.documentElement, {
|
|
208
333
|
attributeFilter: ['dir'],
|
|
@@ -219,6 +344,9 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
219
344
|
this._directionObserver?.disconnect();
|
|
220
345
|
this._directionObserver = undefined;
|
|
221
346
|
|
|
347
|
+
this._triggerObserver?.disconnect();
|
|
348
|
+
this._triggerObserver = undefined;
|
|
349
|
+
|
|
222
350
|
this._shouldResolveOverlayMode = false;
|
|
223
351
|
|
|
224
352
|
if (this._reanchorFrame) {
|
|
@@ -234,23 +362,32 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
234
362
|
// Picks between `absolute` (browser handles scrolling, but clipped by overflow ancestors) and
|
|
235
363
|
// `fixed` (escapes overflow clips, but must re-offset on every scroll). Switches to `fixed`
|
|
236
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.
|
|
237
369
|
private resolveOverlayMode (): void {
|
|
238
370
|
let isAtOrAboveAbsoluteContainingBlock = false;
|
|
239
371
|
let isAtOrAboveFixedContainingBlock = false;
|
|
240
|
-
let
|
|
241
|
-
let
|
|
372
|
+
let absoluteClippers = 0;
|
|
373
|
+
let fixedClippers = 0;
|
|
242
374
|
|
|
243
|
-
|
|
375
|
+
const { documentElement, body } = this.ownerDocument;
|
|
244
376
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
377
|
+
// Resolved on the same triggers as the overlay mode, because both answers change only
|
|
378
|
+
// when the ancestor chain does.
|
|
379
|
+
this._refreshTriggerClippers();
|
|
248
380
|
|
|
249
|
-
|
|
250
|
-
|
|
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;
|
|
251
389
|
}
|
|
252
390
|
|
|
253
|
-
const styles = getComputedStyle(element);
|
|
254
391
|
const isContainingBlock = createsContainingBlock(styles);
|
|
255
392
|
|
|
256
393
|
if (isContainingBlock || styles.position !== 'static') {
|
|
@@ -261,21 +398,31 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
261
398
|
isAtOrAboveFixedContainingBlock = true;
|
|
262
399
|
}
|
|
263
400
|
|
|
264
|
-
|
|
265
|
-
// containing block and the clipper is counted correctly.
|
|
266
|
-
if (styles.overflowX !== 'visible' || styles.overflowY !== 'visible') {
|
|
267
|
-
isAbsoluteClipped = isAbsoluteClipped || isAtOrAboveAbsoluteContainingBlock;
|
|
268
|
-
isFixedClipped = isFixedClipped || isAtOrAboveFixedContainingBlock;
|
|
269
|
-
}
|
|
401
|
+
const clips = styles.overflowX !== 'visible' || styles.overflowY !== 'visible';
|
|
270
402
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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;
|
|
274
408
|
|
|
275
|
-
|
|
276
|
-
|
|
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
|
+
}
|
|
277
415
|
|
|
278
|
-
|
|
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' : '';
|
|
279
426
|
}
|
|
280
427
|
|
|
281
428
|
// Measures the trigger relative to the origin marker (which sits at the containing block's
|
|
@@ -284,13 +431,36 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
284
431
|
private projectOverTrigger (): void {
|
|
285
432
|
this._isPositioned = false;
|
|
286
433
|
|
|
287
|
-
const triggerElement = this.
|
|
434
|
+
const triggerElement = this._getTriggerElement();
|
|
288
435
|
|
|
289
436
|
if (!triggerElement || !this._originElement) {
|
|
290
437
|
['top', 'left', 'width', 'height'].forEach((suffix) => {
|
|
291
438
|
this.style.removeProperty(`--tooltip-anchor-${suffix}`);
|
|
292
439
|
});
|
|
293
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) {
|
|
294
464
|
this._isPositioned = true;
|
|
295
465
|
|
|
296
466
|
return;
|
|
@@ -299,7 +469,7 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
299
469
|
const originRect = this._originElement.getBoundingClientRect();
|
|
300
470
|
const {
|
|
301
471
|
top, left, width, height,
|
|
302
|
-
} =
|
|
472
|
+
} = visibleRect;
|
|
303
473
|
|
|
304
474
|
this.style.setProperty('--tooltip-anchor-top', `${top - originRect.top}px`);
|
|
305
475
|
this.style.setProperty('--tooltip-anchor-left', `${left - originRect.left}px`);
|
|
@@ -313,18 +483,143 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
313
483
|
this._isPositioned = true;
|
|
314
484
|
}
|
|
315
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
|
+
|
|
316
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
|
+
|
|
317
516
|
/**
|
|
318
|
-
* The custom elements manifest analyser scans `this.dispatchEvent` calls for event names
|
|
319
|
-
* but cannot resolve constants, so it would record an event literally named
|
|
320
|
-
* `ON_TOOLTIP_CLOSE_EVENT` and generate a matching React callback prop. `@ignore` skips
|
|
321
|
-
* this call site; the event is declared by the class-level `@event` tag instead.
|
|
322
|
-
*
|
|
323
517
|
* @ignore
|
|
324
518
|
*/
|
|
325
519
|
this.dispatchEvent(new Event(ON_TOOLTIP_CLOSE_EVENT, { bubbles: true, composed: true }));
|
|
326
520
|
}
|
|
327
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
|
+
|
|
328
623
|
private renderHeading (): TemplateResult {
|
|
329
624
|
const tag = unsafeStatic(this.headingLevel);
|
|
330
625
|
|
|
@@ -368,6 +663,7 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
368
663
|
[`${componentClass}-layer--type-${type}`]: true,
|
|
369
664
|
'is-open': !!isOpen,
|
|
370
665
|
'is-positioned': this._isPositioned,
|
|
666
|
+
'is-anchorVisible': this._isAnchorVisible,
|
|
371
667
|
};
|
|
372
668
|
|
|
373
669
|
const panelClasses = {
|
package/src/react.ts
CHANGED
|
@@ -11,13 +11,15 @@ const PieTooltipReact = createComponent({
|
|
|
11
11
|
react: React,
|
|
12
12
|
tagName: 'pie-tooltip',
|
|
13
13
|
events: {
|
|
14
|
-
|
|
14
|
+
onPieTooltipOpen: 'pie-tooltip-open' as EventName<Event>, // When a configured trigger asks for the panel. Set `isOpen` to `true` in response.
|
|
15
|
+
onPieTooltipClose: 'pie-tooltip-close' as EventName<Event>, // When the close button is clicked, or a configured trigger asks to dismiss the panel. Set `isOpen` to `false` in response.
|
|
15
16
|
},
|
|
16
17
|
});
|
|
17
18
|
|
|
18
19
|
type ReactBaseType = React.HTMLAttributes<HTMLElement>
|
|
19
20
|
|
|
20
21
|
type PieTooltipEvents = {
|
|
22
|
+
onPieTooltipOpen?: (event: Event) => void;
|
|
21
23
|
onPieTooltipClose?: (event: Event) => void;
|
|
22
24
|
};
|
|
23
25
|
|
package/src/tooltip.scss
CHANGED
|
@@ -65,7 +65,9 @@ $tooltip-alignments: ('', '-start', '-end');
|
|
|
65
65
|
visibility: hidden;
|
|
66
66
|
transition: visibility 0s linear 150ms;
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
// `is-anchorVisible` is false when the trigger has been scrolled entirely out of a clipping
|
|
69
|
+
// ancestor: there is nothing left to point at, so the panel hides rather than float free.
|
|
70
|
+
&.is-open.is-positioned.is-anchorVisible {
|
|
69
71
|
visibility: visible;
|
|
70
72
|
transition-delay: 0s;
|
|
71
73
|
}
|
|
@@ -168,7 +170,7 @@ $tooltip-alignments: ('', '-start', '-end');
|
|
|
168
170
|
transform var(--tooltip-motion-duration) var(--tooltip-motion-easing);
|
|
169
171
|
}
|
|
170
172
|
|
|
171
|
-
.c-tooltip-layer.is-open.is-positioned .c-tooltip {
|
|
173
|
+
.c-tooltip-layer.is-open.is-positioned.is-anchorVisible .c-tooltip {
|
|
172
174
|
--tooltip-motion-duration: 200ms;
|
|
173
175
|
|
|
174
176
|
opacity: 1;
|
|
@@ -367,3 +369,45 @@ $tooltip-alignments: ('', '-start', '-end');
|
|
|
367
369
|
inset-block-start: var(--dt-spacing-b);
|
|
368
370
|
inset-inline-end: var(--dt-spacing-b);
|
|
369
371
|
}
|
|
372
|
+
|
|
373
|
+
// Hover bridge: a transparent pseudo-element extending from each panel edge toward its
|
|
374
|
+
// trigger, covering the --tooltip-layer-offset gap. Keeps the hover region intact so
|
|
375
|
+
// the pointer can travel from the trigger into the panel without the close timer firing.
|
|
376
|
+
// Sized from --tooltip-layer-offset so widening --tooltip-offset cannot open a hole.
|
|
377
|
+
@each $alignment in $tooltip-alignments {
|
|
378
|
+
.c-tooltip--top#{$alignment}::before {
|
|
379
|
+
content: '';
|
|
380
|
+
position: absolute;
|
|
381
|
+
inset-block-start: 100%;
|
|
382
|
+
block-size: var(--tooltip-layer-offset);
|
|
383
|
+
inset-inline-start: 0;
|
|
384
|
+
inline-size: 100%;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.c-tooltip--bottom#{$alignment}::before {
|
|
388
|
+
content: '';
|
|
389
|
+
position: absolute;
|
|
390
|
+
inset-block-end: 100%;
|
|
391
|
+
block-size: var(--tooltip-layer-offset);
|
|
392
|
+
inset-inline-start: 0;
|
|
393
|
+
inline-size: 100%;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.c-tooltip--left#{$alignment}::before {
|
|
397
|
+
content: '';
|
|
398
|
+
position: absolute;
|
|
399
|
+
inset-inline-start: 100%;
|
|
400
|
+
inline-size: var(--tooltip-layer-offset);
|
|
401
|
+
inset-block-start: 0;
|
|
402
|
+
block-size: 100%;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.c-tooltip--right#{$alignment}::before {
|
|
406
|
+
content: '';
|
|
407
|
+
position: absolute;
|
|
408
|
+
inset-inline-end: 100%;
|
|
409
|
+
inline-size: var(--tooltip-layer-offset);
|
|
410
|
+
inset-block-start: 0;
|
|
411
|
+
block-size: 100%;
|
|
412
|
+
}
|
|
413
|
+
}
|