@justeattakeaway/pie-tooltip 0.3.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/dist/react.d.ts CHANGED
@@ -62,15 +62,19 @@ declare class PieTooltip_2 extends PieElement implements TooltipProps {
62
62
  private _hasActionContent;
63
63
  private _isPositioned;
64
64
  private _isAnchorVisible;
65
+ private _resolvedPosition;
65
66
  private _clippedTriggerElement;
66
67
  private _triggerClippers;
68
+ private _overlayClippers;
69
+ private _collisionSignature;
70
+ private _overlayModeDirty;
67
71
  private _triggerTrackingController;
68
72
  private _interactionController;
69
73
  private _directionObserver;
70
74
  private _triggerObserver;
71
75
  private _reanchorFrame;
72
- private _shouldResolveOverlayMode;
73
76
  private _hoverCloseTimer;
77
+ private _openedByClick;
74
78
  static styles: CSSResult;
75
79
  private get _mode();
76
80
  protected firstUpdated(): void;
@@ -81,6 +85,10 @@ declare class PieTooltip_2 extends PieElement implements TooltipProps {
81
85
  private resolveMode;
82
86
  private resolveOverlayMode;
83
87
  private projectOverTrigger;
88
+ private resolveCollision;
89
+ private _setResolvedPosition;
90
+ private _getCollisionBoundary;
91
+ private _getCandidatePositions;
84
92
  private _refreshTriggerClippers;
85
93
  private handleCloseButtonClick;
86
94
  private _getTriggerElement;
@@ -120,7 +128,10 @@ export declare interface TooltipProps {
120
128
  */
121
129
  isOpen?: boolean;
122
130
  /**
123
- * The side of the trigger the panel sits on, and its alignment along the cross axis.
131
+ * The preferred side of the trigger the panel sits on, and its alignment along the cross axis.
132
+ * The component repositions the panel automatically — flipping to the opposite side and/or
133
+ * shifting its alignment — when the preferred position would collide with the viewport or with
134
+ * a clipping scroll container. This behaviour cannot be disabled.
124
135
  */
125
136
  position?: typeof positions[number];
126
137
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@justeattakeaway/pie-tooltip",
3
3
  "description": "PIE Design System Tooltip built using Web Components",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.js",
package/src/defs.ts CHANGED
@@ -56,7 +56,10 @@ export interface TooltipProps {
56
56
  isOpen?: boolean;
57
57
 
58
58
  /**
59
- * The side of the trigger the panel sits on, and its alignment along the cross axis.
59
+ * The preferred side of the trigger the panel sits on, and its alignment along the cross axis.
60
+ * The component repositions the panel automatically — flipping to the opposite side and/or
61
+ * shifting its alignment — when the preferred position would collide with the viewport or with
62
+ * a clipping scroll container. This behaviour cannot be disabled.
60
63
  */
61
64
  position?: typeof positions[number];
62
65
 
package/src/index.ts CHANGED
@@ -133,6 +133,115 @@ const intersectRects = (a: DOMRect, b: DOMRect): DOMRect | null => {
133
133
  return new DOMRect(left, top, right - left, bottom - top);
134
134
  };
135
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
+
136
245
  /**
137
246
  * @tagname pie-tooltip
138
247
  * @event {Event} pie-tooltip-open - When a configured trigger asks for the panel. Set `isOpen` to `true` in response.
@@ -190,10 +299,18 @@ export class PieTooltip extends PieElement implements TooltipProps {
190
299
 
191
300
  @state() private _isAnchorVisible = true;
192
301
 
302
+ @state() private _resolvedPosition: TooltipProps['position'] | undefined;
303
+
193
304
  private _clippedTriggerElement: Element | null = null;
194
305
 
195
306
  private _triggerClippers: Array<Element> = [];
196
307
 
308
+ private _overlayClippers: Array<Element> = [];
309
+
310
+ private _collisionSignature: string | null = null;
311
+
312
+ private _overlayModeDirty = true;
313
+
197
314
  private _triggerTrackingController: AbortController | undefined;
198
315
  private _interactionController: AbortController | undefined;
199
316
 
@@ -203,10 +320,10 @@ export class PieTooltip extends PieElement implements TooltipProps {
203
320
 
204
321
  private _reanchorFrame = 0;
205
322
 
206
- private _shouldResolveOverlayMode = false;
207
-
208
323
  private _hoverCloseTimer: ReturnType<typeof setTimeout> | undefined;
209
324
 
325
+ private _openedByClick = false;
326
+
210
327
  static styles = unsafeCSS(styles);
211
328
 
212
329
  private get _mode (): TooltipMode | undefined {
@@ -219,6 +336,8 @@ export class PieTooltip extends PieElement implements TooltipProps {
219
336
 
220
337
  protected firstUpdated (): void {
221
338
  this.resolveMode();
339
+ this._overlayModeDirty = true;
340
+ this.resolveOverlayMode();
222
341
  this.projectOverTrigger();
223
342
  this._rebuildInteractionListeners();
224
343
  }
@@ -227,10 +346,26 @@ export class PieTooltip extends PieElement implements TooltipProps {
227
346
  const anchoringProperties: Array<keyof PieTooltip> = ['trigger', 'isOpen', 'position', 'size'];
228
347
 
229
348
  if (this.isOpen && changedProperties.has('isOpen')) {
230
- 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;
231
358
  }
232
359
 
233
- 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) {
234
369
  this.projectOverTrigger();
235
370
  }
236
371
 
@@ -276,18 +411,14 @@ export class PieTooltip extends PieElement implements TooltipProps {
276
411
  this._reanchorFrame = requestAnimationFrame(() => {
277
412
  this._reanchorFrame = 0;
278
413
 
279
- if (this._shouldResolveOverlayMode) {
280
- this._shouldResolveOverlayMode = false;
281
- this.resolveOverlayMode();
282
- }
283
-
414
+ this.resolveOverlayMode();
284
415
  this.projectOverTrigger();
285
416
  });
286
417
  };
287
418
 
288
419
  // Flagged rather than resolved immediately so window-drag cannot walk ancestors more than once per frame.
289
420
  const handleResize = () => {
290
- this._shouldResolveOverlayMode = true;
421
+ this._overlayModeDirty = true;
291
422
  handleViewportChange();
292
423
  };
293
424
 
@@ -318,7 +449,7 @@ export class PieTooltip extends PieElement implements TooltipProps {
318
449
  this._triggerObserver = new ResizeObserver(() => {
319
450
  // Geometry changed, so the ancestor chain may have too. Flagged rather than resolved
320
451
  // here so the walk runs at most once per frame.
321
- this._shouldResolveOverlayMode = true;
452
+ this._overlayModeDirty = true;
322
453
  handleViewportChange();
323
454
  });
324
455
 
@@ -347,8 +478,6 @@ export class PieTooltip extends PieElement implements TooltipProps {
347
478
  this._triggerObserver?.disconnect();
348
479
  this._triggerObserver = undefined;
349
480
 
350
- this._shouldResolveOverlayMode = false;
351
-
352
481
  if (this._reanchorFrame) {
353
482
  cancelAnimationFrame(this._reanchorFrame);
354
483
  this._reanchorFrame = 0;
@@ -367,10 +496,16 @@ export class PieTooltip extends PieElement implements TooltipProps {
367
496
  // ancestor of it; a clipper strictly inside the containing block does not clip. So each
368
497
  // clipper is counted against a mode only once that mode's containing block has been reached.
369
498
  private resolveOverlayMode (): void {
499
+ if (!this._overlayModeDirty) {
500
+ return;
501
+ }
502
+
503
+ this._overlayModeDirty = false;
504
+
370
505
  let isAtOrAboveAbsoluteContainingBlock = false;
371
506
  let isAtOrAboveFixedContainingBlock = false;
372
- let absoluteClippers = 0;
373
- let fixedClippers = 0;
507
+ const absoluteClippingAncestors: Array<Element> = [];
508
+ const fixedClippingAncestors: Array<Element> = [];
374
509
 
375
510
  const { documentElement, body } = this.ownerDocument;
376
511
 
@@ -410,11 +545,11 @@ export class PieTooltip extends PieElement implements TooltipProps {
410
545
  // block and the clipper is counted correctly.
411
546
  if (clips && !propagatesOverflowToViewport) {
412
547
  if (isAtOrAboveAbsoluteContainingBlock) {
413
- absoluteClippers += 1;
548
+ absoluteClippingAncestors.push(element);
414
549
  }
415
550
 
416
551
  if (isAtOrAboveFixedContainingBlock) {
417
- fixedClippers += 1;
552
+ fixedClippingAncestors.push(element);
418
553
  }
419
554
  }
420
555
  });
@@ -422,7 +557,10 @@ export class PieTooltip extends PieElement implements TooltipProps {
422
557
  // The fixed containing block is always at or above the absolute one, so the clippers that
423
558
  // apply to a fixed box are a subset of those that apply to an absolute one. A lower count
424
559
  // therefore always means a strictly better escape, never a worse one.
425
- this.style.position = fixedClippers < absoluteClippers ? 'fixed' : '';
560
+ const useFixed = fixedClippingAncestors.length < absoluteClippingAncestors.length;
561
+
562
+ this.style.position = useFixed ? 'fixed' : '';
563
+ this._overlayClippers = useFixed ? fixedClippingAncestors : absoluteClippingAncestors;
426
564
  }
427
565
 
428
566
  // Measures the trigger relative to the origin marker (which sits at the containing block's
@@ -480,9 +618,122 @@ export class PieTooltip extends PieElement implements TooltipProps {
480
618
  const containerInlineSize = container ? container.getBoundingClientRect().width : width;
481
619
 
482
620
  this.style.setProperty('--tooltip-container-inline-size', `${containerInlineSize}px`);
621
+
622
+ this.resolveCollision(visibleRect);
623
+
483
624
  this._isPositioned = true;
484
625
  }
485
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
+
486
737
  // Cached because `projectOverTrigger` runs on every re-anchoring frame, and collecting these
487
738
  // costs a computed style per ancestor. Refreshed when the trigger changes and whenever the
488
739
  // overlay mode is resolved, which is the same cadence as an ancestor chain actually changing.
@@ -595,11 +846,22 @@ export class PieTooltip extends PieElement implements TooltipProps {
595
846
  if (this.triggers.includes('click')) {
596
847
  triggerEl.addEventListener('click', (e: Event) => {
597
848
  e.stopPropagation();
598
- if (this.isOpen) {
599
- this._requestClose();
600
- } else {
849
+
850
+ if (!this.isOpen) {
851
+ // Only click-opens toggle
852
+ this._openedByClick = true;
601
853
  this._requestOpen();
854
+
855
+ return;
856
+ }
857
+
858
+ if (!this._openedByClick) {
859
+ this._openedByClick = true;
860
+
861
+ return;
602
862
  }
863
+
864
+ this._requestClose();
603
865
  }, { signal });
604
866
 
605
867
  // Light-dismiss: click anywhere outside the panel and trigger
@@ -648,13 +910,15 @@ export class PieTooltip extends PieElement implements TooltipProps {
648
910
  heading,
649
911
  isDismissible,
650
912
  isOpen,
651
- position,
913
+ position: preferredPosition,
652
914
  size,
653
915
  type,
654
916
  variant,
917
+ _resolvedPosition,
655
918
  _mode: mode,
656
919
  } = this;
657
920
 
921
+ const position = _resolvedPosition ?? preferredPosition;
658
922
  const isIconType = type === 'icon';
659
923
 
660
924
  const layerClasses = {