@eifi1/ui-kit 0.24.1 → 0.25.1

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.
Files changed (148) hide show
  1. package/README.md +6 -6
  2. package/dist/chart.d.ts +2 -2
  3. package/dist/components/account-chips.d.ts +1 -1
  4. package/dist/components/amount-input.d.ts +2 -2
  5. package/dist/components/button-group.d.ts +2 -2
  6. package/dist/components/calculator.d.ts +2 -2
  7. package/dist/components/column-mapper.d.ts +28 -9
  8. package/dist/components/column-mapper.js +63 -77
  9. package/dist/components/column-mapper.js.map +1 -1
  10. package/dist/components/confirm-dialog.d.ts +2 -2
  11. package/dist/components/copy-button.d.ts +2 -2
  12. package/dist/components/danger-confirm.d.ts +2 -2
  13. package/dist/components/data-table-cells.d.ts +1 -1
  14. package/dist/components/data-table-filter-popover.d.ts +1 -1
  15. package/dist/components/data-table-filters.d.ts +1 -1
  16. package/dist/components/data-table-labels.d.ts +1 -1
  17. package/dist/components/data-table-pagination.d.ts +1 -1
  18. package/dist/components/data-table-pagination.js +6 -1
  19. package/dist/components/data-table-pagination.js.map +1 -1
  20. package/dist/components/data-table.d.ts +1 -1
  21. package/dist/components/data-table.js +7 -1
  22. package/dist/components/data-table.js.map +1 -1
  23. package/dist/components/facing-pair.d.ts +2 -2
  24. package/dist/components/file-button.d.ts +2 -2
  25. package/dist/components/file-dropzone.d.ts +2 -2
  26. package/dist/components/form-actions.d.ts +2 -2
  27. package/dist/components/iban-input.d.ts +2 -2
  28. package/dist/components/language-select.d.ts +2 -2
  29. package/dist/components/money-field.d.ts +2 -2
  30. package/dist/components/number-field.d.ts +2 -2
  31. package/dist/components/number-input.d.ts +2 -2
  32. package/dist/components/numpad-sheet.d.ts +2 -2
  33. package/dist/components/phone-input.d.ts +2 -2
  34. package/dist/components/series-chart.d.ts +2 -2
  35. package/dist/components/settings-fields.d.ts +2 -2
  36. package/dist/components/share-card.d.ts +2 -2
  37. package/dist/components/swipeable-row.js +1 -1
  38. package/dist/components/swipeable-row.js.map +1 -1
  39. package/dist/components/table.d.ts +35 -1
  40. package/dist/components/table.js +4 -0
  41. package/dist/components/table.js.map +1 -1
  42. package/dist/components/text-link.d.ts +2 -2
  43. package/dist/components/time-input.d.ts +2 -2
  44. package/dist/components/tooltip.d.ts +103 -14
  45. package/dist/components/tooltip.js +194 -57
  46. package/dist/components/tooltip.js.map +1 -1
  47. package/dist/components/translation-review-editor.d.ts +9 -1
  48. package/dist/components/translation-review-editor.js +11 -1
  49. package/dist/components/translation-review-editor.js.map +1 -1
  50. package/dist/components/translation-review-labels.d.ts +10 -0
  51. package/dist/components/translation-review-labels.js +5 -1
  52. package/dist/components/translation-review-labels.js.map +1 -1
  53. package/dist/components/translation-review.d.ts +145 -8
  54. package/dist/components/translation-review.js +372 -91
  55. package/dist/components/translation-review.js.map +1 -1
  56. package/dist/components/ui.d.ts +2 -2
  57. package/dist/components/ui.js +55 -18
  58. package/dist/components/ui.js.map +1 -1
  59. package/dist/components/use-table-state.d.ts +1 -1
  60. package/dist/{data-table-labels-B7OdnM0S.d.ts → data-table-labels-Cdn1QS2k.d.ts} +21 -1
  61. package/dist/data-table.d.ts +1 -1
  62. package/dist/feedback/feedback-attachment.d.ts +2 -2
  63. package/dist/feedback/feedback-dialog.d.ts +2 -2
  64. package/dist/feedback/feedback-dialog.js +6 -1
  65. package/dist/feedback/feedback-dialog.js.map +1 -1
  66. package/dist/feedback/feedback-inbox.d.ts +2 -2
  67. package/dist/feedback/feedback-inbox.js +2 -0
  68. package/dist/feedback/feedback-inbox.js.map +1 -1
  69. package/dist/feedback/feedback-thread.d.ts +2 -2
  70. package/dist/{feedback-DOwPu-Il.d.ts → feedback-BXKH-ToU.d.ts} +32 -3
  71. package/dist/feedback.d.ts +2 -2
  72. package/dist/hooks/use-body-scroll-lock.js +16 -0
  73. package/dist/hooks/use-body-scroll-lock.js.map +1 -1
  74. package/dist/hooks/use-file-drop.d.ts +2 -2
  75. package/dist/hooks/use-search-param-state.d.ts +58 -3
  76. package/dist/hooks/use-search-param-state.js +107 -35
  77. package/dist/hooks/use-search-param-state.js.map +1 -1
  78. package/dist/i18n/defaults.d.ts +2 -2
  79. package/dist/i18n/german.d.ts +2 -2
  80. package/dist/i18n/german.js +8 -1
  81. package/dist/i18n/german.js.map +1 -1
  82. package/dist/i18n/kit-labels.d.ts +2 -2
  83. package/dist/i18n/languages.d.ts +2 -2
  84. package/dist/i18n/locales/de-CH.d.ts +2 -2
  85. package/dist/i18n/locales/en.d.ts +2 -2
  86. package/dist/i18n/locales/en.js +5 -1
  87. package/dist/i18n/locales/en.js.map +1 -1
  88. package/dist/i18n/locales/es.d.ts +2 -2
  89. package/dist/i18n/locales/es.js +7 -1
  90. package/dist/i18n/locales/es.js.map +1 -1
  91. package/dist/i18n/locales/fr.d.ts +2 -2
  92. package/dist/i18n/locales/fr.js +6 -1
  93. package/dist/i18n/locales/fr.js.map +1 -1
  94. package/dist/i18n/locales/hu.d.ts +2 -2
  95. package/dist/i18n/locales/hu.js +9 -1
  96. package/dist/i18n/locales/hu.js.map +1 -1
  97. package/dist/i18n/locales/it.d.ts +2 -2
  98. package/dist/i18n/locales/it.js +6 -1
  99. package/dist/i18n/locales/it.js.map +1 -1
  100. package/dist/i18n/locales/zh.d.ts +2 -2
  101. package/dist/i18n/locales/zh.js +6 -1
  102. package/dist/i18n/locales/zh.js.map +1 -1
  103. package/dist/i18n/review.d.ts +2 -2
  104. package/dist/i18n/review.js +11 -0
  105. package/dist/i18n/review.js.map +1 -1
  106. package/dist/index.d.ts +6 -6
  107. package/dist/index.js +9 -1
  108. package/dist/index.js.map +1 -1
  109. package/dist/lib/strip-fade.d.ts +84 -5
  110. package/dist/lib/strip-fade.js +109 -11
  111. package/dist/lib/strip-fade.js.map +1 -1
  112. package/dist/lib/translation-review.d.ts +45 -1
  113. package/dist/lib/translation-review.js +38 -1
  114. package/dist/lib/translation-review.js.map +1 -1
  115. package/dist/rhf/fields.d.ts +2 -2
  116. package/dist/rhf/form.d.ts +2 -2
  117. package/dist/rhf.d.ts +2 -2
  118. package/dist/shell/app-shell.d.ts +2 -2
  119. package/dist/shell/top-bar-brand.d.ts +2 -2
  120. package/dist/shell.d.ts +2 -2
  121. package/dist/wizard/stepper-nav.d.ts +2 -2
  122. package/dist/wizard.d.ts +2 -2
  123. package/package.json +1 -1
  124. package/src/components/column-mapper.tsx +120 -113
  125. package/src/components/data-table-pagination.tsx +10 -1
  126. package/src/components/data-table.tsx +34 -1
  127. package/src/components/swipeable-row.tsx +1 -1
  128. package/src/components/table.tsx +39 -0
  129. package/src/components/tooltip.tsx +487 -104
  130. package/src/components/translation-review-editor.tsx +21 -1
  131. package/src/components/translation-review-labels.ts +17 -0
  132. package/src/components/translation-review.tsx +657 -109
  133. package/src/components/ui.tsx +104 -4
  134. package/src/feedback/feedback-dialog.tsx +21 -0
  135. package/src/feedback/feedback-inbox.tsx +19 -2
  136. package/src/hooks/use-body-scroll-lock.ts +25 -0
  137. package/src/hooks/use-search-param-state.ts +283 -41
  138. package/src/i18n/german.ts +10 -0
  139. package/src/i18n/locales/en.ts +7 -0
  140. package/src/i18n/locales/es.ts +9 -0
  141. package/src/i18n/locales/fr.ts +9 -0
  142. package/src/i18n/locales/hu.ts +11 -0
  143. package/src/i18n/locales/it.ts +9 -0
  144. package/src/i18n/locales/zh.ts +8 -0
  145. package/src/i18n/review.ts +11 -0
  146. package/src/index.ts +12 -0
  147. package/src/lib/strip-fade.ts +220 -15
  148. package/src/lib/translation-review.ts +85 -0
@@ -1,18 +1,22 @@
1
1
  import {
2
2
  cloneElement,
3
3
  isValidElement,
4
+ useEffect,
4
5
  useId,
5
6
  useLayoutEffect,
6
7
  useRef,
7
8
  useState,
8
9
  type ComponentPropsWithoutRef,
10
+ type FocusEvent,
11
+ type MouseEvent,
12
+ type PointerEvent,
9
13
  type ReactElement,
10
14
  type ReactNode,
11
15
  type RefObject,
12
16
  } from "react";
13
17
  import { createPortal } from "react-dom";
14
18
  import { cn } from "../lib/cn";
15
- import { useEscapeKey } from "../hooks/use-dismiss";
19
+ import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss";
16
20
  import { useAnchoredRect, type AnchorRect } from "../hooks/use-anchored-rect";
17
21
  import { dirOf, type Direction } from "../lib/direction";
18
22
  import { hasClippingAncestor } from "../lib/clipping";
@@ -30,6 +34,18 @@ export { CLIPS_ATTRIBUTE } from "../lib/clipping";
30
34
  * mirror (a chart axis, a map). */
31
35
  export type TooltipSide = "top" | "bottom" | "left" | "right" | "start" | "end";
32
36
 
37
+ /** What a TAP — a touch or a pen press, never the mouse — on the trigger does to the
38
+ * bubble. See "A tap is not a hover" on {@link Tooltip}.
39
+ *
40
+ * - `"auto"` (the default): a tap that activates something shows nothing; a tap that
41
+ * activates nothing — on a disabled or `aria-disabled` control (a write lock's
42
+ * reason), or on a trigger with no control at all (a truncated name, a badge) —
43
+ * toggles the bubble, since showing it is then the tap's only answer.
44
+ * - `"toggle"`: every tap toggles it — for a control that exists only to explain, such
45
+ * as a "?" whose click does nothing.
46
+ * - `"ignore"`: a tap never shows it. */
47
+ export type TooltipTap = "auto" | "toggle" | "ignore";
48
+
33
49
  /** The placement the maths works in: a logical side resolved against the trigger. */
34
50
  type PhysicalSide = "top" | "bottom" | "left" | "right";
35
51
 
@@ -105,20 +121,30 @@ export interface TooltipProps extends ComponentPropsWithoutRef<"span"> {
105
121
  lazy?: boolean;
106
122
  /** Tag the bubble `data-private`, for a label that repeats the user's own data. */
107
123
  redact?: boolean;
124
+ /**
125
+ * What a tap (touch or pen) on the trigger does — see {@link TooltipTap} and "A tap is
126
+ * not a hover" below. Default `"auto"`: a tap that activates the control shows nothing;
127
+ * one that activates nothing (a disabled control, plain text) toggles the bubble.
128
+ */
129
+ tap?: TooltipTap;
108
130
  children: ReactNode;
109
131
  }
110
132
 
111
- /** What each variant below takes: the resolved `side`, and every span attribute the
112
- * caller handed {@link Tooltip}, forwarded to that variant's own wrapper. */
113
- type TooltipVariantProps = Omit<TooltipProps, "side" | "portal" | "lazy"> & { side: TooltipSide };
133
+ /** What each variant below takes: the resolved `side` and `tap`, and every span attribute
134
+ * the caller handed {@link Tooltip}, forwarded to that variant's own wrapper. */
135
+ type TooltipVariantProps = Omit<TooltipProps, "side" | "portal" | "lazy" | "tap"> & {
136
+ side: TooltipSide;
137
+ tap: TooltipTap;
138
+ };
114
139
 
115
140
  /**
116
141
  * Hover/focus label for a control.
117
142
  *
118
143
  * Two placements, and the choice matters more than it looks. In place, the bubble is
119
- * always mounted next to the trigger and fades in on `:hover`, which costs no state and
120
- * works in a plain render test. Portalled, the bubble is mounted in `document.body`
121
- * only while it is up, positioned by measurement.
144
+ * always mounted next to the trigger — so a plain render test finds it without a hover —
145
+ * and shown while it is up (`display: none` otherwise, see "never widens the page"
146
+ * below). Portalled, the bubble is mounted in `document.body` only while it is up,
147
+ * positioned by measurement.
122
148
  *
123
149
  * ⚠️ **A bubble that repeats a value has to be redactable.** The consuming app blurs
124
150
  * `[data-private]` under a `demo-mode` class on `<html>` — and the portalled bubble is
@@ -237,8 +263,8 @@ type TooltipVariantProps = Omit<TooltipProps, "side" | "portal" | "lazy"> & { si
237
263
  * So now, when an in-place bubble goes up (default or `lazy`), a layout effect measures
238
264
  * it and, if it crosses the viewport edge minus the same margin the portalled bubble
239
265
  * keeps, slides it back along its CROSS axis only — sideways for `top` / `bottom`, up or
240
- * down for the four side placements — with an inline `transform`, which composes with the
241
- * placement classes' own `translate`. The main axis is left alone on purpose: sliding a
266
+ * down for the four side placements — with an inline margin (a `transform` until 0.25; see
267
+ * {@link shiftStyle} for why that widened the page). The main axis is left alone on purpose: sliding a
242
268
  * `start` bubble along the main axis would slide it over its own trigger, and turning it
243
269
  * round is a measured-placement decision this CSS-placed bubble does not make (pass
244
270
  * `portal` for that). The width is already capped to the viewport (see
@@ -247,13 +273,84 @@ type TooltipVariantProps = Omit<TooltipProps, "side" | "portal" | "lazy"> & { si
247
273
  *
248
274
  * Why on by default, with no prop? Because it is a no-op for every bubble that already
249
275
  * fits — the shift is zero unless the bubble would overflow — so the only placements it
250
- * changes are ones that were broken. The maths is in physical viewport pixels, so it
251
- * needs no `dir`: an RTL row puts its long label at the RIGHT edge and gets slid left by
252
- * the same code. Under jsdom there is no layout — every rect is zero-sized — and a
253
- * zero-sized bubble is taken to be unmeasured, so tests see no transform at all. It is
254
- * measured on open (and when the label or side changes while open), not on every scroll:
255
- * an in-place bubble follows its trigger for free, and a page that scrolls sideways under
256
- * an open tooltip is not a case worth a listener per tooltip.
276
+ * changes are ones that were broken. The maths is in physical viewport pixels: an RTL
277
+ * row puts its long label at the RIGHT edge and gets slid left by the same code, and the
278
+ * reading direction only decides which edge is kept when a bubble is wider than the room
279
+ * (its start, as {@link placeTooltip} keeps). Under jsdom there is no layout — every rect
280
+ * is zero-sized — and a zero-sized bubble is taken to be unmeasured, so tests see no
281
+ * transform at all. It is measured on open (and when the label or side changes while
282
+ * open), not on every scroll: an in-place bubble follows its trigger for free, and a page
283
+ * that scrolls sideways under an open tooltip is not a case worth a listener per tooltip.
284
+ *
285
+ * ⚠️ **An in-place bubble never widens the page (keksdose run 72, live #381).** Two
286
+ * holes let it, and a page wider than the screen is worse than a bubble cut off: on a
287
+ * phone Chrome then lays every `position: fixed` layer out against the WIDER page while
288
+ * the glass still shows the old width. Measured on a 406px phone: the sync icon at x=254,
289
+ * its `bottom` bubble at 102–422, the page 426px wide, and a FloatingPanel 17px off the
290
+ * glass. First, the clamp compared against `window.innerWidth` — and on a phone that is
291
+ * the layout viewport, which GROWS to the page's width once something sticks out (430 on
292
+ * a 406px screen, measured in Chromium's mobile emulation), so a bubble already past the
293
+ * edge found itself "inside" and stayed there. It now measures against the root's client
294
+ * width, which on a phone is the screen (the initial containing block, the width `100vw`
295
+ * and media queries see) whatever overflows, and on a desktop also leaves out a classic
296
+ * scrollbar; the window is the fallback where there is no layout (jsdom). The portalled
297
+ * bubble takes the same width, since a `fixed` bubble placed against a grown layout
298
+ * viewport is off the glass for the same reason. Second, the always-mounted bubble was
299
+ * `opacity: 0` while closed — invisible, but still laid out where the CSS puts it, so a
300
+ * bubble nobody opened, centred on a trigger near the end edge, widened the page all by
301
+ * itself (dev#488's phantom scroll, at the scale of the page instead of a scroller, where
302
+ * the auto-portal cannot help: the page is not an ancestor it can portal out of). It is
303
+ * now `display: none` until it is up, so a closed bubble takes no room at all, and an open
304
+ * one is slid onto the glass in the same layout pass that shows it, before paint. jsdom
305
+ * computes no Tailwind, so tests still find the closed bubble without a hover.
306
+ *
307
+ * ⚠️ **A tap is not a hover (keksdose run 72, live #379).** The bubble used to show on
308
+ * `mouseenter` and on `focus`, and hide only on `mouseleave` and `blur`. A tap on a phone
309
+ * fires an emulated `mouseenter` and, on a button, focuses it — and the virtual mouse and
310
+ * the focus both stay on the button until the next tap somewhere else, so every
311
+ * IconButton's label (the top-bar icons, the sync indicator, a row's actions) stayed up
312
+ * over whatever the tap had just opened. Now a touch or pen press is told apart from the
313
+ * mouse: the emulated `mouseenter` that follows a tap is ignored, the press itself closes
314
+ * a bubble that was up, and a focus that arrives while the page's last input was a finger
315
+ * or a pen — the tap's own, or one a closing dialog RETURNS to the icon it was opened
316
+ * from — shows nothing. The next key press (a Bluetooth keyboard on a tablet) or mouse
317
+ * press makes focus count again, and a mouse entering the trigger hovers as ever, so a
318
+ * hybrid laptop gets both.
319
+ *
320
+ * Focus otherwise shows the bubble, as it always did — the keyboard, a script, a test's
321
+ * `.focus()` — with one more exception: the focus a mouse press on the trigger gives it.
322
+ * There it follows `:focus-visible`, so a text field still shows its bubble while you type
323
+ * and a clicked button does not hold it: a desktop click behaves as it always did while
324
+ * the pointer is over the button (the hover shows it) and lets it go when the pointer
325
+ * leaves, where the in-place bubble used to linger until the button lost focus — the same
326
+ * left-behind bubble on a smaller scale, and what the portalled bubble always did. A
327
+ * keyboard user's bubble, in turn, now stays up when a mouse passes over and leaves (the
328
+ * portalled one used to close). Any `pointerdown` outside the trigger closes an open
329
+ * bubble too — the Safari case, where a tap elsewhere moves no focus.
330
+ *
331
+ * What a tap DOES show is {@link TooltipTap} (`tap`). By default a tap that activates
332
+ * something shows nothing — the action is the answer — while a tap that activates nothing
333
+ * toggles the bubble, because there the bubble is the only answer: a write-locked or
334
+ * disabled control, whose bubble is the reason it did not respond, and a trigger with no
335
+ * control at all, a truncated payee or a badge, whose bubble is the rest of the text. A
336
+ * second tap, a tap anywhere else, Escape or the focus leaving closes it. "Activates" is
337
+ * read off the DOM — a link, a button, a field, a label, an element with a widget role
338
+ * (`button`, `link`, `checkbox`, `option`, …), or a DataTable row (`tr[tabindex]`) — from
339
+ * the tapped element outwards, past the tooltip, so a glyph inside a clickable row or a
340
+ * button counts as that row or button. What it cannot read is a click handler on an
341
+ * element with no role (give it `role="button"`, which it needs anyway, or pass
342
+ * `tap="ignore"`), and a button that exists only to explain, whose click does nothing —
343
+ * FieldHint's "?" — which says so with `tap="toggle"`.
344
+ *
345
+ * Why not show the label on a long press, as Android does for its own icons? Because the
346
+ * web gives no reliable long press on a control: browsers disagree on whether the release
347
+ * that ends one still clicks the button, and the button's own long press — the context
348
+ * menu, text selection, a row's drag — claims the gesture first on others. A gesture meant
349
+ * to ask "what does this do" must not risk doing it. Screen readers do not need it (the
350
+ * bubble is in `aria-describedby`, and an IconButton's label is its name), and a tap on
351
+ * anything that does nothing already shows it. No scroll listener either: every touch scroll begins with a `pointerdown`, which
352
+ * already closes the bubble, and closing on scroll would close a keyboard user's bubble
353
+ * the moment Tab scrolled its control into view.
257
354
  */
258
355
  export function Tooltip({
259
356
  label,
@@ -262,6 +359,7 @@ export function Tooltip({
262
359
  portal,
263
360
  lazy = false,
264
361
  redact = false,
362
+ tap = "auto",
265
363
  children,
266
364
  ...rest
267
365
  }: TooltipProps) {
@@ -277,6 +375,7 @@ export function Tooltip({
277
375
  side={side}
278
376
  className={className}
279
377
  redact={redact}
378
+ tap={tap}
280
379
  {...rest}
281
380
  >
282
381
  {children}
@@ -294,6 +393,7 @@ export function Tooltip({
294
393
  redact={redact}
295
394
  detect={portal === undefined}
296
395
  lazy={lazy}
396
+ tap={tap}
297
397
  {...rest}
298
398
  >
299
399
  {children}
@@ -302,13 +402,241 @@ export function Tooltip({
302
402
  }
303
403
 
304
404
 
405
+ /** The pointer handlers a caller may hand {@link Tooltip} that the trigger also needs:
406
+ * both run, the trigger's first. (The mouse and focus handlers are the trigger's alone,
407
+ * as they have always been.) */
408
+ type PointerHandlers = Pick<
409
+ ComponentPropsWithoutRef<"span">,
410
+ "onPointerEnter" | "onPointerDown" | "onPointerUp" | "onPointerCancel"
411
+ >;
412
+
413
+ /** A press that is not the mouse's: a finger, or a pen on the glass. */
414
+ function isTap(pointerType: string): boolean {
415
+ return pointerType === "touch" || pointerType === "pen";
416
+ }
417
+
418
+ /**
419
+ * What a tap ACTIVATES, for `tap="auto"`: the tapped element or the nearest ancestor —
420
+ * inside the tooltip or round it — that a tap would do something with. The DataTable's
421
+ * own list of row-owned controls (`OWN_CONTROL`), plus `tr[tabindex]` — a clickable
422
+ * DataTable row is a roving tab stop, so all but one of its rows say `-1` — and the other
423
+ * widget roles a tap selects or toggles.
424
+ *
425
+ * Two things on the DataTable's list are left off on purpose. A bare `tabindex`: a roving
426
+ * tab stop is `0` on one element of a widget and `-1` on the rest, so it would make one
427
+ * cell of a display-only grid "activate" and its neighbours not — and dialogs, panels and
428
+ * `<main>` carry `-1` only to be focusable by script. And `gridcell` / `row`: the
429
+ * CalendarHeatmap's days are display-only grid cells whose tooltip is the day's value,
430
+ * and a tap is the only way to read it on a phone; a heatmap day that does select is a
431
+ * `<button>`, caught above.
432
+ */
433
+ const ACTIVATES = [
434
+ "a[href]",
435
+ "button",
436
+ "input",
437
+ "select",
438
+ "textarea",
439
+ "label",
440
+ "summary",
441
+ '[contenteditable=""]',
442
+ '[contenteditable="true"]',
443
+ "tr[tabindex]",
444
+ ...[
445
+ "button",
446
+ "link",
447
+ "checkbox",
448
+ "radio",
449
+ "switch",
450
+ "tab",
451
+ "menuitem",
452
+ "menuitemcheckbox",
453
+ "menuitemradio",
454
+ "option",
455
+ "treeitem",
456
+ "slider",
457
+ "spinbutton",
458
+ "combobox",
459
+ ].map((role) => `[role="${role}"]`),
460
+ ].join(",");
461
+
462
+ /** Whether a tap on `target` should toggle the bubble — see {@link TooltipTap}. Under
463
+ * `auto`: when the tap activates nothing, either because there is no control under it
464
+ * or because the control is disabled (natively, or `aria-disabled` — a write lock). */
465
+ function tapShows(tap: TooltipTap, target: EventTarget | null): boolean {
466
+ if (tap !== "auto") return tap === "toggle";
467
+ if (!(target instanceof Element)) return true;
468
+ const control = target.closest(ACTIVATES);
469
+ return control === null || control.matches(":disabled") || control.closest('[aria-disabled="true"]') !== null;
470
+ }
471
+
472
+ /**
473
+ * The last kind of input the document saw: a `pointerdown`'s `pointerType` ("mouse",
474
+ * "touch", "pen"), "keyboard" after a key press, `null` before either. ONE pair of
475
+ * capturing listeners for the whole page, installed by the first trigger that mounts —
476
+ * not a pair per tooltip, which a table of forty would multiply.
477
+ *
478
+ * Why a page-wide note and not only the trigger's own: the focus that brings a label back
479
+ * after a tap is not always the tap's. A tap on a top-bar icon opens a dialog; closing it
480
+ * (another tap) RETURNS the focus to the icon by script, and that focus arrives with no
481
+ * press on the icon at all. On a phone it would put the label up over the page the dialog
482
+ * just left, and keep it there. Keys an on-screen keyboard sends while typing
483
+ * (`Unidentified`, a composition) and shortcut chords do not count as the keyboard.
484
+ */
485
+ let lastInput: string | null = null;
486
+ let trackingInput = false;
487
+
488
+ function trackInput(): void {
489
+ if (trackingInput || typeof document === "undefined") return;
490
+ trackingInput = true;
491
+ document.addEventListener(
492
+ "pointerdown",
493
+ (e) => {
494
+ lastInput = e.pointerType;
495
+ },
496
+ { capture: true, passive: true },
497
+ );
498
+ document.addEventListener(
499
+ "keydown",
500
+ (e) => {
501
+ if (e.key === "Unidentified" || e.isComposing || e.ctrlKey || e.metaKey || e.altKey) return;
502
+ lastInput = "keyboard";
503
+ },
504
+ { capture: true, passive: true },
505
+ );
506
+ }
507
+
508
+ /**
509
+ * Whether a focus should show the bubble. Not after a tap — the last input was a finger
510
+ * or a pen, whoever moved the focus. Not after a mouse press on this very trigger either,
511
+ * unless the browser would draw a focus ring there (`:focus-visible`: a text field, not a
512
+ * button) — the hover already shows it while the pointer is there. Every other focus
513
+ * shows it as every focus did before: the keyboard, a script, and a test's `.focus()` or
514
+ * `fireEvent.focus` (jsdom's own `:focus-visible` guesses from whatever events the test
515
+ * file fired before, so it is consulted only where a press makes the answer certain).
516
+ */
517
+ function focusShows(target: EventTarget, pressedByMouse: boolean): boolean {
518
+ if (lastInput !== null && isTap(lastInput)) return false;
519
+ if (!pressedByMouse || !(target instanceof Element)) return true;
520
+ try {
521
+ return target.matches(":focus-visible");
522
+ } catch {
523
+ return true;
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Whether the bubble is up, from what the trigger hears — shared by both variants so the
529
+ * in-place and the portalled bubble cannot disagree about it. See "A tap is not a hover"
530
+ * on {@link Tooltip} for the rules; this is their bookkeeping.
531
+ *
532
+ * Three ways up — hovered by a mouse (or a hovering pen), focused (see {@link focusShows}),
533
+ * and tapped (`tap`) — and Escape over all three (`dismissed`), re-armed by the next
534
+ * request rather than by an effect watching the flags: coming back to a trigger is a
535
+ * fresh request for its label, and an effect would also re-show the bubble under a
536
+ * pointer that never left. `onArm` runs with each request, for the variant's own per-open
537
+ * work (the reading direction, the clipping check).
538
+ *
539
+ * The refs change nothing on screen, only how the next event is read. `pressedByTouch`
540
+ * is set by a touch or pen arriving or pressing — which precedes the emulated
541
+ * `mouseenter` a tap produces (pointer events first, then the compatibility mouse
542
+ * events) — and cleared by a mouse, or a pen that is not pressing, entering again, so a
543
+ * hybrid laptop's mouse still hovers. `pressedByMouse` spans one mouse press, the window
544
+ * in which that press's own focus arrives.
545
+ */
546
+ function useTooltipTrigger(
547
+ triggerRef: RefObject<HTMLSpanElement | null>,
548
+ tap: TooltipTap,
549
+ passed: PointerHandlers,
550
+ onArm: (el: HTMLElement) => void,
551
+ ) {
552
+ const [hovered, setHovered] = useState(false);
553
+ const [focused, setFocused] = useState(false);
554
+ const [tapped, setTapped] = useState(false);
555
+ const [dismissed, setDismissed] = useState(false);
556
+ const pressedByTouch = useRef(false);
557
+ const pressedByMouse = useRef(false);
558
+ // The tap in progress, and whether a bubble was up when it began: a tap on an open
559
+ // bubble closes it (at pointerdown) and must not open it again at pointerup.
560
+ const press = useRef<{ wasOpen: boolean } | null>(null);
561
+ useEffect(trackInput, []);
562
+ const open = (hovered || focused || tapped) && !dismissed;
563
+ const close = () => {
564
+ setHovered(false);
565
+ setFocused(false);
566
+ setTapped(false);
567
+ };
568
+ useEscapeKey(() => setDismissed(true), open);
569
+ // A press anywhere else closes it — the case a blur does not cover: Safari moves no
570
+ // focus on a tap, and a tap on plain text moves it nowhere.
571
+ useOutsideClick(triggerRef, close, open);
572
+ const arm = (el: HTMLElement) => {
573
+ setDismissed(false);
574
+ onArm(el);
575
+ };
576
+ const handlers = {
577
+ onPointerEnter: (e: PointerEvent<HTMLSpanElement>) => {
578
+ if (e.pointerType === "touch") pressedByTouch.current = true;
579
+ else if (e.buttons === 0) pressedByTouch.current = false;
580
+ passed.onPointerEnter?.(e);
581
+ },
582
+ onPointerDown: (e: PointerEvent<HTMLSpanElement>) => {
583
+ if (isTap(e.pointerType)) {
584
+ pressedByTouch.current = true;
585
+ press.current = { wasOpen: open };
586
+ close();
587
+ } else {
588
+ pressedByTouch.current = false;
589
+ pressedByMouse.current = true;
590
+ }
591
+ passed.onPointerDown?.(e);
592
+ },
593
+ onPointerUp: (e: PointerEvent<HTMLSpanElement>) => {
594
+ pressedByMouse.current = false;
595
+ const began = press.current;
596
+ press.current = null;
597
+ if (began && !began.wasOpen && isTap(e.pointerType) && tapShows(tap, e.target)) {
598
+ setTapped(true);
599
+ arm(e.currentTarget);
600
+ }
601
+ passed.onPointerUp?.(e);
602
+ },
603
+ // The browser took the gesture over — a scroll, a pinch: not a tap.
604
+ onPointerCancel: (e: PointerEvent<HTMLSpanElement>) => {
605
+ pressedByMouse.current = false;
606
+ press.current = null;
607
+ passed.onPointerCancel?.(e);
608
+ },
609
+ onMouseEnter: (e: MouseEvent<HTMLSpanElement>) => {
610
+ if (pressedByTouch.current) return;
611
+ setHovered(true);
612
+ arm(e.currentTarget);
613
+ },
614
+ onMouseLeave: () => setHovered(false),
615
+ onFocus: (e: FocusEvent<HTMLSpanElement>) => {
616
+ if (!focusShows(e.target, pressedByMouse.current)) return;
617
+ setFocused(true);
618
+ arm(e.currentTarget);
619
+ },
620
+ onBlur: (e: FocusEvent<HTMLSpanElement>) => {
621
+ setFocused(false);
622
+ if (e.relatedTarget instanceof Node && e.currentTarget.contains(e.relatedTarget)) return;
623
+ setTapped(false);
624
+ },
625
+ };
626
+ return { open, dismissed, handlers };
627
+ }
628
+
305
629
  /** The variant that lives next to its trigger, and — when `detect` is on, which is the
306
630
  * default — moves its bubble to `<body>` when that turns out to be inside a clipping
307
631
  * container (see "Inside a scroll container" on {@link Tooltip}).
308
632
  *
309
- * In place it holds the little state it does for the two things CSS cannot express —
310
- * which element to point `aria-describedby` at, and Escape — and not for the fade,
311
- * which is still `group-hover`/`group-focus-within` and still costs a render nothing.
633
+ * Whether the bubble is up is {@link useTooltipTrigger}'s, shared with the portalled
634
+ * variant. Until 0.25 the always-mounted bubble showed itself with CSS alone —
635
+ * `group-hover` / `group-focus-within` — which is exactly what a tap on a phone could not
636
+ * get rid of: the tapped button keeps the focus, and `:focus-within` cannot be told that
637
+ * the focus came from a finger. It is shown from the same state as every other bubble
638
+ * now, and is `display: none` while closed (see "never widens the page" on
639
+ * {@link Tooltip}).
312
640
  *
313
641
  * ONE component for both placements rather than a switch between the two variants: a
314
642
  * switch would be a different component at the same place in the tree, and React
@@ -322,60 +650,48 @@ function InPlaceTooltip({
322
650
  redact,
323
651
  detect,
324
652
  lazy,
653
+ tap,
325
654
  children,
326
655
  ...rest
327
656
  }: TooltipVariantProps & { detect: boolean; lazy: boolean }) {
328
657
  const id = useId();
329
658
  const triggerRef = useRef<HTMLSpanElement | null>(null);
330
659
  const [clipped, setClipped] = useState(false);
331
- const [hovered, setHovered] = useState(false);
332
- const [focused, setFocused] = useState(false);
333
- const [dismissed, setDismissed] = useState(false);
334
660
  const [dir, setDir] = useState<Direction>("ltr");
335
- const open = (hovered || focused) && !dismissed;
336
- useEscapeKey(() => setDismissed(true), open);
661
+ // The reading direction on every request (the clamp keeps the start edge of a bubble
662
+ // too wide for the room); the clipping check too, for a container that began to scroll
663
+ // after mount.
664
+ const { open, dismissed, handlers } = useTooltipTrigger(triggerRef, tap, rest, (el) => {
665
+ setDir(dirOf(el));
666
+ if (detect) setClipped(hasClippingAncestor(el));
667
+ });
337
668
  // keksdose G7: slide an open in-place bubble back onto the screen. See "The in-place
338
669
  // bubble is clamped" on {@link Tooltip}.
339
670
  const bubbleRef = useRef<HTMLSpanElement | null>(null);
340
- const shift = useViewportClamp(bubbleRef, open && !clipped, side, label);
671
+ const { shift, measuring } = useViewportClamp(bubbleRef, open && !clipped, side, label, dir);
341
672
  // At mount, before the first paint: the in-place bubble inside a scroller is the
342
673
  // phantom-scroll bug whether or not anyone opens it.
343
674
  useLayoutEffect(() => {
344
675
  if (detect) setClipped(hasClippingAncestor(triggerRef.current));
345
676
  }, [detect]);
346
- // Re-armed by the next hover or focus rather than by an effect watching those flags:
347
- // coming back to a trigger is a fresh request for its label, and an effect would also
348
- // re-show the bubble under a pointer that never left. The clipping check is repeated
349
- // here for a container that began to scroll after mount.
350
- const arm = (el: Element) => {
351
- setDismissed(false);
352
- if (!detect) return;
353
- setDir(dirOf(el));
354
- setClipped(hasClippingAncestor(el));
355
- };
356
677
  return (
357
- /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- the handlers track
358
- whether the bubble is up and activate nothing; the caller's child is the
359
- interactive element, keeps its own handlers, and its focus shows the bubble too. */
678
+ // No role for this span: its handlers track whether the bubble is up and activate
679
+ // nothing; the caller's child is the interactive element, keeps its own handlers,
680
+ // and its focus shows the bubble too.
360
681
  <span
361
- // `...rest` first: the four handlers below are what decides whether a bubble is
362
- // up, and a caller passing an `onFocus` of its own must not replace them.
682
+ // `...rest` first: the handlers below are what decides whether a bubble is up, and
683
+ // a caller passing an `onFocus` of its own must not replace them (its pointer
684
+ // handlers are called from them).
363
685
  {...rest}
364
686
  ref={triggerRef}
687
+ // Clipped for the one render in which the bubble is measured (see useViewportClamp):
688
+ // that layout must not reach the page's width.
689
+ style={measuring ? { ...rest.style, overflow: "clip" } : rest.style}
365
690
  className={cn("relative inline-flex", !clipped && "group/tooltip", className)}
366
- // These four track WHETHER A BUBBLE IS UP. They activate nothing — the only thing
367
- // here that can be activated is the caller's child, which keeps every handler it
691
+ // These track WHETHER A BUBBLE IS UP. They activate nothing — the only thing here
692
+ // that can be activated is the caller's child, which keeps every handler it
368
693
  // arrived with — so this wrapper needs no role and no key handling of its own.
369
- onMouseEnter={(e) => {
370
- setHovered(true);
371
- arm(e.currentTarget);
372
- }}
373
- onMouseLeave={() => setHovered(false)}
374
- onFocus={(e) => {
375
- setFocused(true);
376
- arm(e.currentTarget);
377
- }}
378
- onBlur={() => setFocused(false)}
694
+ {...handlers}
379
695
  >
380
696
  {/* In place the bubble is always there to point at; portalled or lazy, only while up. */}
381
697
  {describedBy(children, clipped || lazy ? (open ? id : undefined) : dismissed ? undefined : id)}
@@ -385,10 +701,7 @@ function InPlaceTooltip({
385
701
  )
386
702
  ) : lazy ? (
387
703
  // keksdose F6: the in-place slot and classes, the portalled lifetime. Mounted only
388
- // while up, so it is visible whenever it exists — `opacity-100` outright rather
389
- // than the `group-hover` switch, which would also be right but would make the
390
- // bubble's visibility depend on two sources (the state that mounted it and the
391
- // CSS that shows it) that can disagree for a frame after Escape.
704
+ // while up, so it is visible whenever it exists.
392
705
  open && (
393
706
  <span
394
707
  ref={bubbleRef}
@@ -407,14 +720,19 @@ function InPlaceTooltip({
407
720
  id={id}
408
721
  role="tooltip"
409
722
  style={shiftStyle(shift)}
410
- // The `hidden` ATTRIBUTE, not an opacity class: dismissing has to take the
411
- // bubble out of the accessibility tree as well as off the screen, or a screen
412
- // reader still reads out the description of a bubble the user just closed.
723
+ // The `hidden` ATTRIBUTE, not a class: dismissing has to take the bubble out of
724
+ // the accessibility tree as well as off the screen, or a screen reader still
725
+ // reads out the description of a bubble the user just closed. Merely CLOSED it
726
+ // is the `hidden` CLASS — `display: none` in the browser, so it takes no room
727
+ // and widens nothing (live #381), and nothing at all under jsdom, where tests
728
+ // have always found this bubble without a hover. `aria-describedby` reads a
729
+ // `display: none` node all the same.
413
730
  hidden={dismissed || undefined}
414
731
  data-private={redact ? "" : undefined}
415
732
  className={cn(
416
733
  TOOLTIP_SURFACE,
417
- "pointer-events-none absolute z-50 opacity-0 group-hover/tooltip:opacity-100 group-focus-within/tooltip:opacity-100",
734
+ "pointer-events-none absolute z-50",
735
+ open ? "opacity-100" : "hidden",
418
736
  sidePositionClass[side],
419
737
  )}
420
738
  >
@@ -463,24 +781,67 @@ interface Shift {
463
781
 
464
782
  const NO_SHIFT: Shift = { x: 0, y: 0 };
465
783
 
784
+ /** An in-place bubble's slide and the label, side and direction it was measured for. */
785
+ interface Placement {
786
+ shift: Shift;
787
+ for: { side: TooltipSide; label: ReactNode; dir: Direction } | null;
788
+ }
789
+
790
+ const UNPLACED: Placement = { shift: NO_SHIFT, for: null };
791
+
466
792
  /** How far to slide the span `[low, high]` so it sits inside `[TOOLTIP_MARGIN,
467
- * extent - TOOLTIP_MARGIN]`. Zero when it already does. The start edge wins when the
468
- * span is wider than the room — as in {@link clamp}, the start of a label is the half
469
- * worth keeping (and the width cap means that only happens on a viewport narrower than
470
- * twice the margin). */
471
- function slideInto(low: number, high: number, extent: number): number {
472
- if (low < TOOLTIP_MARGIN) return TOOLTIP_MARGIN - low;
473
- if (high > extent - TOOLTIP_MARGIN) return Math.max(extent - TOOLTIP_MARGIN - high, TOOLTIP_MARGIN - low);
793
+ * extent - TOOLTIP_MARGIN]`. Zero when it already does. When the span is wider than the
794
+ * room, the edge it keeps is its START — as in {@link clamp}, the start of a label is the
795
+ * half worth keeping — which is the low edge unless `keepHigh` says the start is the
796
+ * high one (an RTL label, along x). The width cap means that only happens on a viewport
797
+ * barely wider than the margins, or one whose scrollbar the cap's `100vw` counts. */
798
+ function slideInto(low: number, high: number, extent: number, keepHigh = false): number {
799
+ const min = TOOLTIP_MARGIN;
800
+ const max = extent - TOOLTIP_MARGIN;
801
+ if (high - low > max - min) return keepHigh ? max - high : min - low;
802
+ if (low < min) return min - low;
803
+ if (high > max) return max - high;
474
804
  return 0;
475
805
  }
476
806
 
807
+ /**
808
+ * The glass a bubble has to stay on, in CSS pixels — `window.innerWidth` is the wrong
809
+ * width on a phone (keksdose run 72, live #381). There it is the LAYOUT viewport, and
810
+ * that grows to the page's width as soon as anything sticks out past the screen: 430 on
811
+ * a 406px screen in Chromium's mobile emulation, with `position: fixed` layers laid out
812
+ * against the 430. A bubble that had widened the page then measured itself as inside it.
813
+ * The root element's client width is the initial containing block instead — the screen,
814
+ * the width `100vw` and the media queries see — whatever overflows, and on a desktop it
815
+ * also leaves a classic scrollbar out. It is 0 where nothing is laid out (jsdom), and the
816
+ * window is the answer there. The height stays the window's: nothing measured grows it,
817
+ * and a phone's collapsing toolbar makes the window the truer of the two.
818
+ */
819
+ function viewportSize(): TooltipViewport {
820
+ return {
821
+ width: document.documentElement.clientWidth || window.innerWidth,
822
+ height: window.innerHeight,
823
+ };
824
+ }
825
+
477
826
  /**
478
827
  * The in-place bubble's viewport clamp (keksdose G7): while `active`, measure the bubble
479
828
  * in a LAYOUT effect — before paint, so there is no frame with the label off the screen —
480
- * and return the cross-axis slide that brings it inside the viewport minus
481
- * `TOOLTIP_MARGIN`. `top` / `bottom` slide along x; `left` / `right` / `start` / `end`
829
+ * and return the cross-axis slide that brings it inside the viewport ({@link viewportSize})
830
+ * minus `TOOLTIP_MARGIN`. `top` / `bottom` slide along x; `left` / `right` / `start` / `end`
482
831
  * along y. {@link NO_SHIFT} while closed, so the next open measures the bubble where the
483
- * CSS alone puts it.
832
+ * CSS alone puts it. `dir` only decides which edge survives a bubble wider than the room.
833
+ *
834
+ * `measuring` is true for the one render in which the bubble is measured — on open, and
835
+ * again when the label, side or direction changes while open — and the caller clips its
836
+ * wrapper (`overflow: clip`) for that render. Measuring means laying the bubble out where
837
+ * the CSS alone puts it, which is past the edge whenever there is something to slide, and
838
+ * that layout counts towards the page's scrollable overflow even though it is never
839
+ * painted. Chrome on a phone grows the layout viewport to it and does not always give the
840
+ * width back: in an RTL page, where overflow on the left is scrollable, a bubble measured
841
+ * at −24 left the page 426 wide and scrolled by −20 after the slide had already brought it
842
+ * to 4 (Chromium mobile emulation, 406px). A clipped wrapper keeps the measurement out of
843
+ * every ancestor's overflow — `clip` and not `hidden`, so the wrapper never becomes a
844
+ * scroll container — and it is unclipped, with the slide, in the same task, before paint.
484
845
  *
485
846
  * The rect it reads already includes the slide it applied last time (a label that
486
847
  * changed while open), so that slide is taken back out before deciding the new one.
@@ -491,33 +852,62 @@ function useViewportClamp(
491
852
  active: boolean,
492
853
  side: TooltipSide,
493
854
  label: ReactNode,
494
- ): Shift {
495
- const [shift, setShift] = useState<Shift>(NO_SHIFT);
855
+ dir: Direction,
856
+ ): { shift: Shift; measuring: boolean } {
857
+ // The slide, and what it was measured for: anything else on screen — a fresh open, a
858
+ // new label, side or direction — is not measured yet, and renders clipped until it is.
859
+ const [placement, setPlacement] = useState<Placement>(UNPLACED);
860
+ const measured =
861
+ placement.for !== null &&
862
+ placement.for.side === side &&
863
+ placement.for.label === label &&
864
+ placement.for.dir === dir;
496
865
  useLayoutEffect(() => {
497
866
  const el = bubbleRef.current;
498
867
  if (!active || !el) {
499
- setShift(NO_SHIFT);
868
+ setPlacement(UNPLACED);
500
869
  return;
501
870
  }
871
+ if (measured) return;
502
872
  const r = el.getBoundingClientRect();
503
- if (r.width === 0 && r.height === 0) return;
873
+ const unlaid = r.width === 0 && r.height === 0;
504
874
  const horizontal = side === "top" || side === "bottom";
505
- setShift((previous) => {
506
- const next = horizontal
507
- ? { x: slideInto(r.left - previous.x, r.right - previous.x, window.innerWidth), y: 0 }
508
- : { x: 0, y: slideInto(r.top - previous.y, r.bottom - previous.y, window.innerHeight) };
509
- return next.x === previous.x && next.y === previous.y ? previous : next;
875
+ const viewport = viewportSize();
876
+ setPlacement(({ shift: previous }) => {
877
+ const next = unlaid
878
+ ? previous
879
+ : horizontal
880
+ ? { x: slideInto(r.left - previous.x, r.right - previous.x, viewport.width, dir === "rtl"), y: 0 }
881
+ : { x: 0, y: slideInto(r.top - previous.y, r.bottom - previous.y, viewport.height) };
882
+ const shift = next.x === previous.x && next.y === previous.y ? previous : next;
883
+ return { shift, for: { side, label, dir } };
510
884
  });
511
- }, [bubbleRef, active, side, label]);
512
- return shift;
885
+ }, [bubbleRef, active, measured, side, label, dir]);
886
+ return { shift: placement.shift, measuring: active && !measured };
513
887
  }
514
888
 
515
- /** The inline style for a slide: none at all when there is nothing to slide, so a bubble
516
- * that fits renders exactly as it did before G7. `transform` rather than `translate`
517
- * because the placement classes own the `translate` property (Tailwind v4's
518
- * `-translate-x-1/2`); the two compose instead of one replacing the other. */
889
+ /**
890
+ * The inline style for a slide: none at all when there is nothing to slide, so a bubble
891
+ * that fits renders exactly as it did before G7.
892
+ *
893
+ * A MARGIN, not a `transform` (0.14–0.24 used `transform: translate(…)`), because of what
894
+ * Blink does with the page's width (keksdose run 72, live #381). The slide is decided after
895
+ * a layout that placed the bubble where the CSS alone puts it — past the edge — and that
896
+ * layout already counted it into the page's scrollable overflow. A later change to
897
+ * `transform` alone re-paints but does not re-lay-out, so that overflow was never taken
898
+ * back: measured in Chromium's mobile emulation, a bubble shown at 108–428 on a 406px
899
+ * screen and then slid to 400 by a transform left the page 428 wide, the layout viewport
900
+ * with it (`position: fixed` layers laid out against 428), until the bubble closed. The
901
+ * same slide as a margin is a layout change; the page went back to 406 in the same frame.
902
+ *
903
+ * Physical margins, matching the physical maths: `margin-left` moves a `top` / `bottom`
904
+ * bubble, whose placement is the physical `left: 50%`; `margin-top` moves the four side
905
+ * placements, placed by `top: 50%`. Neither is a margin the placement classes set — they
906
+ * keep their gap on the main axis (`mb-1`, `ms-1`, …).
907
+ */
519
908
  function shiftStyle(shift: Shift) {
520
- return shift.x === 0 && shift.y === 0 ? undefined : { transform: `translate(${shift.x}px, ${shift.y}px)` };
909
+ if (shift.x === 0 && shift.y === 0) return undefined;
910
+ return shift.x !== 0 ? { marginLeft: shift.x } : { marginTop: shift.y };
521
911
  }
522
912
 
523
913
  const portalTransformBySide: Record<PhysicalSide, string> = {
@@ -660,46 +1050,38 @@ function PortalTooltip({
660
1050
  side,
661
1051
  className,
662
1052
  redact,
1053
+ tap,
663
1054
  children,
664
1055
  ...rest
665
1056
  }: TooltipVariantProps) {
666
1057
  const triggerRef = useRef<HTMLSpanElement | null>(null);
667
- const [visible, setVisible] = useState(false);
668
1058
  // The trigger's reading direction, read when the bubble is asked for (an event, not a
669
1059
  // render): it resolves `start` / `end`, and the portalled bubble — which has left the
670
1060
  // subtree it would have inherited `dir` from — carries it too.
671
1061
  const [dir, setDir] = useState<Direction>("ltr");
672
- const show = (el: Element) => {
673
- setDir(dirOf(el));
674
- setVisible(true);
675
- };
676
1062
  const id = useId();
677
1063
  // Escape closes it outright, since this variant's bubble only exists while it is
678
1064
  // shown. The next mouseenter/focus brings it back, which is the behaviour WCAG
679
1065
  // 1.4.13 asks for: dismissible now, still available when you ask again.
680
- useEscapeKey(() => setVisible(false), visible);
1066
+ const { open, handlers } = useTooltipTrigger(triggerRef, tap, rest, (el) => setDir(dirOf(el)));
681
1067
 
682
1068
  return (
683
1069
  <>
684
- {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- as in
685
- `InPlaceTooltip`: the handlers only show and hide the bubble; the caller's child
686
- is the interactive element, and focusing it shows the bubble. */}
1070
+ {/* As in `InPlaceTooltip`, no role: the handlers only show and hide the bubble; the
1071
+ caller's child is the interactive element, and focusing it shows the bubble. */}
687
1072
  <span
688
- // As in `InPlaceTooltip`: the caller's attributes first, the four handlers that
689
- // run this component after them. The BUBBLE is deliberately not given them — it
1073
+ // As in `InPlaceTooltip`: the caller's attributes first, the handlers that run
1074
+ // this component after them. The BUBBLE is deliberately not given them — it
690
1075
  // is portalled to `<body>`, and an id or a tour anchor duplicated onto a node
691
1076
  // that only exists while hovered would match twice or match nothing.
692
1077
  {...rest}
693
1078
  ref={triggerRef}
694
1079
  className={cn("relative inline-flex", className)}
695
- onMouseEnter={(e) => show(e.currentTarget)}
696
- onMouseLeave={() => setVisible(false)}
697
- onFocus={(e) => show(e.currentTarget)}
698
- onBlur={() => setVisible(false)}
1080
+ {...handlers}
699
1081
  >
700
- {describedBy(children, visible ? id : undefined)}
1082
+ {describedBy(children, open ? id : undefined)}
701
1083
  </span>
702
- {visible && (
1084
+ {open && (
703
1085
  <PortalBubble triggerRef={triggerRef} id={id} label={label} side={side} dir={dir} redact={redact} />
704
1086
  )}
705
1087
  </>
@@ -740,7 +1122,8 @@ function PortalBubble({
740
1122
  if (!measured) return null;
741
1123
  const next = {
742
1124
  size: { width: measured.width, height: measured.height },
743
- viewport: { width: window.innerWidth, height: window.innerHeight },
1125
+ // The glass, not the window: see viewportSize (live #381).
1126
+ viewport: viewportSize(),
744
1127
  };
745
1128
  // Only publish what actually CHANGED: every re-measure allocates a fresh
746
1129
  // object, and a new object on every scroll event would re-render the