@justeattakeaway/pie-tooltip 0.3.0 → 0.5.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 +99 -33
- package/custom-elements.json +136 -6
- package/dist/index.d.ts +27 -56
- package/dist/index.js +352 -240
- package/dist/react.d.ts +27 -56
- package/dist/react.js +1 -1
- package/package.json +4 -4
- package/src/defs.ts +14 -54
- package/src/index.ts +338 -95
- package/src/react.ts +1 -1
- package/src/tooltip.scss +8 -1
package/src/index.ts
CHANGED
|
@@ -37,10 +37,10 @@ import '@justeattakeaway/pie-icons-webc/dist/IconClose.js';
|
|
|
37
37
|
|
|
38
38
|
export * from './defs';
|
|
39
39
|
|
|
40
|
-
const
|
|
40
|
+
const FOCUS_PANEL_MAX_ATTEMPTS = 5;
|
|
41
|
+
|
|
42
|
+
const nextFrame = (): Promise<number> => new Promise(requestAnimationFrame);
|
|
41
43
|
|
|
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
44
|
const createsContainingBlock = (styles: CSSStyleDeclaration): boolean => {
|
|
45
45
|
const isSet = (value: string | undefined) => !!value && value !== 'none';
|
|
46
46
|
|
|
@@ -58,7 +58,6 @@ const createsContainingBlock = (styles: CSSStyleDeclaration): boolean => {
|
|
|
58
58
|
return true;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
// `size` and `style` containment do not establish a containing block; everything else does.
|
|
62
61
|
if (/\b(paint|layout|content|strict)\b/.test(styles.contain)) {
|
|
63
62
|
return true;
|
|
64
63
|
}
|
|
@@ -67,7 +66,6 @@ const createsContainingBlock = (styles: CSSStyleDeclaration): boolean => {
|
|
|
67
66
|
return true;
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
// will-change pre-establishes the containing block before the property is applied.
|
|
71
69
|
return /\b(transform|perspective|filter|backdrop-filter|contain|translate|rotate|scale)\b/.test(styles.willChange);
|
|
72
70
|
};
|
|
73
71
|
|
|
@@ -91,8 +89,6 @@ const flattenedAncestors = (element: Element): Array<Element> => {
|
|
|
91
89
|
return ancestors;
|
|
92
90
|
};
|
|
93
91
|
|
|
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
92
|
const collectClippingAncestors = (element: Element): Array<Element> => {
|
|
97
93
|
const { documentElement, body } = element.ownerDocument;
|
|
98
94
|
|
|
@@ -107,9 +103,6 @@ const collectClippingAncestors = (element: Element): Array<Element> => {
|
|
|
107
103
|
});
|
|
108
104
|
};
|
|
109
105
|
|
|
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
106
|
const getClipRect = (element: Element): DOMRect => {
|
|
114
107
|
const { left, top } = element.getBoundingClientRect();
|
|
115
108
|
const {
|
|
@@ -119,7 +112,6 @@ const getClipRect = (element: Element): DOMRect => {
|
|
|
119
112
|
return new DOMRect(left + clientLeft, top + clientTop, clientWidth, clientHeight);
|
|
120
113
|
};
|
|
121
114
|
|
|
122
|
-
// Intersects two rects. Returns `null` when they do not overlap.
|
|
123
115
|
const intersectRects = (a: DOMRect, b: DOMRect): DOMRect | null => {
|
|
124
116
|
const left = Math.max(a.left, b.left);
|
|
125
117
|
const top = Math.max(a.top, b.top);
|
|
@@ -133,10 +125,119 @@ const intersectRects = (a: DOMRect, b: DOMRect): DOMRect | null => {
|
|
|
133
125
|
return new DOMRect(left, top, right - left, bottom - top);
|
|
134
126
|
};
|
|
135
127
|
|
|
128
|
+
type TooltipSide = 'top' | 'bottom' | 'left' | 'right';
|
|
129
|
+
type TooltipAlignment = '' | '-start' | '-end';
|
|
130
|
+
|
|
131
|
+
interface CandidateRect {
|
|
132
|
+
left: number;
|
|
133
|
+
top: number;
|
|
134
|
+
right: number;
|
|
135
|
+
bottom: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const oppositeSide: Record<TooltipSide, TooltipSide> = {
|
|
139
|
+
top: 'bottom',
|
|
140
|
+
bottom: 'top',
|
|
141
|
+
left: 'right',
|
|
142
|
+
right: 'left',
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const crossSides: Record<TooltipSide, Array<TooltipSide>> = {
|
|
146
|
+
top: ['left', 'right'],
|
|
147
|
+
bottom: ['left', 'right'],
|
|
148
|
+
left: ['top', 'bottom'],
|
|
149
|
+
right: ['top', 'bottom'],
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const parsePosition = (position: TooltipProps['position']): { side: TooltipSide; alignment: TooltipAlignment } => {
|
|
153
|
+
const match = /^(top|bottom|left|right)(-start|-end)?$/.exec(position ?? '');
|
|
154
|
+
|
|
155
|
+
if (!match) {
|
|
156
|
+
return { side: 'top', alignment: '' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { side: match[1] as TooltipSide, alignment: (match[2] ?? '') as TooltipAlignment };
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const toPhysicalSide = (side: TooltipSide, isRtl: boolean): TooltipSide => {
|
|
163
|
+
if (!isRtl) {
|
|
164
|
+
return side;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (side === 'left') {
|
|
168
|
+
return 'right';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (side === 'right') {
|
|
172
|
+
return 'left';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return side;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const getCandidateRect = (
|
|
179
|
+
side: TooltipSide,
|
|
180
|
+
alignment: TooltipAlignment,
|
|
181
|
+
anchor: DOMRect,
|
|
182
|
+
panelWidth: number,
|
|
183
|
+
panelHeight: number,
|
|
184
|
+
offset: number,
|
|
185
|
+
isRtl: boolean,
|
|
186
|
+
): CandidateRect => {
|
|
187
|
+
const physicalSide = toPhysicalSide(side, isRtl);
|
|
188
|
+
const isVerticalSide = physicalSide === 'top' || physicalSide === 'bottom';
|
|
189
|
+
|
|
190
|
+
let left = 0;
|
|
191
|
+
let top = 0;
|
|
192
|
+
|
|
193
|
+
if (physicalSide === 'top') {
|
|
194
|
+
top = anchor.top - offset - panelHeight;
|
|
195
|
+
} else if (physicalSide === 'bottom') {
|
|
196
|
+
top = anchor.bottom + offset;
|
|
197
|
+
} else if (physicalSide === 'left') {
|
|
198
|
+
left = anchor.left - offset - panelWidth;
|
|
199
|
+
} else {
|
|
200
|
+
left = anchor.right + offset;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (isVerticalSide) {
|
|
204
|
+
if (alignment === '') {
|
|
205
|
+
left = anchor.left + (anchor.width / 2) - (panelWidth / 2);
|
|
206
|
+
} else if (alignment === '-start') {
|
|
207
|
+
left = isRtl ? anchor.right - panelWidth : anchor.left;
|
|
208
|
+
} else {
|
|
209
|
+
left = isRtl ? anchor.left : anchor.right - panelWidth;
|
|
210
|
+
}
|
|
211
|
+
} else if (alignment === '') {
|
|
212
|
+
top = anchor.top + (anchor.height / 2) - (panelHeight / 2);
|
|
213
|
+
} else {
|
|
214
|
+
top = alignment === '-start' ? anchor.top : anchor.bottom - panelHeight;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
left,
|
|
219
|
+
top,
|
|
220
|
+
right: left + panelWidth,
|
|
221
|
+
bottom: top + panelHeight,
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const containsRect = (boundary: DOMRect, rect: CandidateRect): boolean => rect.left >= boundary.left &&
|
|
226
|
+
rect.right <= boundary.right &&
|
|
227
|
+
rect.top >= boundary.top &&
|
|
228
|
+
rect.bottom <= boundary.bottom;
|
|
229
|
+
|
|
230
|
+
const getVisibleArea = (boundary: DOMRect, rect: CandidateRect): number => {
|
|
231
|
+
const width = Math.min(boundary.right, rect.right) - Math.max(boundary.left, rect.left);
|
|
232
|
+
const height = Math.min(boundary.bottom, rect.bottom) - Math.max(boundary.top, rect.top);
|
|
233
|
+
|
|
234
|
+
return Math.max(0, width) * Math.max(0, height);
|
|
235
|
+
};
|
|
236
|
+
|
|
136
237
|
/**
|
|
137
238
|
* @tagname pie-tooltip
|
|
138
239
|
* @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.
|
|
240
|
+
* @event {Event} pie-tooltip-close - When the close button is clicked, Escape is pressed, or a configured trigger asks to dismiss the panel. Set `isOpen` to `false` in response.
|
|
140
241
|
* @slot content - The descriptive content of the panel. Must not contain focusable elements.
|
|
141
242
|
* @slot action - An optional slot for interactive content such as a `pie-button`. Filling this slot switches the panel to a non-modal dialog.
|
|
142
243
|
*/
|
|
@@ -190,10 +291,18 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
190
291
|
|
|
191
292
|
@state() private _isAnchorVisible = true;
|
|
192
293
|
|
|
294
|
+
@state() private _resolvedPosition: TooltipProps['position'] | undefined;
|
|
295
|
+
|
|
193
296
|
private _clippedTriggerElement: Element | null = null;
|
|
194
297
|
|
|
195
298
|
private _triggerClippers: Array<Element> = [];
|
|
196
299
|
|
|
300
|
+
private _overlayClippers: Array<Element> = [];
|
|
301
|
+
|
|
302
|
+
private _collisionSignature: string | null = null;
|
|
303
|
+
|
|
304
|
+
private _overlayModeDirty = true;
|
|
305
|
+
|
|
197
306
|
private _triggerTrackingController: AbortController | undefined;
|
|
198
307
|
private _interactionController: AbortController | undefined;
|
|
199
308
|
|
|
@@ -203,10 +312,16 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
203
312
|
|
|
204
313
|
private _reanchorFrame = 0;
|
|
205
314
|
|
|
206
|
-
private _shouldResolveOverlayMode = false;
|
|
207
|
-
|
|
208
315
|
private _hoverCloseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
209
316
|
|
|
317
|
+
private _openedByClick = false;
|
|
318
|
+
|
|
319
|
+
private readonly _instanceId = crypto.randomUUID();
|
|
320
|
+
|
|
321
|
+
private get _headingId (): string {
|
|
322
|
+
return `pie-tooltip-heading-${this._instanceId}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
210
325
|
static styles = unsafeCSS(styles);
|
|
211
326
|
|
|
212
327
|
private get _mode (): TooltipMode | undefined {
|
|
@@ -214,11 +329,13 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
214
329
|
return undefined;
|
|
215
330
|
}
|
|
216
331
|
|
|
217
|
-
return this._hasActionContent ? 'dialog' : 'tooltip';
|
|
332
|
+
return (this._hasActionContent || this.isDismissible) ? 'dialog' : 'tooltip';
|
|
218
333
|
}
|
|
219
334
|
|
|
220
335
|
protected firstUpdated (): void {
|
|
221
336
|
this.resolveMode();
|
|
337
|
+
this._overlayModeDirty = true;
|
|
338
|
+
this.resolveOverlayMode();
|
|
222
339
|
this.projectOverTrigger();
|
|
223
340
|
this._rebuildInteractionListeners();
|
|
224
341
|
}
|
|
@@ -227,16 +344,29 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
227
344
|
const anchoringProperties: Array<keyof PieTooltip> = ['trigger', 'isOpen', 'position', 'size'];
|
|
228
345
|
|
|
229
346
|
if (this.isOpen && changedProperties.has('isOpen')) {
|
|
230
|
-
this.
|
|
347
|
+
this._overlayModeDirty = true;
|
|
231
348
|
}
|
|
232
349
|
|
|
233
|
-
if (
|
|
350
|
+
if (!this.isOpen) {
|
|
351
|
+
this._openedByClick = false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (changedProperties.has('trigger')) {
|
|
355
|
+
this._overlayModeDirty = true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const isAnchoringChange = anchoringProperties.some((prop) => changedProperties.has(prop));
|
|
359
|
+
|
|
360
|
+
if (isAnchoringChange) {
|
|
361
|
+
this._collisionSignature = null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
this.resolveOverlayMode();
|
|
365
|
+
|
|
366
|
+
if (isAnchoringChange) {
|
|
234
367
|
this.projectOverTrigger();
|
|
235
368
|
}
|
|
236
369
|
|
|
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
370
|
if (this.isOpen && changedProperties.has('trigger')) {
|
|
241
371
|
this.stopTrackingTrigger();
|
|
242
372
|
}
|
|
@@ -258,8 +388,36 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
258
388
|
super.disconnectedCallback();
|
|
259
389
|
}
|
|
260
390
|
|
|
261
|
-
|
|
262
|
-
|
|
391
|
+
public async focusPanel (): Promise<boolean> {
|
|
392
|
+
if (this._mode !== 'dialog') {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
await this.updateComplete;
|
|
397
|
+
await nextFrame();
|
|
398
|
+
|
|
399
|
+
const content = this.renderRoot.querySelector<HTMLElement>(`.${componentClass}-content`);
|
|
400
|
+
|
|
401
|
+
if (!content) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const focusLanded = () => this.shadowRoot?.activeElement === content;
|
|
406
|
+
|
|
407
|
+
for (let attempt = 0; attempt < FOCUS_PANEL_MAX_ATTEMPTS; attempt++) {
|
|
408
|
+
content.focus({ preventScroll: true });
|
|
409
|
+
|
|
410
|
+
if (focusLanded()) {
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// eslint-disable-next-line no-await-in-loop
|
|
415
|
+
await nextFrame();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
|
|
263
421
|
private startTrackingTrigger (): void {
|
|
264
422
|
if (this._triggerTrackingController) {
|
|
265
423
|
return;
|
|
@@ -276,28 +434,19 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
276
434
|
this._reanchorFrame = requestAnimationFrame(() => {
|
|
277
435
|
this._reanchorFrame = 0;
|
|
278
436
|
|
|
279
|
-
|
|
280
|
-
this._shouldResolveOverlayMode = false;
|
|
281
|
-
this.resolveOverlayMode();
|
|
282
|
-
}
|
|
283
|
-
|
|
437
|
+
this.resolveOverlayMode();
|
|
284
438
|
this.projectOverTrigger();
|
|
285
439
|
});
|
|
286
440
|
};
|
|
287
441
|
|
|
288
|
-
// Flagged rather than resolved immediately so window-drag cannot walk ancestors more than once per frame.
|
|
289
442
|
const handleResize = () => {
|
|
290
|
-
this.
|
|
443
|
+
this._overlayModeDirty = true;
|
|
291
444
|
handleViewportChange();
|
|
292
445
|
};
|
|
293
446
|
|
|
294
447
|
window.addEventListener('scroll', handleViewportChange, { capture: true, passive: true, signal });
|
|
295
448
|
window.addEventListener('resize', handleResize, { passive: true, signal });
|
|
296
449
|
|
|
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
450
|
const shadowRoots = new Set<ShadowRoot>();
|
|
302
451
|
|
|
303
452
|
flattenedAncestors(this).forEach((ancestor) => {
|
|
@@ -312,13 +461,8 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
312
461
|
root.addEventListener('scroll', handleViewportChange, { capture: true, passive: true, signal });
|
|
313
462
|
});
|
|
314
463
|
|
|
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
464
|
this._triggerObserver = new ResizeObserver(() => {
|
|
319
|
-
|
|
320
|
-
// here so the walk runs at most once per frame.
|
|
321
|
-
this._shouldResolveOverlayMode = true;
|
|
465
|
+
this._overlayModeDirty = true;
|
|
322
466
|
handleViewportChange();
|
|
323
467
|
});
|
|
324
468
|
|
|
@@ -347,8 +491,6 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
347
491
|
this._triggerObserver?.disconnect();
|
|
348
492
|
this._triggerObserver = undefined;
|
|
349
493
|
|
|
350
|
-
this._shouldResolveOverlayMode = false;
|
|
351
|
-
|
|
352
494
|
if (this._reanchorFrame) {
|
|
353
495
|
cancelAnimationFrame(this._reanchorFrame);
|
|
354
496
|
this._reanchorFrame = 0;
|
|
@@ -359,31 +501,25 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
359
501
|
this._hasActionContent = this._assignedActionElements.length > 0;
|
|
360
502
|
}
|
|
361
503
|
|
|
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
504
|
private resolveOverlayMode (): void {
|
|
505
|
+
if (!this._overlayModeDirty) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
this._overlayModeDirty = false;
|
|
510
|
+
|
|
370
511
|
let isAtOrAboveAbsoluteContainingBlock = false;
|
|
371
512
|
let isAtOrAboveFixedContainingBlock = false;
|
|
372
|
-
|
|
373
|
-
|
|
513
|
+
const absoluteClippingAncestors: Array<Element> = [];
|
|
514
|
+
const fixedClippingAncestors: Array<Element> = [];
|
|
374
515
|
|
|
375
516
|
const { documentElement, body } = this.ownerDocument;
|
|
376
517
|
|
|
377
|
-
// Resolved on the same triggers as the overlay mode, because both answers change only
|
|
378
|
-
// when the ancestor chain does.
|
|
379
518
|
this._refreshTriggerClippers();
|
|
380
519
|
|
|
381
520
|
flattenedAncestors(this).forEach((element) => {
|
|
382
521
|
const styles = getComputedStyle(element);
|
|
383
522
|
|
|
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
523
|
if (styles.display === 'contents') {
|
|
388
524
|
return;
|
|
389
525
|
}
|
|
@@ -400,34 +536,25 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
400
536
|
|
|
401
537
|
const clips = styles.overflowX !== 'visible' || styles.overflowY !== 'visible';
|
|
402
538
|
|
|
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
539
|
const propagatesOverflowToViewport = element === documentElement || element === body;
|
|
408
540
|
|
|
409
|
-
// Counted after the containing-block flags so an ancestor that is both the containing
|
|
410
|
-
// block and the clipper is counted correctly.
|
|
411
541
|
if (clips && !propagatesOverflowToViewport) {
|
|
412
542
|
if (isAtOrAboveAbsoluteContainingBlock) {
|
|
413
|
-
|
|
543
|
+
absoluteClippingAncestors.push(element);
|
|
414
544
|
}
|
|
415
545
|
|
|
416
546
|
if (isAtOrAboveFixedContainingBlock) {
|
|
417
|
-
|
|
547
|
+
fixedClippingAncestors.push(element);
|
|
418
548
|
}
|
|
419
549
|
}
|
|
420
550
|
});
|
|
421
551
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
this.
|
|
552
|
+
const useFixed = fixedClippingAncestors.length < absoluteClippingAncestors.length;
|
|
553
|
+
|
|
554
|
+
this.style.position = useFixed ? 'fixed' : '';
|
|
555
|
+
this._overlayClippers = useFixed ? fixedClippingAncestors : absoluteClippingAncestors;
|
|
426
556
|
}
|
|
427
557
|
|
|
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
558
|
private projectOverTrigger (): void {
|
|
432
559
|
this._isPositioned = false;
|
|
433
560
|
|
|
@@ -448,16 +575,11 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
448
575
|
this._refreshTriggerClippers();
|
|
449
576
|
}
|
|
450
577
|
|
|
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
578
|
const visibleRect = this._triggerClippers.reduce<DOMRect | null>(
|
|
456
579
|
(rect, clipper) => (rect ? intersectRects(rect, getClipRect(clipper)) : null),
|
|
457
580
|
triggerElement.getBoundingClientRect(),
|
|
458
581
|
);
|
|
459
582
|
|
|
460
|
-
// Nothing of the trigger is left to point at, so there is nothing to describe either.
|
|
461
583
|
this._isAnchorVisible = visibleRect !== null;
|
|
462
584
|
|
|
463
585
|
if (!visibleRect) {
|
|
@@ -480,12 +602,122 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
480
602
|
const containerInlineSize = container ? container.getBoundingClientRect().width : width;
|
|
481
603
|
|
|
482
604
|
this.style.setProperty('--tooltip-container-inline-size', `${containerInlineSize}px`);
|
|
605
|
+
|
|
606
|
+
this.resolveCollision(visibleRect);
|
|
607
|
+
|
|
483
608
|
this._isPositioned = true;
|
|
484
609
|
}
|
|
485
610
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
611
|
+
private resolveCollision (anchorRect: DOMRect): void {
|
|
612
|
+
const panel = this.renderRoot.querySelector<HTMLElement>(`.${componentClass}`);
|
|
613
|
+
|
|
614
|
+
if (!panel) {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const boundary = this._getCollisionBoundary();
|
|
619
|
+
|
|
620
|
+
if (!boundary) {
|
|
621
|
+
this._collisionSignature = null;
|
|
622
|
+
this._setResolvedPosition(this.position ?? defaultProps.position);
|
|
623
|
+
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const isIconType = this.type === 'icon';
|
|
628
|
+
const hostStyles = getComputedStyle(this);
|
|
629
|
+
const isRtl = hostStyles.direction === 'rtl';
|
|
630
|
+
|
|
631
|
+
const panelWidth = panel.offsetWidth;
|
|
632
|
+
const panelHeight = panel.offsetHeight;
|
|
633
|
+
const signature = [
|
|
634
|
+
anchorRect.left, anchorRect.top, anchorRect.width, anchorRect.height,
|
|
635
|
+
boundary.left, boundary.top, boundary.width, boundary.height,
|
|
636
|
+
panelWidth, panelHeight,
|
|
637
|
+
isRtl,
|
|
638
|
+
].join(':');
|
|
639
|
+
|
|
640
|
+
if (this._collisionSignature === signature) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
this._collisionSignature = signature;
|
|
645
|
+
|
|
646
|
+
const offset = parseFloat(hostStyles.getPropertyValue('--tooltip-offset')) || 0;
|
|
647
|
+
const arrowSize = parseFloat(hostStyles.getPropertyValue('--tooltip-arrow-size')) || 0;
|
|
648
|
+
const layerOffset = isIconType ? offset : offset + arrowSize;
|
|
649
|
+
|
|
650
|
+
const { side: preferredSide, alignment: preferredAlignment } = parsePosition(this.position ?? defaultProps.position);
|
|
651
|
+
|
|
652
|
+
let fitting: { side: TooltipSide; alignment: TooltipAlignment } | undefined;
|
|
653
|
+
let bestFallback: { side: TooltipSide; alignment: TooltipAlignment } | undefined;
|
|
654
|
+
let bestArea = -1;
|
|
655
|
+
|
|
656
|
+
this._getCandidatePositions(preferredSide, preferredAlignment).some(({ side, alignment }) => {
|
|
657
|
+
const rect = getCandidateRect(
|
|
658
|
+
side,
|
|
659
|
+
alignment,
|
|
660
|
+
anchorRect,
|
|
661
|
+
panelWidth,
|
|
662
|
+
panelHeight,
|
|
663
|
+
layerOffset,
|
|
664
|
+
isRtl,
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
if (containsRect(boundary, rect)) {
|
|
668
|
+
fitting = { side, alignment };
|
|
669
|
+
|
|
670
|
+
return true;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const area = getVisibleArea(boundary, rect);
|
|
674
|
+
|
|
675
|
+
if (area > bestArea) {
|
|
676
|
+
bestArea = area;
|
|
677
|
+
bestFallback = { side, alignment };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return false;
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
const resolved = fitting ?? bestFallback;
|
|
684
|
+
const resolvedPosition = resolved
|
|
685
|
+
? `${resolved.side}${resolved.alignment}` as TooltipProps['position']
|
|
686
|
+
: this.position ?? defaultProps.position;
|
|
687
|
+
|
|
688
|
+
this._setResolvedPosition(resolvedPosition);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private _setResolvedPosition (position: TooltipProps['position']): void {
|
|
692
|
+
if (this._resolvedPosition !== position) {
|
|
693
|
+
this._resolvedPosition = position;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private _getCollisionBoundary (): DOMRect | null {
|
|
698
|
+
const { documentElement } = this.ownerDocument;
|
|
699
|
+
const viewport = new DOMRect(0, 0, documentElement.clientWidth, documentElement.clientHeight);
|
|
700
|
+
|
|
701
|
+
return this._overlayClippers.reduce<DOMRect | null>(
|
|
702
|
+
(rect, clipper) => (rect ? intersectRects(rect, getClipRect(clipper)) : null),
|
|
703
|
+
viewport,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private _getCandidatePositions (
|
|
708
|
+
preferredSide: TooltipSide,
|
|
709
|
+
preferredAlignment: TooltipAlignment,
|
|
710
|
+
): Array<{ side: TooltipSide; alignment: TooltipAlignment }> {
|
|
711
|
+
const sideOrder: Array<TooltipSide> = [preferredSide, oppositeSide[preferredSide], ...crossSides[preferredSide]];
|
|
712
|
+
const allAlignments: Array<TooltipAlignment> = ['', '-start', '-end'];
|
|
713
|
+
|
|
714
|
+
return sideOrder.flatMap((side) => {
|
|
715
|
+
const alignments = [preferredAlignment, ...allAlignments.filter((alignment) => alignment !== preferredAlignment)];
|
|
716
|
+
|
|
717
|
+
return alignments.map((alignment) => ({ side, alignment }));
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
489
721
|
private _refreshTriggerClippers (): void {
|
|
490
722
|
const triggerElement = this._getTriggerElement();
|
|
491
723
|
|
|
@@ -537,20 +769,20 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
537
769
|
private _rebuildInteractionListeners (): void {
|
|
538
770
|
this._teardownInteractionListeners();
|
|
539
771
|
|
|
540
|
-
if (!this.triggers?.length) return;
|
|
541
|
-
|
|
542
772
|
const controller = new AbortController();
|
|
543
773
|
const { signal } = controller;
|
|
544
774
|
this._interactionController = controller;
|
|
545
775
|
|
|
546
|
-
const triggerEl = this._getTriggerElement();
|
|
547
|
-
|
|
548
776
|
this.ownerDocument.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
549
777
|
if (e.key === 'Escape' && this.isOpen) {
|
|
550
778
|
this._requestClose();
|
|
551
779
|
}
|
|
552
780
|
}, { signal });
|
|
553
781
|
|
|
782
|
+
if (!this.triggers?.length) return;
|
|
783
|
+
|
|
784
|
+
const triggerEl = this._getTriggerElement();
|
|
785
|
+
|
|
554
786
|
if (!triggerEl) return;
|
|
555
787
|
|
|
556
788
|
if (this.triggers.includes('hover')) {
|
|
@@ -563,7 +795,6 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
563
795
|
this._startHoverCloseTimer();
|
|
564
796
|
}, { signal });
|
|
565
797
|
|
|
566
|
-
// panel mouseenter/leave for the hover bridge (includes the bridge pseudo-element)
|
|
567
798
|
const panel = this.renderRoot.querySelector('.c-tooltip');
|
|
568
799
|
if (panel) {
|
|
569
800
|
panel.addEventListener('mouseenter', () => {
|
|
@@ -577,14 +808,12 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
577
808
|
}
|
|
578
809
|
|
|
579
810
|
if (this.triggers.includes('focus')) {
|
|
580
|
-
// open on focus, close on blur unless focus moved into panel action content
|
|
581
811
|
triggerEl.addEventListener('focusin', () => {
|
|
582
812
|
this._requestOpen();
|
|
583
813
|
}, { signal });
|
|
584
814
|
|
|
585
815
|
triggerEl.addEventListener('focusout', (e: Event) => {
|
|
586
816
|
const related = (e as FocusEvent).relatedTarget as Node | null;
|
|
587
|
-
// relatedTarget is retargeted to the shadow host when focus moves into shadow DOM
|
|
588
817
|
const staysInside = related && (this.contains(related) || related === this);
|
|
589
818
|
if (!staysInside) {
|
|
590
819
|
this._requestClose();
|
|
@@ -595,14 +824,23 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
595
824
|
if (this.triggers.includes('click')) {
|
|
596
825
|
triggerEl.addEventListener('click', (e: Event) => {
|
|
597
826
|
e.stopPropagation();
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
827
|
+
|
|
828
|
+
if (!this.isOpen) {
|
|
829
|
+
this._openedByClick = true;
|
|
601
830
|
this._requestOpen();
|
|
831
|
+
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (!this._openedByClick) {
|
|
836
|
+
this._openedByClick = true;
|
|
837
|
+
|
|
838
|
+
return;
|
|
602
839
|
}
|
|
840
|
+
|
|
841
|
+
this._requestClose();
|
|
603
842
|
}, { signal });
|
|
604
843
|
|
|
605
|
-
// Light-dismiss: click anywhere outside the panel and trigger
|
|
606
844
|
this.ownerDocument.addEventListener('click', (e: Event) => {
|
|
607
845
|
const target = e.composedPath()[0] as Node;
|
|
608
846
|
const isInsidePanel = this.contains(target) || this.shadowRoot?.contains(target);
|
|
@@ -624,9 +862,9 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
624
862
|
const tag = unsafeStatic(this.headingLevel);
|
|
625
863
|
|
|
626
864
|
return html`<${tag}
|
|
627
|
-
id="${
|
|
865
|
+
id="${this._headingId}"
|
|
628
866
|
class="${componentClass}-heading"
|
|
629
|
-
data-test-id="${
|
|
867
|
+
data-test-id="${componentSelector}-heading">${this.heading}</${tag}>`;
|
|
630
868
|
}
|
|
631
869
|
|
|
632
870
|
private renderCloseButton (): TemplateResult {
|
|
@@ -648,13 +886,15 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
648
886
|
heading,
|
|
649
887
|
isDismissible,
|
|
650
888
|
isOpen,
|
|
651
|
-
position,
|
|
889
|
+
position: preferredPosition,
|
|
652
890
|
size,
|
|
653
891
|
type,
|
|
654
892
|
variant,
|
|
893
|
+
_resolvedPosition,
|
|
655
894
|
_mode: mode,
|
|
656
895
|
} = this;
|
|
657
896
|
|
|
897
|
+
const position = _resolvedPosition ?? preferredPosition;
|
|
658
898
|
const isIconType = type === 'icon';
|
|
659
899
|
|
|
660
900
|
const layerClasses = {
|
|
@@ -673,7 +913,7 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
673
913
|
[`${componentClass}--type-${type}`]: true,
|
|
674
914
|
[`${componentClass}--size-${size}`]: !isIconType,
|
|
675
915
|
'is-dismissible': !!isDismissible,
|
|
676
|
-
'has-action':
|
|
916
|
+
'has-action': this._hasActionContent === true,
|
|
677
917
|
'has-heading': !!heading,
|
|
678
918
|
};
|
|
679
919
|
|
|
@@ -688,12 +928,15 @@ export class PieTooltip extends PieElement implements TooltipProps {
|
|
|
688
928
|
data-test-id="${componentSelector}"
|
|
689
929
|
role="${ifDefined(mode)}"
|
|
690
930
|
aria-hidden="${!isOpen}"
|
|
691
|
-
aria-labelledby="${isDialog && heading ?
|
|
931
|
+
aria-labelledby="${isDialog && heading ? this._headingId : nothing}"
|
|
692
932
|
aria-label="${isDialog && !heading && aria?.label ? aria.label : nothing}">
|
|
693
933
|
${isIconType ? nothing : html`<div class="${componentClass}-arrow" data-test-id="${componentSelector}-arrow"></div>`}
|
|
694
934
|
<div class="${componentClass}-body">
|
|
695
935
|
${heading ? this.renderHeading() : nothing}
|
|
696
|
-
<div
|
|
936
|
+
<div
|
|
937
|
+
class="${componentClass}-content"
|
|
938
|
+
tabindex="${isDialog ? -1 : nothing}"
|
|
939
|
+
data-test-id="${componentSelector}-content">
|
|
697
940
|
<slot name="content"></slot>
|
|
698
941
|
</div>
|
|
699
942
|
</div>
|