@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/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-close - When the close button is clicked. Set `isOpen` to `false` in response.
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 _shouldResolveOverlayMode = false;
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.resolveOverlayMode();
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
- if (anchoringProperties.some((prop) => changedProperties.has(prop))) {
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
- if (this._shouldResolveOverlayMode) {
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._shouldResolveOverlayMode = true;
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._shouldResolveOverlayMode = false;
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
- let isAbsoluteClipped = false;
241
- let isFixedClipped = false;
507
+ const absoluteClippingAncestors: Array<Element> = [];
508
+ const fixedClippingAncestors: Array<Element> = [];
509
+
510
+ const { documentElement, body } = this.ownerDocument;
242
511
 
243
- let node: Node | null = this.parentNode;
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
- while (node) {
246
- // Step over shadow roots to the host; layout follows the light DOM, not slot assignment.
247
- const element = node instanceof ShadowRoot ? node.host : node;
516
+ flattenedAncestors(this).forEach((element) => {
517
+ const styles = getComputedStyle(element);
248
518
 
249
- if (!(element instanceof Element)) {
250
- break;
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
- // Check after setting the containing-block flags so an ancestor that is both the
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
- }
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
- if (element === this.ownerDocument.documentElement) {
272
- break;
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
- node = element.parentNode;
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 = isAbsoluteClipped && !isFixedClipped ? 'fixed' : '';
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.trigger ? this.ownerDocument.getElementById(this.trigger) : null;
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
- } = triggerElement.getBoundingClientRect();
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 = {