@justeattakeaway/pie-tooltip 0.4.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/dist/react.d.ts CHANGED
@@ -6,13 +6,9 @@ import * as React_2 from 'react';
6
6
  import { TemplateResult } from 'lit';
7
7
 
8
8
  declare type AriaProps = {
9
- /**
10
- * The accessible name for the close button. Required whenever `isDismissible` is set.
11
- */
9
+ /** The accessible name for the close button. Required whenever `isDismissible` is set. */
12
10
  close?: string;
13
- /**
14
- * The accessible name for the panel in dialog mode. Only used when `heading` is not provided.
15
- */
11
+ /** The accessible name for the panel in dialog mode, when no `heading` is provided. */
16
12
  label?: string;
17
13
  };
18
14
 
@@ -26,10 +22,7 @@ export declare const defaultProps: DefaultProps;
26
22
 
27
23
  export declare const headingLevels: readonly ["h2", "h3", "h4", "h5", "h6"];
28
24
 
29
- /**
30
- * The two patterns the panel can present as. Inferred from the `action` slot rather than
31
- * configured: an empty `action` slot is a tooltip, a filled one is a non-modal dialog.
32
- */
25
+ /** The two patterns the panel can present as, inferred from the `action` slot. */
33
26
  export declare const modes: readonly ["tooltip", "dialog"];
34
27
 
35
28
  export declare const ON_TOOLTIP_CLOSE_EVENT = "pie-tooltip-close";
@@ -41,7 +34,7 @@ export declare const PieTooltip: React_2.ForwardRefExoticComponent<React_2.Props
41
34
  /**
42
35
  * @tagname pie-tooltip
43
36
  * @event {Event} pie-tooltip-open - When a configured trigger asks for the panel. Set `isOpen` to `true` in response.
44
- * @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.
37
+ * @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.
45
38
  * @slot content - The descriptive content of the panel. Must not contain focusable elements.
46
39
  * @slot action - An optional slot for interactive content such as a `pie-button`. Filling this slot switches the panel to a non-modal dialog.
47
40
  */
@@ -75,11 +68,14 @@ declare class PieTooltip_2 extends PieElement implements TooltipProps {
75
68
  private _reanchorFrame;
76
69
  private _hoverCloseTimer;
77
70
  private _openedByClick;
71
+ private readonly _instanceId;
72
+ private get _headingId();
78
73
  static styles: CSSResult;
79
74
  private get _mode();
80
75
  protected firstUpdated(): void;
81
76
  protected updated(changedProperties: PropertyValues<this>): void;
82
77
  disconnectedCallback(): void;
78
+ focusPanel(): Promise<boolean>;
83
79
  private startTrackingTrigger;
84
80
  private stopTrackingTrigger;
85
81
  private resolveMode;
@@ -117,63 +113,27 @@ export declare const sizes: readonly ["default", "fit-to-content", "fill-contain
117
113
  export declare type TooltipMode = typeof modes[number];
118
114
 
119
115
  export declare interface TooltipProps {
120
- /**
121
- * The `id` of the element the panel is anchored to. The trigger lives elsewhere in the
122
- * DOM and is never slotted into the tooltip.
123
- */
116
+ /** The `id` of the element the panel is anchored to. */
124
117
  trigger?: string;
125
- /**
126
- * When true, the panel is visible. The component never writes to this property: the
127
- * consumer owns it and updates it in response to the close event.
128
- */
118
+ /** When true, the panel is visible. The component never writes to this property. */
129
119
  isOpen?: boolean;
130
- /**
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.
135
- */
120
+ /** The preferred side and alignment; the panel repositions itself to avoid collisions. */
136
121
  position?: typeof positions[number];
137
- /**
138
- * How the panel sizes itself. `default` is a fixed 280px and wraps, `fit-to-content` is as
139
- * wide as its content, and `fill-container` matches the inline size of the trigger's parent
140
- * element. Not applied when `type` is `icon`.
141
- */
122
+ /** How the panel sizes itself. Not applied when `type` is `icon`. */
142
123
  size?: typeof sizes[number];
143
- /**
144
- * The colour treatment of the panel. `default` is the dark panel, `inverse` the light one.
145
- */
124
+ /** The colour treatment of the panel. `default` is the dark panel, `inverse` the light one. */
146
125
  variant?: typeof variants[number];
147
- /**
148
- * The presentation of the panel. `icon` is the compact treatment intended for icon triggers:
149
- * it has no arrow and is always as wide as its content, so `size` and `--tooltip-width` have
150
- * no effect on it.
151
- */
126
+ /** The presentation of the panel. `icon` is the compact, arrow-less treatment. */
152
127
  type?: typeof types[number];
153
- /**
154
- * When true, a close button is rendered inside the panel.
155
- */
128
+ /** When true, a close button is rendered inside the panel and the panel presents as a non-modal dialog, so it needs `heading` or `aria.label` for its accessible name. */
156
129
  isDismissible?: boolean;
157
- /**
158
- * The text to display in the panel's heading. In dialog mode this also provides the
159
- * panel's accessible name.
160
- */
130
+ /** The text to display in the panel's heading. In dialog mode this also names the panel. */
161
131
  heading?: string;
162
- /**
163
- * The HTML heading tag to use for the panel's heading. Can be h2-h6.
164
- */
132
+ /** The HTML heading tag to use for the panel's heading. Can be h2-h6. */
165
133
  headingLevel?: typeof headingLevels[number];
166
- /**
167
- * The ARIA labels used for various parts of the tooltip.
168
- */
134
+ /** The ARIA labels used for various parts of the tooltip. */
169
135
  aria?: AriaProps;
170
- /**
171
- * Which interactions request that the panel opens and closes. The component never opens or
172
- * closes itself: a configured interaction emits `pie-tooltip-open` or `pie-tooltip-close`
173
- * and the consumer decides whether to honour it by setting `isOpen`. Empty by default, so
174
- * no interaction is watched at all. Configure `hover` and `focus` together for keyboard
175
- * reachability, and `click` for pointer users.
176
- */
136
+ /** Which interactions request that the panel opens and closes. */
177
137
  triggers?: Array<TooltipTrigger>;
178
138
  }
179
139
 
package/dist/react.js CHANGED
@@ -11,7 +11,7 @@ const i = e({
11
11
  onPieTooltipOpen: "pie-tooltip-open",
12
12
  // When a configured trigger asks for the panel. Set `isOpen` to `true` in response.
13
13
  onPieTooltipClose: "pie-tooltip-close"
14
- // When the close button is clicked, or a configured trigger asks to dismiss the panel. Set `isOpen` to `false` in response.
14
+ // When the close button is clicked, Escape is pressed, or a configured trigger asks to dismiss the panel. Set `isOpen` to `false` in response.
15
15
  }
16
16
  }), l = i;
17
17
  export {
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.4.0",
4
+ "version": "0.5.0",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.js",
@@ -36,11 +36,11 @@
36
36
  "license": "Apache-2.0",
37
37
  "devDependencies": {
38
38
  "@justeattakeaway/pie-components-config": "0.21.4",
39
- "@justeattakeaway/pie-css": "1.9.0",
40
- "@justeattakeaway/pie-monorepo-utils": "0.9.10"
39
+ "@justeattakeaway/pie-css": "1.9.1",
40
+ "@justeattakeaway/pie-monorepo-utils": "0.9.11"
41
41
  },
42
42
  "dependencies": {
43
- "@justeattakeaway/pie-icon-button": "2.7.35",
43
+ "@justeattakeaway/pie-icon-button": "2.8.0",
44
44
  "@justeattakeaway/pie-icons-webc": "1.29.4",
45
45
  "@justeattakeaway/pie-webc-core": "22.0.0"
46
46
  },
package/src/defs.ts CHANGED
@@ -21,95 +21,52 @@ export const types = ['default', 'icon'] as const;
21
21
  export const headingLevels = ['h2', 'h3', 'h4', 'h5', 'h6'] as const;
22
22
  export const triggers = ['hover', 'focus', 'click'] as const;
23
23
 
24
- /**
25
- * The two patterns the panel can present as. Inferred from the `action` slot rather than
26
- * configured: an empty `action` slot is a tooltip, a filled one is a non-modal dialog.
27
- */
24
+ /** The two patterns the panel can present as, inferred from the `action` slot. */
28
25
  export const modes = ['tooltip', 'dialog'] as const;
29
26
 
30
27
  export type TooltipMode = typeof modes[number];
31
28
  export type TooltipTrigger = typeof triggers[number];
32
29
 
33
30
  type AriaProps = {
34
- /**
35
- * The accessible name for the close button. Required whenever `isDismissible` is set.
36
- */
31
+ /** The accessible name for the close button. Required whenever `isDismissible` is set. */
37
32
  close?: string;
38
33
 
39
- /**
40
- * The accessible name for the panel in dialog mode. Only used when `heading` is not provided.
41
- */
34
+ /** The accessible name for the panel in dialog mode, when no `heading` is provided. */
42
35
  label?: string;
43
36
  };
44
37
 
45
38
  export interface TooltipProps {
46
- /**
47
- * The `id` of the element the panel is anchored to. The trigger lives elsewhere in the
48
- * DOM and is never slotted into the tooltip.
49
- */
39
+ /** The `id` of the element the panel is anchored to. */
50
40
  trigger?: string;
51
41
 
52
- /**
53
- * When true, the panel is visible. The component never writes to this property: the
54
- * consumer owns it and updates it in response to the close event.
55
- */
42
+ /** When true, the panel is visible. The component never writes to this property. */
56
43
  isOpen?: boolean;
57
44
 
58
- /**
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.
63
- */
45
+ /** The preferred side and alignment; the panel repositions itself to avoid collisions. */
64
46
  position?: typeof positions[number];
65
47
 
66
- /**
67
- * How the panel sizes itself. `default` is a fixed 280px and wraps, `fit-to-content` is as
68
- * wide as its content, and `fill-container` matches the inline size of the trigger's parent
69
- * element. Not applied when `type` is `icon`.
70
- */
48
+ /** How the panel sizes itself. Not applied when `type` is `icon`. */
71
49
  size?: typeof sizes[number];
72
50
 
73
- /**
74
- * The colour treatment of the panel. `default` is the dark panel, `inverse` the light one.
75
- */
51
+ /** The colour treatment of the panel. `default` is the dark panel, `inverse` the light one. */
76
52
  variant?: typeof variants[number];
77
53
 
78
- /**
79
- * The presentation of the panel. `icon` is the compact treatment intended for icon triggers:
80
- * it has no arrow and is always as wide as its content, so `size` and `--tooltip-width` have
81
- * no effect on it.
82
- */
54
+ /** The presentation of the panel. `icon` is the compact, arrow-less treatment. */
83
55
  type?: typeof types[number];
84
56
 
85
- /**
86
- * When true, a close button is rendered inside the panel.
87
- */
57
+ /** When true, a close button is rendered inside the panel and the panel presents as a non-modal dialog, so it needs `heading` or `aria.label` for its accessible name. */
88
58
  isDismissible?: boolean;
89
59
 
90
- /**
91
- * The text to display in the panel's heading. In dialog mode this also provides the
92
- * panel's accessible name.
93
- */
60
+ /** The text to display in the panel's heading. In dialog mode this also names the panel. */
94
61
  heading?: string;
95
62
 
96
- /**
97
- * The HTML heading tag to use for the panel's heading. Can be h2-h6.
98
- */
63
+ /** The HTML heading tag to use for the panel's heading. Can be h2-h6. */
99
64
  headingLevel?: typeof headingLevels[number];
100
65
 
101
- /**
102
- * The ARIA labels used for various parts of the tooltip.
103
- */
66
+ /** The ARIA labels used for various parts of the tooltip. */
104
67
  aria?: AriaProps;
105
68
 
106
- /**
107
- * Which interactions request that the panel opens and closes. The component never opens or
108
- * closes itself: a configured interaction emits `pie-tooltip-open` or `pie-tooltip-close`
109
- * and the consumer decides whether to honour it by setting `isOpen`. Empty by default, so
110
- * no interaction is watched at all. Configure `hover` and `focus` together for keyboard
111
- * reachability, and `click` for pointer users.
112
- */
69
+ /** Which interactions request that the panel opens and closes. */
113
70
  triggers?: Array<TooltipTrigger>;
114
71
  }
115
72
 
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);
@@ -245,7 +237,7 @@ const getVisibleArea = (boundary: DOMRect, rect: CandidateRect): number => {
245
237
  /**
246
238
  * @tagname pie-tooltip
247
239
  * @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.
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.
249
241
  * @slot content - The descriptive content of the panel. Must not contain focusable elements.
250
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.
251
243
  */
@@ -324,6 +316,12 @@ export class PieTooltip extends PieElement implements TooltipProps {
324
316
 
325
317
  private _openedByClick = false;
326
318
 
319
+ private readonly _instanceId = crypto.randomUUID();
320
+
321
+ private get _headingId (): string {
322
+ return `pie-tooltip-heading-${this._instanceId}`;
323
+ }
324
+
327
325
  static styles = unsafeCSS(styles);
328
326
 
329
327
  private get _mode (): TooltipMode | undefined {
@@ -331,7 +329,7 @@ export class PieTooltip extends PieElement implements TooltipProps {
331
329
  return undefined;
332
330
  }
333
331
 
334
- return this._hasActionContent ? 'dialog' : 'tooltip';
332
+ return (this._hasActionContent || this.isDismissible) ? 'dialog' : 'tooltip';
335
333
  }
336
334
 
337
335
  protected firstUpdated (): void {
@@ -369,9 +367,6 @@ export class PieTooltip extends PieElement implements TooltipProps {
369
367
  this.projectOverTrigger();
370
368
  }
371
369
 
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
370
  if (this.isOpen && changedProperties.has('trigger')) {
376
371
  this.stopTrackingTrigger();
377
372
  }
@@ -393,8 +388,36 @@ export class PieTooltip extends PieElement implements TooltipProps {
393
388
  super.disconnectedCallback();
394
389
  }
395
390
 
396
- // Listens for scroll (capture — catches any ancestor), resize, and dir-attribute changes to
397
- // 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
+
398
421
  private startTrackingTrigger (): void {
399
422
  if (this._triggerTrackingController) {
400
423
  return;
@@ -416,7 +439,6 @@ export class PieTooltip extends PieElement implements TooltipProps {
416
439
  });
417
440
  };
418
441
 
419
- // Flagged rather than resolved immediately so window-drag cannot walk ancestors more than once per frame.
420
442
  const handleResize = () => {
421
443
  this._overlayModeDirty = true;
422
444
  handleViewportChange();
@@ -425,10 +447,6 @@ export class PieTooltip extends PieElement implements TooltipProps {
425
447
  window.addEventListener('scroll', handleViewportChange, { capture: true, passive: true, signal });
426
448
  window.addEventListener('resize', handleResize, { passive: true, signal });
427
449
 
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
450
  const shadowRoots = new Set<ShadowRoot>();
433
451
 
434
452
  flattenedAncestors(this).forEach((ancestor) => {
@@ -443,12 +461,7 @@ export class PieTooltip extends PieElement implements TooltipProps {
443
461
  root.addEventListener('scroll', handleViewportChange, { capture: true, passive: true, signal });
444
462
  });
445
463
 
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
464
  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
465
  this._overlayModeDirty = true;
453
466
  handleViewportChange();
454
467
  });
@@ -488,13 +501,6 @@ export class PieTooltip extends PieElement implements TooltipProps {
488
501
  this._hasActionContent = this._assignedActionElements.length > 0;
489
502
  }
490
503
 
491
- // Picks between `absolute` (browser handles scrolling, but clipped by overflow ancestors) and
492
- // `fixed` (escapes overflow clips, but must re-offset on every scroll). Switches to `fixed`
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.
498
504
  private resolveOverlayMode (): void {
499
505
  if (!this._overlayModeDirty) {
500
506
  return;
@@ -509,16 +515,11 @@ export class PieTooltip extends PieElement implements TooltipProps {
509
515
 
510
516
  const { documentElement, body } = this.ownerDocument;
511
517
 
512
- // Resolved on the same triggers as the overlay mode, because both answers change only
513
- // when the ancestor chain does.
514
518
  this._refreshTriggerClippers();
515
519
 
516
520
  flattenedAncestors(this).forEach((element) => {
517
521
  const styles = getComputedStyle(element);
518
522
 
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
523
  if (styles.display === 'contents') {
523
524
  return;
524
525
  }
@@ -535,14 +536,8 @@ export class PieTooltip extends PieElement implements TooltipProps {
535
536
 
536
537
  const clips = styles.overflowX !== 'visible' || styles.overflowY !== 'visible';
537
538
 
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
539
  const propagatesOverflowToViewport = element === documentElement || element === body;
543
540
 
544
- // Counted after the containing-block flags so an ancestor that is both the containing
545
- // block and the clipper is counted correctly.
546
541
  if (clips && !propagatesOverflowToViewport) {
547
542
  if (isAtOrAboveAbsoluteContainingBlock) {
548
543
  absoluteClippingAncestors.push(element);
@@ -554,18 +549,12 @@ export class PieTooltip extends PieElement implements TooltipProps {
554
549
  }
555
550
  });
556
551
 
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
552
  const useFixed = fixedClippingAncestors.length < absoluteClippingAncestors.length;
561
553
 
562
554
  this.style.position = useFixed ? 'fixed' : '';
563
555
  this._overlayClippers = useFixed ? fixedClippingAncestors : absoluteClippingAncestors;
564
556
  }
565
557
 
566
- // Measures the trigger relative to the origin marker (which sits at the containing block's
567
- // origin) and writes CSS custom properties. Self-referential: correct for any containing
568
- // block. Physical values; shadow-root CSS uses logical properties for RTL mirroring.
569
558
  private projectOverTrigger (): void {
570
559
  this._isPositioned = false;
571
560
 
@@ -586,16 +575,11 @@ export class PieTooltip extends PieElement implements TooltipProps {
586
575
  this._refreshTriggerClippers();
587
576
  }
588
577
 
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
578
  const visibleRect = this._triggerClippers.reduce<DOMRect | null>(
594
579
  (rect, clipper) => (rect ? intersectRects(rect, getClipRect(clipper)) : null),
595
580
  triggerElement.getBoundingClientRect(),
596
581
  );
597
582
 
598
- // Nothing of the trigger is left to point at, so there is nothing to describe either.
599
583
  this._isAnchorVisible = visibleRect !== null;
600
584
 
601
585
  if (!visibleRect) {
@@ -734,9 +718,6 @@ export class PieTooltip extends PieElement implements TooltipProps {
734
718
  });
735
719
  }
736
720
 
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
721
  private _refreshTriggerClippers (): void {
741
722
  const triggerElement = this._getTriggerElement();
742
723
 
@@ -788,20 +769,20 @@ export class PieTooltip extends PieElement implements TooltipProps {
788
769
  private _rebuildInteractionListeners (): void {
789
770
  this._teardownInteractionListeners();
790
771
 
791
- if (!this.triggers?.length) return;
792
-
793
772
  const controller = new AbortController();
794
773
  const { signal } = controller;
795
774
  this._interactionController = controller;
796
775
 
797
- const triggerEl = this._getTriggerElement();
798
-
799
776
  this.ownerDocument.addEventListener('keydown', (e: KeyboardEvent) => {
800
777
  if (e.key === 'Escape' && this.isOpen) {
801
778
  this._requestClose();
802
779
  }
803
780
  }, { signal });
804
781
 
782
+ if (!this.triggers?.length) return;
783
+
784
+ const triggerEl = this._getTriggerElement();
785
+
805
786
  if (!triggerEl) return;
806
787
 
807
788
  if (this.triggers.includes('hover')) {
@@ -814,7 +795,6 @@ export class PieTooltip extends PieElement implements TooltipProps {
814
795
  this._startHoverCloseTimer();
815
796
  }, { signal });
816
797
 
817
- // panel mouseenter/leave for the hover bridge (includes the bridge pseudo-element)
818
798
  const panel = this.renderRoot.querySelector('.c-tooltip');
819
799
  if (panel) {
820
800
  panel.addEventListener('mouseenter', () => {
@@ -828,14 +808,12 @@ export class PieTooltip extends PieElement implements TooltipProps {
828
808
  }
829
809
 
830
810
  if (this.triggers.includes('focus')) {
831
- // open on focus, close on blur unless focus moved into panel action content
832
811
  triggerEl.addEventListener('focusin', () => {
833
812
  this._requestOpen();
834
813
  }, { signal });
835
814
 
836
815
  triggerEl.addEventListener('focusout', (e: Event) => {
837
816
  const related = (e as FocusEvent).relatedTarget as Node | null;
838
- // relatedTarget is retargeted to the shadow host when focus moves into shadow DOM
839
817
  const staysInside = related && (this.contains(related) || related === this);
840
818
  if (!staysInside) {
841
819
  this._requestClose();
@@ -848,7 +826,6 @@ export class PieTooltip extends PieElement implements TooltipProps {
848
826
  e.stopPropagation();
849
827
 
850
828
  if (!this.isOpen) {
851
- // Only click-opens toggle
852
829
  this._openedByClick = true;
853
830
  this._requestOpen();
854
831
 
@@ -864,7 +841,6 @@ export class PieTooltip extends PieElement implements TooltipProps {
864
841
  this._requestClose();
865
842
  }, { signal });
866
843
 
867
- // Light-dismiss: click anywhere outside the panel and trigger
868
844
  this.ownerDocument.addEventListener('click', (e: Event) => {
869
845
  const target = e.composedPath()[0] as Node;
870
846
  const isInsidePanel = this.contains(target) || this.shadowRoot?.contains(target);
@@ -886,9 +862,9 @@ export class PieTooltip extends PieElement implements TooltipProps {
886
862
  const tag = unsafeStatic(this.headingLevel);
887
863
 
888
864
  return html`<${tag}
889
- id="${headingId}"
865
+ id="${this._headingId}"
890
866
  class="${componentClass}-heading"
891
- data-test-id="${headingId}">${this.heading}</${tag}>`;
867
+ data-test-id="${componentSelector}-heading">${this.heading}</${tag}>`;
892
868
  }
893
869
 
894
870
  private renderCloseButton (): TemplateResult {
@@ -937,7 +913,7 @@ export class PieTooltip extends PieElement implements TooltipProps {
937
913
  [`${componentClass}--type-${type}`]: true,
938
914
  [`${componentClass}--size-${size}`]: !isIconType,
939
915
  'is-dismissible': !!isDismissible,
940
- 'has-action': mode === 'dialog',
916
+ 'has-action': this._hasActionContent === true,
941
917
  'has-heading': !!heading,
942
918
  };
943
919
 
@@ -952,12 +928,15 @@ export class PieTooltip extends PieElement implements TooltipProps {
952
928
  data-test-id="${componentSelector}"
953
929
  role="${ifDefined(mode)}"
954
930
  aria-hidden="${!isOpen}"
955
- aria-labelledby="${isDialog && heading ? headingId : nothing}"
931
+ aria-labelledby="${isDialog && heading ? this._headingId : nothing}"
956
932
  aria-label="${isDialog && !heading && aria?.label ? aria.label : nothing}">
957
933
  ${isIconType ? nothing : html`<div class="${componentClass}-arrow" data-test-id="${componentSelector}-arrow"></div>`}
958
934
  <div class="${componentClass}-body">
959
935
  ${heading ? this.renderHeading() : nothing}
960
- <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">
961
940
  <slot name="content"></slot>
962
941
  </div>
963
942
  </div>
package/src/react.ts CHANGED
@@ -12,7 +12,7 @@ const PieTooltipReact = createComponent({
12
12
  tagName: 'pie-tooltip',
13
13
  events: {
14
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
+ onPieTooltipClose: 'pie-tooltip-close' as EventName<Event>, // When the close button is clicked, Escape is pressed, or a configured trigger asks to dismiss the panel. Set `isOpen` to `false` in response.
16
16
  },
17
17
  });
18
18
 
package/src/tooltip.scss CHANGED
@@ -1,6 +1,6 @@
1
1
  @use '@justeattakeaway/pie-css/scss' as p;
2
2
 
3
- // All twelve position values are generated explicitly — none inferred.
3
+ // All twelve position values are generated explicitly, none inferred.
4
4
  $tooltip-alignments: ('', '-start', '-end');
5
5
 
6
6
  // Zero-size, pinned to the containing block's origin. Trigger position is measured relative
@@ -343,6 +343,13 @@ $tooltip-alignments: ('', '-start', '-end');
343
343
  overflow-wrap: break-word;
344
344
  }
345
345
 
346
+ // The content is the programmatic focus target in dialog mode (see `focusPanel`). It is never
347
+ // reached by Tab, which moves between the panel's own controls, so it draws no focus ring of
348
+ // its own: the controls inside the panel carry their own focus indicators.
349
+ .c-tooltip-content:focus {
350
+ outline: none;
351
+ }
352
+
346
353
  .c-tooltip.has-action .c-tooltip-action {
347
354
  display: flex;
348
355
  justify-content: flex-end;