@justeattakeaway/pie-tooltip 0.2.0 → 0.4.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 +202 -8
- package/custom-elements.json +292 -2
- package/dist/index.d.ts +45 -4
- package/dist/index.js +387 -160
- package/dist/react.d.ts +46 -4
- package/dist/react.js +16 -12
- package/package.json +6 -6
- package/src/defs.ts +17 -2
- package/src/index.ts +599 -39
- 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,181 @@ 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
|
+
|
|
136
|
+
type TooltipSide = 'top' | 'bottom' | 'left' | 'right';
|
|
137
|
+
type TooltipAlignment = '' | '-start' | '-end';
|
|
138
|
+
|
|
139
|
+
interface CandidateRect {
|
|
140
|
+
left: number;
|
|
141
|
+
top: number;
|
|
142
|
+
right: number;
|
|
143
|
+
bottom: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const oppositeSide: Record<TooltipSide, TooltipSide> = {
|
|
147
|
+
top: 'bottom',
|
|
148
|
+
bottom: 'top',
|
|
149
|
+
left: 'right',
|
|
150
|
+
right: 'left',
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const crossSides: Record<TooltipSide, Array<TooltipSide>> = {
|
|
154
|
+
top: ['left', 'right'],
|
|
155
|
+
bottom: ['left', 'right'],
|
|
156
|
+
left: ['top', 'bottom'],
|
|
157
|
+
right: ['top', 'bottom'],
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const parsePosition = (position: TooltipProps['position']): { side: TooltipSide; alignment: TooltipAlignment } => {
|
|
161
|
+
const match = /^(top|bottom|left|right)(-start|-end)?$/.exec(position ?? '');
|
|
162
|
+
|
|
163
|
+
if (!match) {
|
|
164
|
+
return { side: 'top', alignment: '' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { side: match[1] as TooltipSide, alignment: (match[2] ?? '') as TooltipAlignment };
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const toPhysicalSide = (side: TooltipSide, isRtl: boolean): TooltipSide => {
|
|
171
|
+
if (!isRtl) {
|
|
172
|
+
return side;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (side === 'left') {
|
|
176
|
+
return 'right';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (side === 'right') {
|
|
180
|
+
return 'left';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return side;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const getCandidateRect = (
|
|
187
|
+
side: TooltipSide,
|
|
188
|
+
alignment: TooltipAlignment,
|
|
189
|
+
anchor: DOMRect,
|
|
190
|
+
panelWidth: number,
|
|
191
|
+
panelHeight: number,
|
|
192
|
+
offset: number,
|
|
193
|
+
isRtl: boolean,
|
|
194
|
+
): CandidateRect => {
|
|
195
|
+
const physicalSide = toPhysicalSide(side, isRtl);
|
|
196
|
+
const isVerticalSide = physicalSide === 'top' || physicalSide === 'bottom';
|
|
197
|
+
|
|
198
|
+
let left = 0;
|
|
199
|
+
let top = 0;
|
|
200
|
+
|
|
201
|
+
if (physicalSide === 'top') {
|
|
202
|
+
top = anchor.top - offset - panelHeight;
|
|
203
|
+
} else if (physicalSide === 'bottom') {
|
|
204
|
+
top = anchor.bottom + offset;
|
|
205
|
+
} else if (physicalSide === 'left') {
|
|
206
|
+
left = anchor.left - offset - panelWidth;
|
|
207
|
+
} else {
|
|
208
|
+
left = anchor.right + offset;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (isVerticalSide) {
|
|
212
|
+
if (alignment === '') {
|
|
213
|
+
left = anchor.left + (anchor.width / 2) - (panelWidth / 2);
|
|
214
|
+
} else if (alignment === '-start') {
|
|
215
|
+
left = isRtl ? anchor.right - panelWidth : anchor.left;
|
|
216
|
+
} else {
|
|
217
|
+
left = isRtl ? anchor.left : anchor.right - panelWidth;
|
|
218
|
+
}
|
|
219
|
+
} else if (alignment === '') {
|
|
220
|
+
top = anchor.top + (anchor.height / 2) - (panelHeight / 2);
|
|
221
|
+
} else {
|
|
222
|
+
top = alignment === '-start' ? anchor.top : anchor.bottom - panelHeight;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
left,
|
|
227
|
+
top,
|
|
228
|
+
right: left + panelWidth,
|
|
229
|
+
bottom: top + panelHeight,
|
|
230
|
+
};
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const containsRect = (boundary: DOMRect, rect: CandidateRect): boolean => rect.left >= boundary.left &&
|
|
234
|
+
rect.right <= boundary.right &&
|
|
235
|
+
rect.top >= boundary.top &&
|
|
236
|
+
rect.bottom <= boundary.bottom;
|
|
237
|
+
|
|
238
|
+
const getVisibleArea = (boundary: DOMRect, rect: CandidateRect): number => {
|
|
239
|
+
const width = Math.min(boundary.right, rect.right) - Math.max(boundary.left, rect.left);
|
|
240
|
+
const height = Math.min(boundary.bottom, rect.bottom) - Math.max(boundary.top, rect.top);
|
|
241
|
+
|
|
242
|
+
return Math.max(0, width) * Math.max(0, height);
|
|
243
|
+
};
|
|
244
|
+
|
|
73
245
|
/**
|
|
74
246
|
* @tagname pie-tooltip
|
|
75
|
-
* @event {Event} pie-tooltip-
|
|
247
|
+
* @event {Event} pie-tooltip-open - When a configured trigger asks for the panel. Set `isOpen` to `true` in response.
|
|
248
|
+
* @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
249
|
* @slot content - The descriptive content of the panel. Must not contain focusable elements.
|
|
77
250
|
* @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
251
|
*/
|
|
@@ -113,6 +286,9 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
113
286
|
@property({ type: Object })
|
|
114
287
|
public aria: TooltipProps['aria'];
|
|
115
288
|
|
|
289
|
+
@property({ type: Array })
|
|
290
|
+
public triggers: TooltipProps['triggers'] = [];
|
|
291
|
+
|
|
116
292
|
@queryAssignedElements({ slot: 'action' }) private _assignedActionElements!: Array<HTMLElement>;
|
|
117
293
|
|
|
118
294
|
@query('.c-tooltip-origin') private _originElement!: HTMLElement | null;
|
|
@@ -121,13 +297,32 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
121
297
|
|
|
122
298
|
@state() private _isPositioned = false;
|
|
123
299
|
|
|
300
|
+
@state() private _isAnchorVisible = true;
|
|
301
|
+
|
|
302
|
+
@state() private _resolvedPosition: TooltipProps['position'] | undefined;
|
|
303
|
+
|
|
304
|
+
private _clippedTriggerElement: Element | null = null;
|
|
305
|
+
|
|
306
|
+
private _triggerClippers: Array<Element> = [];
|
|
307
|
+
|
|
308
|
+
private _overlayClippers: Array<Element> = [];
|
|
309
|
+
|
|
310
|
+
private _collisionSignature: string | null = null;
|
|
311
|
+
|
|
312
|
+
private _overlayModeDirty = true;
|
|
313
|
+
|
|
124
314
|
private _triggerTrackingController: AbortController | undefined;
|
|
315
|
+
private _interactionController: AbortController | undefined;
|
|
125
316
|
|
|
126
317
|
private _directionObserver: MutationObserver | undefined;
|
|
127
318
|
|
|
319
|
+
private _triggerObserver: ResizeObserver | undefined;
|
|
320
|
+
|
|
128
321
|
private _reanchorFrame = 0;
|
|
129
322
|
|
|
130
|
-
private
|
|
323
|
+
private _hoverCloseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
324
|
+
|
|
325
|
+
private _openedByClick = false;
|
|
131
326
|
|
|
132
327
|
static styles = unsafeCSS(styles);
|
|
133
328
|
|
|
@@ -141,28 +336,59 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
141
336
|
|
|
142
337
|
protected firstUpdated (): void {
|
|
143
338
|
this.resolveMode();
|
|
339
|
+
this._overlayModeDirty = true;
|
|
340
|
+
this.resolveOverlayMode();
|
|
144
341
|
this.projectOverTrigger();
|
|
342
|
+
this._rebuildInteractionListeners();
|
|
145
343
|
}
|
|
146
344
|
|
|
147
345
|
protected updated (changedProperties: PropertyValues<this>): void {
|
|
148
346
|
const anchoringProperties: Array<keyof PieTooltip> = ['trigger', 'isOpen', 'position', 'size'];
|
|
149
347
|
|
|
150
348
|
if (this.isOpen && changedProperties.has('isOpen')) {
|
|
151
|
-
this.
|
|
349
|
+
this._overlayModeDirty = true;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (!this.isOpen) {
|
|
353
|
+
this._openedByClick = false;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (changedProperties.has('trigger')) {
|
|
357
|
+
this._overlayModeDirty = true;
|
|
152
358
|
}
|
|
153
359
|
|
|
154
|
-
|
|
360
|
+
const isAnchoringChange = anchoringProperties.some((prop) => changedProperties.has(prop));
|
|
361
|
+
|
|
362
|
+
if (isAnchoringChange) {
|
|
363
|
+
this._collisionSignature = null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
this.resolveOverlayMode();
|
|
367
|
+
|
|
368
|
+
if (isAnchoringChange) {
|
|
155
369
|
this.projectOverTrigger();
|
|
156
370
|
}
|
|
157
371
|
|
|
372
|
+
// Tracking is bound to a specific trigger element and a specific ancestor chain, so a new
|
|
373
|
+
// trigger needs it rebuilt rather than left in place. `startTrackingTrigger` early-returns
|
|
374
|
+
// while a controller exists, so the stop has to come first.
|
|
375
|
+
if (this.isOpen && changedProperties.has('trigger')) {
|
|
376
|
+
this.stopTrackingTrigger();
|
|
377
|
+
}
|
|
378
|
+
|
|
158
379
|
if (this.isOpen) {
|
|
159
380
|
this.startTrackingTrigger();
|
|
160
381
|
} else {
|
|
161
382
|
this.stopTrackingTrigger();
|
|
162
383
|
}
|
|
384
|
+
|
|
385
|
+
if (changedProperties.has('triggers') || changedProperties.has('trigger')) {
|
|
386
|
+
this._rebuildInteractionListeners();
|
|
387
|
+
}
|
|
163
388
|
}
|
|
164
389
|
|
|
165
390
|
public disconnectedCallback (): void {
|
|
391
|
+
this._teardownInteractionListeners();
|
|
166
392
|
this.stopTrackingTrigger();
|
|
167
393
|
super.disconnectedCallback();
|
|
168
394
|
}
|
|
@@ -185,24 +411,54 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
185
411
|
this._reanchorFrame = requestAnimationFrame(() => {
|
|
186
412
|
this._reanchorFrame = 0;
|
|
187
413
|
|
|
188
|
-
|
|
189
|
-
this._shouldResolveOverlayMode = false;
|
|
190
|
-
this.resolveOverlayMode();
|
|
191
|
-
}
|
|
192
|
-
|
|
414
|
+
this.resolveOverlayMode();
|
|
193
415
|
this.projectOverTrigger();
|
|
194
416
|
});
|
|
195
417
|
};
|
|
196
418
|
|
|
197
419
|
// Flagged rather than resolved immediately so window-drag cannot walk ancestors more than once per frame.
|
|
198
420
|
const handleResize = () => {
|
|
199
|
-
this.
|
|
421
|
+
this._overlayModeDirty = true;
|
|
200
422
|
handleViewportChange();
|
|
201
423
|
};
|
|
202
424
|
|
|
203
425
|
window.addEventListener('scroll', handleViewportChange, { capture: true, passive: true, signal });
|
|
204
426
|
window.addEventListener('resize', handleResize, { passive: true, signal });
|
|
205
427
|
|
|
428
|
+
// `scroll` is not composed, so its path stops at the shadow root it happened in and a
|
|
429
|
+
// listener on `window` never sees it. A scroll container inside another component's
|
|
430
|
+
// shadow root (`pie-modal`'s, for one) therefore needs its own listener, or the panel
|
|
431
|
+
// detaches from the trigger as soon as that container scrolls.
|
|
432
|
+
const shadowRoots = new Set<ShadowRoot>();
|
|
433
|
+
|
|
434
|
+
flattenedAncestors(this).forEach((ancestor) => {
|
|
435
|
+
const root = ancestor.getRootNode();
|
|
436
|
+
|
|
437
|
+
if (root instanceof ShadowRoot) {
|
|
438
|
+
shadowRoots.add(root);
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
shadowRoots.forEach((root) => {
|
|
443
|
+
root.addEventListener('scroll', handleViewportChange, { capture: true, passive: true, signal });
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// A trigger inside a container that was `display: none` when the panel opened has no box
|
|
447
|
+
// to measure. `pie-modal` calls `showModal()` from an async `firstUpdated`, so this is the
|
|
448
|
+
// normal case for a tooltip inside a modal that is open on load.
|
|
449
|
+
this._triggerObserver = new ResizeObserver(() => {
|
|
450
|
+
// Geometry changed, so the ancestor chain may have too. Flagged rather than resolved
|
|
451
|
+
// here so the walk runs at most once per frame.
|
|
452
|
+
this._overlayModeDirty = true;
|
|
453
|
+
handleViewportChange();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const triggerElement = this._getTriggerElement();
|
|
457
|
+
|
|
458
|
+
if (triggerElement) {
|
|
459
|
+
this._triggerObserver.observe(triggerElement);
|
|
460
|
+
}
|
|
461
|
+
|
|
206
462
|
this._directionObserver = new MutationObserver(handleViewportChange);
|
|
207
463
|
this._directionObserver.observe(this.ownerDocument.documentElement, {
|
|
208
464
|
attributeFilter: ['dir'],
|
|
@@ -219,7 +475,8 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
219
475
|
this._directionObserver?.disconnect();
|
|
220
476
|
this._directionObserver = undefined;
|
|
221
477
|
|
|
222
|
-
this.
|
|
478
|
+
this._triggerObserver?.disconnect();
|
|
479
|
+
this._triggerObserver = undefined;
|
|
223
480
|
|
|
224
481
|
if (this._reanchorFrame) {
|
|
225
482
|
cancelAnimationFrame(this._reanchorFrame);
|
|
@@ -234,23 +491,38 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
234
491
|
// Picks between `absolute` (browser handles scrolling, but clipped by overflow ancestors) and
|
|
235
492
|
// `fixed` (escapes overflow clips, but must re-offset on every scroll). Switches to `fixed`
|
|
236
493
|
// only when it escapes a clip that `absolute` would not.
|
|
494
|
+
//
|
|
495
|
+
// An overflow ancestor clips a positioned box only if it is that box's containing block or an
|
|
496
|
+
// ancestor of it; a clipper strictly inside the containing block does not clip. So each
|
|
497
|
+
// clipper is counted against a mode only once that mode's containing block has been reached.
|
|
237
498
|
private resolveOverlayMode (): void {
|
|
499
|
+
if (!this._overlayModeDirty) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
this._overlayModeDirty = false;
|
|
504
|
+
|
|
238
505
|
let isAtOrAboveAbsoluteContainingBlock = false;
|
|
239
506
|
let isAtOrAboveFixedContainingBlock = false;
|
|
240
|
-
|
|
241
|
-
|
|
507
|
+
const absoluteClippingAncestors: Array<Element> = [];
|
|
508
|
+
const fixedClippingAncestors: Array<Element> = [];
|
|
509
|
+
|
|
510
|
+
const { documentElement, body } = this.ownerDocument;
|
|
242
511
|
|
|
243
|
-
|
|
512
|
+
// Resolved on the same triggers as the overlay mode, because both answers change only
|
|
513
|
+
// when the ancestor chain does.
|
|
514
|
+
this._refreshTriggerClippers();
|
|
244
515
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const element = node instanceof ShadowRoot ? node.host : node;
|
|
516
|
+
flattenedAncestors(this).forEach((element) => {
|
|
517
|
+
const styles = getComputedStyle(element);
|
|
248
518
|
|
|
249
|
-
|
|
250
|
-
|
|
519
|
+
// `display: contents` generates no box, so it can neither be a containing block nor
|
|
520
|
+
// clip. `display: none` is deliberately not skipped: its computed values still
|
|
521
|
+
// describe the box it will generate, so the answer holds once it is shown.
|
|
522
|
+
if (styles.display === 'contents') {
|
|
523
|
+
return;
|
|
251
524
|
}
|
|
252
525
|
|
|
253
|
-
const styles = getComputedStyle(element);
|
|
254
526
|
const isContainingBlock = createsContainingBlock(styles);
|
|
255
527
|
|
|
256
528
|
if (isContainingBlock || styles.position !== 'static') {
|
|
@@ -261,21 +533,34 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
261
533
|
isAtOrAboveFixedContainingBlock = true;
|
|
262
534
|
}
|
|
263
535
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
536
|
+
const clips = styles.overflowX !== 'visible' || styles.overflowY !== 'visible';
|
|
537
|
+
|
|
538
|
+
// Overflow on the root element and on the body propagates to the viewport, which no
|
|
539
|
+
// positioning scheme escapes. Counting it can only make `fixed` look no better than
|
|
540
|
+
// `absolute`, which is what happens while a `pie-modal` is open, because its scroll
|
|
541
|
+
// lock sets `overflow: hidden` on the body.
|
|
542
|
+
const propagatesOverflowToViewport = element === documentElement || element === body;
|
|
270
543
|
|
|
271
|
-
|
|
272
|
-
|
|
544
|
+
// Counted after the containing-block flags so an ancestor that is both the containing
|
|
545
|
+
// block and the clipper is counted correctly.
|
|
546
|
+
if (clips && !propagatesOverflowToViewport) {
|
|
547
|
+
if (isAtOrAboveAbsoluteContainingBlock) {
|
|
548
|
+
absoluteClippingAncestors.push(element);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (isAtOrAboveFixedContainingBlock) {
|
|
552
|
+
fixedClippingAncestors.push(element);
|
|
553
|
+
}
|
|
273
554
|
}
|
|
555
|
+
});
|
|
274
556
|
|
|
275
|
-
|
|
276
|
-
|
|
557
|
+
// The fixed containing block is always at or above the absolute one, so the clippers that
|
|
558
|
+
// apply to a fixed box are a subset of those that apply to an absolute one. A lower count
|
|
559
|
+
// therefore always means a strictly better escape, never a worse one.
|
|
560
|
+
const useFixed = fixedClippingAncestors.length < absoluteClippingAncestors.length;
|
|
277
561
|
|
|
278
|
-
this.style.position =
|
|
562
|
+
this.style.position = useFixed ? 'fixed' : '';
|
|
563
|
+
this._overlayClippers = useFixed ? fixedClippingAncestors : absoluteClippingAncestors;
|
|
279
564
|
}
|
|
280
565
|
|
|
281
566
|
// Measures the trigger relative to the origin marker (which sits at the containing block's
|
|
@@ -284,13 +569,36 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
284
569
|
private projectOverTrigger (): void {
|
|
285
570
|
this._isPositioned = false;
|
|
286
571
|
|
|
287
|
-
const triggerElement = this.
|
|
572
|
+
const triggerElement = this._getTriggerElement();
|
|
288
573
|
|
|
289
574
|
if (!triggerElement || !this._originElement) {
|
|
290
575
|
['top', 'left', 'width', 'height'].forEach((suffix) => {
|
|
291
576
|
this.style.removeProperty(`--tooltip-anchor-${suffix}`);
|
|
292
577
|
});
|
|
293
578
|
this.style.removeProperty('--tooltip-container-inline-size');
|
|
579
|
+
this._isAnchorVisible = true;
|
|
580
|
+
this._isPositioned = true;
|
|
581
|
+
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (triggerElement !== this._clippedTriggerElement) {
|
|
586
|
+
this._refreshTriggerClippers();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Anchoring to the trigger's full box would point the panel at a part of the trigger that
|
|
590
|
+
// has been scrolled out of sight, leaving whatever sits over it — a pinned modal footer,
|
|
591
|
+
// for one — between the two. Anchoring to the visible part instead keeps the arrow on the
|
|
592
|
+
// edge the reader can actually see.
|
|
593
|
+
const visibleRect = this._triggerClippers.reduce<DOMRect | null>(
|
|
594
|
+
(rect, clipper) => (rect ? intersectRects(rect, getClipRect(clipper)) : null),
|
|
595
|
+
triggerElement.getBoundingClientRect(),
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
// Nothing of the trigger is left to point at, so there is nothing to describe either.
|
|
599
|
+
this._isAnchorVisible = visibleRect !== null;
|
|
600
|
+
|
|
601
|
+
if (!visibleRect) {
|
|
294
602
|
this._isPositioned = true;
|
|
295
603
|
|
|
296
604
|
return;
|
|
@@ -299,7 +607,7 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
299
607
|
const originRect = this._originElement.getBoundingClientRect();
|
|
300
608
|
const {
|
|
301
609
|
top, left, width, height,
|
|
302
|
-
} =
|
|
610
|
+
} = visibleRect;
|
|
303
611
|
|
|
304
612
|
this.style.setProperty('--tooltip-anchor-top', `${top - originRect.top}px`);
|
|
305
613
|
this.style.setProperty('--tooltip-anchor-left', `${left - originRect.left}px`);
|
|
@@ -310,21 +618,270 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
310
618
|
const containerInlineSize = container ? container.getBoundingClientRect().width : width;
|
|
311
619
|
|
|
312
620
|
this.style.setProperty('--tooltip-container-inline-size', `${containerInlineSize}px`);
|
|
621
|
+
|
|
622
|
+
this.resolveCollision(visibleRect);
|
|
623
|
+
|
|
313
624
|
this._isPositioned = true;
|
|
314
625
|
}
|
|
315
626
|
|
|
627
|
+
private resolveCollision (anchorRect: DOMRect): void {
|
|
628
|
+
const panel = this.renderRoot.querySelector<HTMLElement>(`.${componentClass}`);
|
|
629
|
+
|
|
630
|
+
if (!panel) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const boundary = this._getCollisionBoundary();
|
|
635
|
+
|
|
636
|
+
if (!boundary) {
|
|
637
|
+
this._collisionSignature = null;
|
|
638
|
+
this._setResolvedPosition(this.position ?? defaultProps.position);
|
|
639
|
+
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const isIconType = this.type === 'icon';
|
|
644
|
+
const hostStyles = getComputedStyle(this);
|
|
645
|
+
const isRtl = hostStyles.direction === 'rtl';
|
|
646
|
+
|
|
647
|
+
const panelWidth = panel.offsetWidth;
|
|
648
|
+
const panelHeight = panel.offsetHeight;
|
|
649
|
+
const signature = [
|
|
650
|
+
anchorRect.left, anchorRect.top, anchorRect.width, anchorRect.height,
|
|
651
|
+
boundary.left, boundary.top, boundary.width, boundary.height,
|
|
652
|
+
panelWidth, panelHeight,
|
|
653
|
+
isRtl,
|
|
654
|
+
].join(':');
|
|
655
|
+
|
|
656
|
+
if (this._collisionSignature === signature) {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
this._collisionSignature = signature;
|
|
661
|
+
|
|
662
|
+
const offset = parseFloat(hostStyles.getPropertyValue('--tooltip-offset')) || 0;
|
|
663
|
+
const arrowSize = parseFloat(hostStyles.getPropertyValue('--tooltip-arrow-size')) || 0;
|
|
664
|
+
const layerOffset = isIconType ? offset : offset + arrowSize;
|
|
665
|
+
|
|
666
|
+
const { side: preferredSide, alignment: preferredAlignment } = parsePosition(this.position ?? defaultProps.position);
|
|
667
|
+
|
|
668
|
+
let fitting: { side: TooltipSide; alignment: TooltipAlignment } | undefined;
|
|
669
|
+
let bestFallback: { side: TooltipSide; alignment: TooltipAlignment } | undefined;
|
|
670
|
+
let bestArea = -1;
|
|
671
|
+
|
|
672
|
+
this._getCandidatePositions(preferredSide, preferredAlignment).some(({ side, alignment }) => {
|
|
673
|
+
const rect = getCandidateRect(
|
|
674
|
+
side,
|
|
675
|
+
alignment,
|
|
676
|
+
anchorRect,
|
|
677
|
+
panelWidth,
|
|
678
|
+
panelHeight,
|
|
679
|
+
layerOffset,
|
|
680
|
+
isRtl,
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
if (containsRect(boundary, rect)) {
|
|
684
|
+
fitting = { side, alignment };
|
|
685
|
+
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const area = getVisibleArea(boundary, rect);
|
|
690
|
+
|
|
691
|
+
if (area > bestArea) {
|
|
692
|
+
bestArea = area;
|
|
693
|
+
bestFallback = { side, alignment };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return false;
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const resolved = fitting ?? bestFallback;
|
|
700
|
+
const resolvedPosition = resolved
|
|
701
|
+
? `${resolved.side}${resolved.alignment}` as TooltipProps['position']
|
|
702
|
+
: this.position ?? defaultProps.position;
|
|
703
|
+
|
|
704
|
+
this._setResolvedPosition(resolvedPosition);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private _setResolvedPosition (position: TooltipProps['position']): void {
|
|
708
|
+
if (this._resolvedPosition !== position) {
|
|
709
|
+
this._resolvedPosition = position;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private _getCollisionBoundary (): DOMRect | null {
|
|
714
|
+
const { documentElement } = this.ownerDocument;
|
|
715
|
+
const viewport = new DOMRect(0, 0, documentElement.clientWidth, documentElement.clientHeight);
|
|
716
|
+
|
|
717
|
+
return this._overlayClippers.reduce<DOMRect | null>(
|
|
718
|
+
(rect, clipper) => (rect ? intersectRects(rect, getClipRect(clipper)) : null),
|
|
719
|
+
viewport,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
private _getCandidatePositions (
|
|
724
|
+
preferredSide: TooltipSide,
|
|
725
|
+
preferredAlignment: TooltipAlignment,
|
|
726
|
+
): Array<{ side: TooltipSide; alignment: TooltipAlignment }> {
|
|
727
|
+
const sideOrder: Array<TooltipSide> = [preferredSide, oppositeSide[preferredSide], ...crossSides[preferredSide]];
|
|
728
|
+
const allAlignments: Array<TooltipAlignment> = ['', '-start', '-end'];
|
|
729
|
+
|
|
730
|
+
return sideOrder.flatMap((side) => {
|
|
731
|
+
const alignments = [preferredAlignment, ...allAlignments.filter((alignment) => alignment !== preferredAlignment)];
|
|
732
|
+
|
|
733
|
+
return alignments.map((alignment) => ({ side, alignment }));
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Cached because `projectOverTrigger` runs on every re-anchoring frame, and collecting these
|
|
738
|
+
// costs a computed style per ancestor. Refreshed when the trigger changes and whenever the
|
|
739
|
+
// overlay mode is resolved, which is the same cadence as an ancestor chain actually changing.
|
|
740
|
+
private _refreshTriggerClippers (): void {
|
|
741
|
+
const triggerElement = this._getTriggerElement();
|
|
742
|
+
|
|
743
|
+
this._clippedTriggerElement = triggerElement;
|
|
744
|
+
this._triggerClippers = triggerElement ? collectClippingAncestors(triggerElement) : [];
|
|
745
|
+
}
|
|
746
|
+
|
|
316
747
|
private handleCloseButtonClick (): void {
|
|
748
|
+
this._requestClose();
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private _getTriggerElement (): Element | null {
|
|
752
|
+
return this.trigger ? this.ownerDocument.getElementById(this.trigger) : null;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
private _requestOpen (): void {
|
|
756
|
+
if (this.isOpen) return;
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* @ignore
|
|
760
|
+
*/
|
|
761
|
+
this.dispatchEvent(new Event(ON_TOOLTIP_OPEN_EVENT, { bubbles: true, composed: true }));
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private _requestClose (): void {
|
|
765
|
+
if (!this.isOpen) return;
|
|
766
|
+
|
|
317
767
|
/**
|
|
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
768
|
* @ignore
|
|
324
769
|
*/
|
|
325
770
|
this.dispatchEvent(new Event(ON_TOOLTIP_CLOSE_EVENT, { bubbles: true, composed: true }));
|
|
326
771
|
}
|
|
327
772
|
|
|
773
|
+
private _startHoverCloseTimer (): void {
|
|
774
|
+
if (this._hoverCloseTimer !== undefined) return;
|
|
775
|
+
this._hoverCloseTimer = setTimeout(() => {
|
|
776
|
+
this._hoverCloseTimer = undefined;
|
|
777
|
+
this._requestClose();
|
|
778
|
+
}, 100);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
private _cancelHoverCloseTimer (): void {
|
|
782
|
+
if (this._hoverCloseTimer !== undefined) {
|
|
783
|
+
clearTimeout(this._hoverCloseTimer);
|
|
784
|
+
this._hoverCloseTimer = undefined;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private _rebuildInteractionListeners (): void {
|
|
789
|
+
this._teardownInteractionListeners();
|
|
790
|
+
|
|
791
|
+
if (!this.triggers?.length) return;
|
|
792
|
+
|
|
793
|
+
const controller = new AbortController();
|
|
794
|
+
const { signal } = controller;
|
|
795
|
+
this._interactionController = controller;
|
|
796
|
+
|
|
797
|
+
const triggerEl = this._getTriggerElement();
|
|
798
|
+
|
|
799
|
+
this.ownerDocument.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
800
|
+
if (e.key === 'Escape' && this.isOpen) {
|
|
801
|
+
this._requestClose();
|
|
802
|
+
}
|
|
803
|
+
}, { signal });
|
|
804
|
+
|
|
805
|
+
if (!triggerEl) return;
|
|
806
|
+
|
|
807
|
+
if (this.triggers.includes('hover')) {
|
|
808
|
+
triggerEl.addEventListener('mouseenter', () => {
|
|
809
|
+
this._cancelHoverCloseTimer();
|
|
810
|
+
this._requestOpen();
|
|
811
|
+
}, { signal });
|
|
812
|
+
|
|
813
|
+
triggerEl.addEventListener('mouseleave', () => {
|
|
814
|
+
this._startHoverCloseTimer();
|
|
815
|
+
}, { signal });
|
|
816
|
+
|
|
817
|
+
// panel mouseenter/leave for the hover bridge (includes the bridge pseudo-element)
|
|
818
|
+
const panel = this.renderRoot.querySelector('.c-tooltip');
|
|
819
|
+
if (panel) {
|
|
820
|
+
panel.addEventListener('mouseenter', () => {
|
|
821
|
+
this._cancelHoverCloseTimer();
|
|
822
|
+
}, { signal });
|
|
823
|
+
|
|
824
|
+
panel.addEventListener('mouseleave', () => {
|
|
825
|
+
this._startHoverCloseTimer();
|
|
826
|
+
}, { signal });
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (this.triggers.includes('focus')) {
|
|
831
|
+
// open on focus, close on blur unless focus moved into panel action content
|
|
832
|
+
triggerEl.addEventListener('focusin', () => {
|
|
833
|
+
this._requestOpen();
|
|
834
|
+
}, { signal });
|
|
835
|
+
|
|
836
|
+
triggerEl.addEventListener('focusout', (e: Event) => {
|
|
837
|
+
const related = (e as FocusEvent).relatedTarget as Node | null;
|
|
838
|
+
// relatedTarget is retargeted to the shadow host when focus moves into shadow DOM
|
|
839
|
+
const staysInside = related && (this.contains(related) || related === this);
|
|
840
|
+
if (!staysInside) {
|
|
841
|
+
this._requestClose();
|
|
842
|
+
}
|
|
843
|
+
}, { signal });
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (this.triggers.includes('click')) {
|
|
847
|
+
triggerEl.addEventListener('click', (e: Event) => {
|
|
848
|
+
e.stopPropagation();
|
|
849
|
+
|
|
850
|
+
if (!this.isOpen) {
|
|
851
|
+
// Only click-opens toggle
|
|
852
|
+
this._openedByClick = true;
|
|
853
|
+
this._requestOpen();
|
|
854
|
+
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (!this._openedByClick) {
|
|
859
|
+
this._openedByClick = true;
|
|
860
|
+
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
this._requestClose();
|
|
865
|
+
}, { signal });
|
|
866
|
+
|
|
867
|
+
// Light-dismiss: click anywhere outside the panel and trigger
|
|
868
|
+
this.ownerDocument.addEventListener('click', (e: Event) => {
|
|
869
|
+
const target = e.composedPath()[0] as Node;
|
|
870
|
+
const isInsidePanel = this.contains(target) || this.shadowRoot?.contains(target);
|
|
871
|
+
const isInsideTrigger = triggerEl.contains(target) || target === triggerEl;
|
|
872
|
+
if (!isInsidePanel && !isInsideTrigger && this.isOpen) {
|
|
873
|
+
this._requestClose();
|
|
874
|
+
}
|
|
875
|
+
}, { signal });
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
private _teardownInteractionListeners (): void {
|
|
880
|
+
this._interactionController?.abort();
|
|
881
|
+
this._interactionController = undefined;
|
|
882
|
+
this._cancelHoverCloseTimer();
|
|
883
|
+
}
|
|
884
|
+
|
|
328
885
|
private renderHeading (): TemplateResult {
|
|
329
886
|
const tag = unsafeStatic(this.headingLevel);
|
|
330
887
|
|
|
@@ -353,13 +910,15 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
353
910
|
heading,
|
|
354
911
|
isDismissible,
|
|
355
912
|
isOpen,
|
|
356
|
-
position,
|
|
913
|
+
position: preferredPosition,
|
|
357
914
|
size,
|
|
358
915
|
type,
|
|
359
916
|
variant,
|
|
917
|
+
_resolvedPosition,
|
|
360
918
|
_mode: mode,
|
|
361
919
|
} = this;
|
|
362
920
|
|
|
921
|
+
const position = _resolvedPosition ?? preferredPosition;
|
|
363
922
|
const isIconType = type === 'icon';
|
|
364
923
|
|
|
365
924
|
const layerClasses = {
|
|
@@ -368,6 +927,7 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
368
927
|
[`${componentClass}-layer--type-${type}`]: true,
|
|
369
928
|
'is-open': !!isOpen,
|
|
370
929
|
'is-positioned': this._isPositioned,
|
|
930
|
+
'is-anchorVisible': this._isAnchorVisible,
|
|
371
931
|
};
|
|
372
932
|
|
|
373
933
|
const panelClasses = {
|