@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/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 headingId = 'pie-tooltip-heading';
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.resolveOverlayMode();
347
+ this._overlayModeDirty = true;
231
348
  }
232
349
 
233
- if (anchoringProperties.some((prop) => changedProperties.has(prop))) {
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
- // Listens for scroll (capture — catches any ancestor), resize, and dir-attribute changes to
262
- // re-anchor the panel. Multiple events per frame coalesce into one requestAnimationFrame call.
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
- if (this._shouldResolveOverlayMode) {
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._shouldResolveOverlayMode = true;
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
- // Geometry changed, so the ancestor chain may have too. Flagged rather than resolved
320
- // here so the walk runs at most once per frame.
321
- this._shouldResolveOverlayMode = true;
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
- let absoluteClippers = 0;
373
- let fixedClippers = 0;
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
- absoluteClippers += 1;
543
+ absoluteClippingAncestors.push(element);
414
544
  }
415
545
 
416
546
  if (isAtOrAboveFixedContainingBlock) {
417
- fixedClippers += 1;
547
+ fixedClippingAncestors.push(element);
418
548
  }
419
549
  }
420
550
  });
421
551
 
422
- // The fixed containing block is always at or above the absolute one, so the clippers that
423
- // apply to a fixed box are a subset of those that apply to an absolute one. A lower count
424
- // therefore always means a strictly better escape, never a worse one.
425
- this.style.position = fixedClippers < absoluteClippers ? 'fixed' : '';
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
- // Cached because `projectOverTrigger` runs on every re-anchoring frame, and collecting these
487
- // costs a computed style per ancestor. Refreshed when the trigger changes and whenever the
488
- // overlay mode is resolved, which is the same cadence as an ancestor chain actually changing.
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
- if (this.isOpen) {
599
- this._requestClose();
600
- } else {
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="${headingId}"
865
+ id="${this._headingId}"
628
866
  class="${componentClass}-heading"
629
- data-test-id="${headingId}">${this.heading}</${tag}>`;
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': mode === 'dialog',
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 ? headingId : nothing}"
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 class="${componentClass}-content" data-test-id="${componentSelector}-content">
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>