@justeattakeaway/pie-tooltip 0.2.0 → 0.3.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,72 @@ 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
+
73
136
  /**
74
137
  * @tagname pie-tooltip
75
- * @event {Event} pie-tooltip-close - When the close button is clicked. Set `isOpen` to `false` in response.
138
+ * @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.
76
140
  * @slot content - The descriptive content of the panel. Must not contain focusable elements.
77
141
  * @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
142
  */
@@ -113,6 +177,9 @@ export class PieTooltip extends PieElement implements TooltipProps {
113
177
  @property({ type: Object })
114
178
  public aria: TooltipProps['aria'];
115
179
 
180
+ @property({ type: Array })
181
+ public triggers: TooltipProps['triggers'] = [];
182
+
116
183
  @queryAssignedElements({ slot: 'action' }) private _assignedActionElements!: Array<HTMLElement>;
117
184
 
118
185
  @query('.c-tooltip-origin') private _originElement!: HTMLElement | null;
@@ -121,14 +188,25 @@ export class PieTooltip extends PieElement implements TooltipProps {
121
188
 
122
189
  @state() private _isPositioned = false;
123
190
 
191
+ @state() private _isAnchorVisible = true;
192
+
193
+ private _clippedTriggerElement: Element | null = null;
194
+
195
+ private _triggerClippers: Array<Element> = [];
196
+
124
197
  private _triggerTrackingController: AbortController | undefined;
198
+ private _interactionController: AbortController | undefined;
125
199
 
126
200
  private _directionObserver: MutationObserver | undefined;
127
201
 
202
+ private _triggerObserver: ResizeObserver | undefined;
203
+
128
204
  private _reanchorFrame = 0;
129
205
 
130
206
  private _shouldResolveOverlayMode = false;
131
207
 
208
+ private _hoverCloseTimer: ReturnType<typeof setTimeout> | undefined;
209
+
132
210
  static styles = unsafeCSS(styles);
133
211
 
134
212
  private get _mode (): TooltipMode | undefined {
@@ -142,6 +220,7 @@ export class PieTooltip extends PieElement implements TooltipProps {
142
220
  protected firstUpdated (): void {
143
221
  this.resolveMode();
144
222
  this.projectOverTrigger();
223
+ this._rebuildInteractionListeners();
145
224
  }
146
225
 
147
226
  protected updated (changedProperties: PropertyValues<this>): void {
@@ -155,14 +234,26 @@ export class PieTooltip extends PieElement implements TooltipProps {
155
234
  this.projectOverTrigger();
156
235
  }
157
236
 
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
+ if (this.isOpen && changedProperties.has('trigger')) {
241
+ this.stopTrackingTrigger();
242
+ }
243
+
158
244
  if (this.isOpen) {
159
245
  this.startTrackingTrigger();
160
246
  } else {
161
247
  this.stopTrackingTrigger();
162
248
  }
249
+
250
+ if (changedProperties.has('triggers') || changedProperties.has('trigger')) {
251
+ this._rebuildInteractionListeners();
252
+ }
163
253
  }
164
254
 
165
255
  public disconnectedCallback (): void {
256
+ this._teardownInteractionListeners();
166
257
  this.stopTrackingTrigger();
167
258
  super.disconnectedCallback();
168
259
  }
@@ -203,6 +294,40 @@ export class PieTooltip extends PieElement implements TooltipProps {
203
294
  window.addEventListener('scroll', handleViewportChange, { capture: true, passive: true, signal });
204
295
  window.addEventListener('resize', handleResize, { passive: true, signal });
205
296
 
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
+ const shadowRoots = new Set<ShadowRoot>();
302
+
303
+ flattenedAncestors(this).forEach((ancestor) => {
304
+ const root = ancestor.getRootNode();
305
+
306
+ if (root instanceof ShadowRoot) {
307
+ shadowRoots.add(root);
308
+ }
309
+ });
310
+
311
+ shadowRoots.forEach((root) => {
312
+ root.addEventListener('scroll', handleViewportChange, { capture: true, passive: true, signal });
313
+ });
314
+
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
+ 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;
322
+ handleViewportChange();
323
+ });
324
+
325
+ const triggerElement = this._getTriggerElement();
326
+
327
+ if (triggerElement) {
328
+ this._triggerObserver.observe(triggerElement);
329
+ }
330
+
206
331
  this._directionObserver = new MutationObserver(handleViewportChange);
207
332
  this._directionObserver.observe(this.ownerDocument.documentElement, {
208
333
  attributeFilter: ['dir'],
@@ -219,6 +344,9 @@ export class PieTooltip extends PieElement implements TooltipProps {
219
344
  this._directionObserver?.disconnect();
220
345
  this._directionObserver = undefined;
221
346
 
347
+ this._triggerObserver?.disconnect();
348
+ this._triggerObserver = undefined;
349
+
222
350
  this._shouldResolveOverlayMode = false;
223
351
 
224
352
  if (this._reanchorFrame) {
@@ -234,23 +362,32 @@ export class PieTooltip extends PieElement implements TooltipProps {
234
362
  // Picks between `absolute` (browser handles scrolling, but clipped by overflow ancestors) and
235
363
  // `fixed` (escapes overflow clips, but must re-offset on every scroll). Switches to `fixed`
236
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.
237
369
  private resolveOverlayMode (): void {
238
370
  let isAtOrAboveAbsoluteContainingBlock = false;
239
371
  let isAtOrAboveFixedContainingBlock = false;
240
- let isAbsoluteClipped = false;
241
- let isFixedClipped = false;
372
+ let absoluteClippers = 0;
373
+ let fixedClippers = 0;
242
374
 
243
- let node: Node | null = this.parentNode;
375
+ const { documentElement, body } = this.ownerDocument;
244
376
 
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;
377
+ // Resolved on the same triggers as the overlay mode, because both answers change only
378
+ // when the ancestor chain does.
379
+ this._refreshTriggerClippers();
248
380
 
249
- if (!(element instanceof Element)) {
250
- break;
381
+ flattenedAncestors(this).forEach((element) => {
382
+ const styles = getComputedStyle(element);
383
+
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
+ if (styles.display === 'contents') {
388
+ return;
251
389
  }
252
390
 
253
- const styles = getComputedStyle(element);
254
391
  const isContainingBlock = createsContainingBlock(styles);
255
392
 
256
393
  if (isContainingBlock || styles.position !== 'static') {
@@ -261,21 +398,31 @@ export class PieTooltip extends PieElement implements TooltipProps {
261
398
  isAtOrAboveFixedContainingBlock = true;
262
399
  }
263
400
 
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
- }
401
+ const clips = styles.overflowX !== 'visible' || styles.overflowY !== 'visible';
270
402
 
271
- if (element === this.ownerDocument.documentElement) {
272
- break;
273
- }
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
+ const propagatesOverflowToViewport = element === documentElement || element === body;
274
408
 
275
- node = element.parentNode;
276
- }
409
+ // Counted after the containing-block flags so an ancestor that is both the containing
410
+ // block and the clipper is counted correctly.
411
+ if (clips && !propagatesOverflowToViewport) {
412
+ if (isAtOrAboveAbsoluteContainingBlock) {
413
+ absoluteClippers += 1;
414
+ }
277
415
 
278
- this.style.position = isAbsoluteClipped && !isFixedClipped ? 'fixed' : '';
416
+ if (isAtOrAboveFixedContainingBlock) {
417
+ fixedClippers += 1;
418
+ }
419
+ }
420
+ });
421
+
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' : '';
279
426
  }
280
427
 
281
428
  // Measures the trigger relative to the origin marker (which sits at the containing block's
@@ -284,13 +431,36 @@ export class PieTooltip extends PieElement implements TooltipProps {
284
431
  private projectOverTrigger (): void {
285
432
  this._isPositioned = false;
286
433
 
287
- const triggerElement = this.trigger ? this.ownerDocument.getElementById(this.trigger) : null;
434
+ const triggerElement = this._getTriggerElement();
288
435
 
289
436
  if (!triggerElement || !this._originElement) {
290
437
  ['top', 'left', 'width', 'height'].forEach((suffix) => {
291
438
  this.style.removeProperty(`--tooltip-anchor-${suffix}`);
292
439
  });
293
440
  this.style.removeProperty('--tooltip-container-inline-size');
441
+ this._isAnchorVisible = true;
442
+ this._isPositioned = true;
443
+
444
+ return;
445
+ }
446
+
447
+ if (triggerElement !== this._clippedTriggerElement) {
448
+ this._refreshTriggerClippers();
449
+ }
450
+
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
+ const visibleRect = this._triggerClippers.reduce<DOMRect | null>(
456
+ (rect, clipper) => (rect ? intersectRects(rect, getClipRect(clipper)) : null),
457
+ triggerElement.getBoundingClientRect(),
458
+ );
459
+
460
+ // Nothing of the trigger is left to point at, so there is nothing to describe either.
461
+ this._isAnchorVisible = visibleRect !== null;
462
+
463
+ if (!visibleRect) {
294
464
  this._isPositioned = true;
295
465
 
296
466
  return;
@@ -299,7 +469,7 @@ export class PieTooltip extends PieElement implements TooltipProps {
299
469
  const originRect = this._originElement.getBoundingClientRect();
300
470
  const {
301
471
  top, left, width, height,
302
- } = triggerElement.getBoundingClientRect();
472
+ } = visibleRect;
303
473
 
304
474
  this.style.setProperty('--tooltip-anchor-top', `${top - originRect.top}px`);
305
475
  this.style.setProperty('--tooltip-anchor-left', `${left - originRect.left}px`);
@@ -313,18 +483,143 @@ export class PieTooltip extends PieElement implements TooltipProps {
313
483
  this._isPositioned = true;
314
484
  }
315
485
 
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.
489
+ private _refreshTriggerClippers (): void {
490
+ const triggerElement = this._getTriggerElement();
491
+
492
+ this._clippedTriggerElement = triggerElement;
493
+ this._triggerClippers = triggerElement ? collectClippingAncestors(triggerElement) : [];
494
+ }
495
+
316
496
  private handleCloseButtonClick (): void {
497
+ this._requestClose();
498
+ }
499
+
500
+ private _getTriggerElement (): Element | null {
501
+ return this.trigger ? this.ownerDocument.getElementById(this.trigger) : null;
502
+ }
503
+
504
+ private _requestOpen (): void {
505
+ if (this.isOpen) return;
506
+
507
+ /**
508
+ * @ignore
509
+ */
510
+ this.dispatchEvent(new Event(ON_TOOLTIP_OPEN_EVENT, { bubbles: true, composed: true }));
511
+ }
512
+
513
+ private _requestClose (): void {
514
+ if (!this.isOpen) return;
515
+
317
516
  /**
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
517
  * @ignore
324
518
  */
325
519
  this.dispatchEvent(new Event(ON_TOOLTIP_CLOSE_EVENT, { bubbles: true, composed: true }));
326
520
  }
327
521
 
522
+ private _startHoverCloseTimer (): void {
523
+ if (this._hoverCloseTimer !== undefined) return;
524
+ this._hoverCloseTimer = setTimeout(() => {
525
+ this._hoverCloseTimer = undefined;
526
+ this._requestClose();
527
+ }, 100);
528
+ }
529
+
530
+ private _cancelHoverCloseTimer (): void {
531
+ if (this._hoverCloseTimer !== undefined) {
532
+ clearTimeout(this._hoverCloseTimer);
533
+ this._hoverCloseTimer = undefined;
534
+ }
535
+ }
536
+
537
+ private _rebuildInteractionListeners (): void {
538
+ this._teardownInteractionListeners();
539
+
540
+ if (!this.triggers?.length) return;
541
+
542
+ const controller = new AbortController();
543
+ const { signal } = controller;
544
+ this._interactionController = controller;
545
+
546
+ const triggerEl = this._getTriggerElement();
547
+
548
+ this.ownerDocument.addEventListener('keydown', (e: KeyboardEvent) => {
549
+ if (e.key === 'Escape' && this.isOpen) {
550
+ this._requestClose();
551
+ }
552
+ }, { signal });
553
+
554
+ if (!triggerEl) return;
555
+
556
+ if (this.triggers.includes('hover')) {
557
+ triggerEl.addEventListener('mouseenter', () => {
558
+ this._cancelHoverCloseTimer();
559
+ this._requestOpen();
560
+ }, { signal });
561
+
562
+ triggerEl.addEventListener('mouseleave', () => {
563
+ this._startHoverCloseTimer();
564
+ }, { signal });
565
+
566
+ // panel mouseenter/leave for the hover bridge (includes the bridge pseudo-element)
567
+ const panel = this.renderRoot.querySelector('.c-tooltip');
568
+ if (panel) {
569
+ panel.addEventListener('mouseenter', () => {
570
+ this._cancelHoverCloseTimer();
571
+ }, { signal });
572
+
573
+ panel.addEventListener('mouseleave', () => {
574
+ this._startHoverCloseTimer();
575
+ }, { signal });
576
+ }
577
+ }
578
+
579
+ if (this.triggers.includes('focus')) {
580
+ // open on focus, close on blur unless focus moved into panel action content
581
+ triggerEl.addEventListener('focusin', () => {
582
+ this._requestOpen();
583
+ }, { signal });
584
+
585
+ triggerEl.addEventListener('focusout', (e: Event) => {
586
+ const related = (e as FocusEvent).relatedTarget as Node | null;
587
+ // relatedTarget is retargeted to the shadow host when focus moves into shadow DOM
588
+ const staysInside = related && (this.contains(related) || related === this);
589
+ if (!staysInside) {
590
+ this._requestClose();
591
+ }
592
+ }, { signal });
593
+ }
594
+
595
+ if (this.triggers.includes('click')) {
596
+ triggerEl.addEventListener('click', (e: Event) => {
597
+ e.stopPropagation();
598
+ if (this.isOpen) {
599
+ this._requestClose();
600
+ } else {
601
+ this._requestOpen();
602
+ }
603
+ }, { signal });
604
+
605
+ // Light-dismiss: click anywhere outside the panel and trigger
606
+ this.ownerDocument.addEventListener('click', (e: Event) => {
607
+ const target = e.composedPath()[0] as Node;
608
+ const isInsidePanel = this.contains(target) || this.shadowRoot?.contains(target);
609
+ const isInsideTrigger = triggerEl.contains(target) || target === triggerEl;
610
+ if (!isInsidePanel && !isInsideTrigger && this.isOpen) {
611
+ this._requestClose();
612
+ }
613
+ }, { signal });
614
+ }
615
+ }
616
+
617
+ private _teardownInteractionListeners (): void {
618
+ this._interactionController?.abort();
619
+ this._interactionController = undefined;
620
+ this._cancelHoverCloseTimer();
621
+ }
622
+
328
623
  private renderHeading (): TemplateResult {
329
624
  const tag = unsafeStatic(this.headingLevel);
330
625
 
@@ -368,6 +663,7 @@ export class PieTooltip extends PieElement implements TooltipProps {
368
663
  [`${componentClass}-layer--type-${type}`]: true,
369
664
  'is-open': !!isOpen,
370
665
  'is-positioned': this._isPositioned,
666
+ 'is-anchorVisible': this._isAnchorVisible,
371
667
  };
372
668
 
373
669
  const panelClasses = {
package/src/react.ts CHANGED
@@ -11,13 +11,15 @@ const PieTooltipReact = createComponent({
11
11
  react: React,
12
12
  tagName: 'pie-tooltip',
13
13
  events: {
14
- onPieTooltipClose: 'pie-tooltip-close' as EventName<Event>, // When the close button is clicked. Set `isOpen` to `false` in response.
14
+ onPieTooltipOpen: 'pie-tooltip-open' as EventName<Event>, // When a configured trigger asks for the panel. Set `isOpen` to `true` in response.
15
+ onPieTooltipClose: 'pie-tooltip-close' as EventName<Event>, // When the close button is clicked, or a configured trigger asks to dismiss the panel. Set `isOpen` to `false` in response.
15
16
  },
16
17
  });
17
18
 
18
19
  type ReactBaseType = React.HTMLAttributes<HTMLElement>
19
20
 
20
21
  type PieTooltipEvents = {
22
+ onPieTooltipOpen?: (event: Event) => void;
21
23
  onPieTooltipClose?: (event: Event) => void;
22
24
  };
23
25
 
package/src/tooltip.scss CHANGED
@@ -65,7 +65,9 @@ $tooltip-alignments: ('', '-start', '-end');
65
65
  visibility: hidden;
66
66
  transition: visibility 0s linear 150ms;
67
67
 
68
- &.is-open.is-positioned {
68
+ // `is-anchorVisible` is false when the trigger has been scrolled entirely out of a clipping
69
+ // ancestor: there is nothing left to point at, so the panel hides rather than float free.
70
+ &.is-open.is-positioned.is-anchorVisible {
69
71
  visibility: visible;
70
72
  transition-delay: 0s;
71
73
  }
@@ -168,7 +170,7 @@ $tooltip-alignments: ('', '-start', '-end');
168
170
  transform var(--tooltip-motion-duration) var(--tooltip-motion-easing);
169
171
  }
170
172
 
171
- .c-tooltip-layer.is-open.is-positioned .c-tooltip {
173
+ .c-tooltip-layer.is-open.is-positioned.is-anchorVisible .c-tooltip {
172
174
  --tooltip-motion-duration: 200ms;
173
175
 
174
176
  opacity: 1;
@@ -367,3 +369,45 @@ $tooltip-alignments: ('', '-start', '-end');
367
369
  inset-block-start: var(--dt-spacing-b);
368
370
  inset-inline-end: var(--dt-spacing-b);
369
371
  }
372
+
373
+ // Hover bridge: a transparent pseudo-element extending from each panel edge toward its
374
+ // trigger, covering the --tooltip-layer-offset gap. Keeps the hover region intact so
375
+ // the pointer can travel from the trigger into the panel without the close timer firing.
376
+ // Sized from --tooltip-layer-offset so widening --tooltip-offset cannot open a hole.
377
+ @each $alignment in $tooltip-alignments {
378
+ .c-tooltip--top#{$alignment}::before {
379
+ content: '';
380
+ position: absolute;
381
+ inset-block-start: 100%;
382
+ block-size: var(--tooltip-layer-offset);
383
+ inset-inline-start: 0;
384
+ inline-size: 100%;
385
+ }
386
+
387
+ .c-tooltip--bottom#{$alignment}::before {
388
+ content: '';
389
+ position: absolute;
390
+ inset-block-end: 100%;
391
+ block-size: var(--tooltip-layer-offset);
392
+ inset-inline-start: 0;
393
+ inline-size: 100%;
394
+ }
395
+
396
+ .c-tooltip--left#{$alignment}::before {
397
+ content: '';
398
+ position: absolute;
399
+ inset-inline-start: 100%;
400
+ inline-size: var(--tooltip-layer-offset);
401
+ inset-block-start: 0;
402
+ block-size: 100%;
403
+ }
404
+
405
+ .c-tooltip--right#{$alignment}::before {
406
+ content: '';
407
+ position: absolute;
408
+ inset-inline-end: 100%;
409
+ inline-size: var(--tooltip-layer-offset);
410
+ inset-block-start: 0;
411
+ block-size: 100%;
412
+ }
413
+ }