@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
@@ -8,6 +8,17 @@ export { CLIPS_ATTRIBUTE } from '../lib/clipping.js';
8
8
  * for. `left` / `right` stay physical, for a bubble tied to something that does not
9
9
  * mirror (a chart axis, a map). */
10
10
  type TooltipSide = "top" | "bottom" | "left" | "right" | "start" | "end";
11
+ /** What a TAP — a touch or a pen press, never the mouse — on the trigger does to the
12
+ * bubble. See "A tap is not a hover" on {@link Tooltip}.
13
+ *
14
+ * - `"auto"` (the default): a tap that activates something shows nothing; a tap that
15
+ * activates nothing — on a disabled or `aria-disabled` control (a write lock's
16
+ * reason), or on a trigger with no control at all (a truncated name, a badge) —
17
+ * toggles the bubble, since showing it is then the tap's only answer.
18
+ * - `"toggle"`: every tap toggles it — for a control that exists only to explain, such
19
+ * as a "?" whose click does nothing.
20
+ * - `"ignore"`: a tap never shows it. */
21
+ type TooltipTap = "auto" | "toggle" | "ignore";
11
22
  /** The placement the maths works in: a logical side resolved against the trigger. */
12
23
  type PhysicalSide = "top" | "bottom" | "left" | "right";
13
24
  /**
@@ -46,15 +57,22 @@ interface TooltipProps extends ComponentPropsWithoutRef<"span"> {
46
57
  lazy?: boolean;
47
58
  /** Tag the bubble `data-private`, for a label that repeats the user's own data. */
48
59
  redact?: boolean;
60
+ /**
61
+ * What a tap (touch or pen) on the trigger does — see {@link TooltipTap} and "A tap is
62
+ * not a hover" below. Default `"auto"`: a tap that activates the control shows nothing;
63
+ * one that activates nothing (a disabled control, plain text) toggles the bubble.
64
+ */
65
+ tap?: TooltipTap;
49
66
  children: ReactNode;
50
67
  }
51
68
  /**
52
69
  * Hover/focus label for a control.
53
70
  *
54
71
  * Two placements, and the choice matters more than it looks. In place, the bubble is
55
- * always mounted next to the trigger and fades in on `:hover`, which costs no state and
56
- * works in a plain render test. Portalled, the bubble is mounted in `document.body`
57
- * only while it is up, positioned by measurement.
72
+ * always mounted next to the trigger — so a plain render test finds it without a hover —
73
+ * and shown while it is up (`display: none` otherwise, see "never widens the page"
74
+ * below). Portalled, the bubble is mounted in `document.body` only while it is up,
75
+ * positioned by measurement.
58
76
  *
59
77
  * ⚠️ **A bubble that repeats a value has to be redactable.** The consuming app blurs
60
78
  * `[data-private]` under a `demo-mode` class on `<html>` — and the portalled bubble is
@@ -173,8 +191,8 @@ interface TooltipProps extends ComponentPropsWithoutRef<"span"> {
173
191
  * So now, when an in-place bubble goes up (default or `lazy`), a layout effect measures
174
192
  * it and, if it crosses the viewport edge minus the same margin the portalled bubble
175
193
  * keeps, slides it back along its CROSS axis only — sideways for `top` / `bottom`, up or
176
- * down for the four side placements — with an inline `transform`, which composes with the
177
- * placement classes' own `translate`. The main axis is left alone on purpose: sliding a
194
+ * down for the four side placements — with an inline margin (a `transform` until 0.25; see
195
+ * {@link shiftStyle} for why that widened the page). The main axis is left alone on purpose: sliding a
178
196
  * `start` bubble along the main axis would slide it over its own trigger, and turning it
179
197
  * round is a measured-placement decision this CSS-placed bubble does not make (pass
180
198
  * `portal` for that). The width is already capped to the viewport (see
@@ -183,15 +201,86 @@ interface TooltipProps extends ComponentPropsWithoutRef<"span"> {
183
201
  *
184
202
  * Why on by default, with no prop? Because it is a no-op for every bubble that already
185
203
  * fits — the shift is zero unless the bubble would overflow — so the only placements it
186
- * changes are ones that were broken. The maths is in physical viewport pixels, so it
187
- * needs no `dir`: an RTL row puts its long label at the RIGHT edge and gets slid left by
188
- * the same code. Under jsdom there is no layout — every rect is zero-sized — and a
189
- * zero-sized bubble is taken to be unmeasured, so tests see no transform at all. It is
190
- * measured on open (and when the label or side changes while open), not on every scroll:
191
- * an in-place bubble follows its trigger for free, and a page that scrolls sideways under
192
- * an open tooltip is not a case worth a listener per tooltip.
204
+ * changes are ones that were broken. The maths is in physical viewport pixels: an RTL
205
+ * row puts its long label at the RIGHT edge and gets slid left by the same code, and the
206
+ * reading direction only decides which edge is kept when a bubble is wider than the room
207
+ * (its start, as {@link placeTooltip} keeps). Under jsdom there is no layout — every rect
208
+ * is zero-sized — and a zero-sized bubble is taken to be unmeasured, so tests see no
209
+ * transform at all. It is measured on open (and when the label or side changes while
210
+ * open), not on every scroll: an in-place bubble follows its trigger for free, and a page
211
+ * that scrolls sideways under an open tooltip is not a case worth a listener per tooltip.
212
+ *
213
+ * ⚠️ **An in-place bubble never widens the page (keksdose run 72, live #381).** Two
214
+ * holes let it, and a page wider than the screen is worse than a bubble cut off: on a
215
+ * phone Chrome then lays every `position: fixed` layer out against the WIDER page while
216
+ * the glass still shows the old width. Measured on a 406px phone: the sync icon at x=254,
217
+ * its `bottom` bubble at 102–422, the page 426px wide, and a FloatingPanel 17px off the
218
+ * glass. First, the clamp compared against `window.innerWidth` — and on a phone that is
219
+ * the layout viewport, which GROWS to the page's width once something sticks out (430 on
220
+ * a 406px screen, measured in Chromium's mobile emulation), so a bubble already past the
221
+ * edge found itself "inside" and stayed there. It now measures against the root's client
222
+ * width, which on a phone is the screen (the initial containing block, the width `100vw`
223
+ * and media queries see) whatever overflows, and on a desktop also leaves out a classic
224
+ * scrollbar; the window is the fallback where there is no layout (jsdom). The portalled
225
+ * bubble takes the same width, since a `fixed` bubble placed against a grown layout
226
+ * viewport is off the glass for the same reason. Second, the always-mounted bubble was
227
+ * `opacity: 0` while closed — invisible, but still laid out where the CSS puts it, so a
228
+ * bubble nobody opened, centred on a trigger near the end edge, widened the page all by
229
+ * itself (dev#488's phantom scroll, at the scale of the page instead of a scroller, where
230
+ * the auto-portal cannot help: the page is not an ancestor it can portal out of). It is
231
+ * now `display: none` until it is up, so a closed bubble takes no room at all, and an open
232
+ * one is slid onto the glass in the same layout pass that shows it, before paint. jsdom
233
+ * computes no Tailwind, so tests still find the closed bubble without a hover.
234
+ *
235
+ * ⚠️ **A tap is not a hover (keksdose run 72, live #379).** The bubble used to show on
236
+ * `mouseenter` and on `focus`, and hide only on `mouseleave` and `blur`. A tap on a phone
237
+ * fires an emulated `mouseenter` and, on a button, focuses it — and the virtual mouse and
238
+ * the focus both stay on the button until the next tap somewhere else, so every
239
+ * IconButton's label (the top-bar icons, the sync indicator, a row's actions) stayed up
240
+ * over whatever the tap had just opened. Now a touch or pen press is told apart from the
241
+ * mouse: the emulated `mouseenter` that follows a tap is ignored, the press itself closes
242
+ * a bubble that was up, and a focus that arrives while the page's last input was a finger
243
+ * or a pen — the tap's own, or one a closing dialog RETURNS to the icon it was opened
244
+ * from — shows nothing. The next key press (a Bluetooth keyboard on a tablet) or mouse
245
+ * press makes focus count again, and a mouse entering the trigger hovers as ever, so a
246
+ * hybrid laptop gets both.
247
+ *
248
+ * Focus otherwise shows the bubble, as it always did — the keyboard, a script, a test's
249
+ * `.focus()` — with one more exception: the focus a mouse press on the trigger gives it.
250
+ * There it follows `:focus-visible`, so a text field still shows its bubble while you type
251
+ * and a clicked button does not hold it: a desktop click behaves as it always did while
252
+ * the pointer is over the button (the hover shows it) and lets it go when the pointer
253
+ * leaves, where the in-place bubble used to linger until the button lost focus — the same
254
+ * left-behind bubble on a smaller scale, and what the portalled bubble always did. A
255
+ * keyboard user's bubble, in turn, now stays up when a mouse passes over and leaves (the
256
+ * portalled one used to close). Any `pointerdown` outside the trigger closes an open
257
+ * bubble too — the Safari case, where a tap elsewhere moves no focus.
258
+ *
259
+ * What a tap DOES show is {@link TooltipTap} (`tap`). By default a tap that activates
260
+ * something shows nothing — the action is the answer — while a tap that activates nothing
261
+ * toggles the bubble, because there the bubble is the only answer: a write-locked or
262
+ * disabled control, whose bubble is the reason it did not respond, and a trigger with no
263
+ * control at all, a truncated payee or a badge, whose bubble is the rest of the text. A
264
+ * second tap, a tap anywhere else, Escape or the focus leaving closes it. "Activates" is
265
+ * read off the DOM — a link, a button, a field, a label, an element with a widget role
266
+ * (`button`, `link`, `checkbox`, `option`, …), or a DataTable row (`tr[tabindex]`) — from
267
+ * the tapped element outwards, past the tooltip, so a glyph inside a clickable row or a
268
+ * button counts as that row or button. What it cannot read is a click handler on an
269
+ * element with no role (give it `role="button"`, which it needs anyway, or pass
270
+ * `tap="ignore"`), and a button that exists only to explain, whose click does nothing —
271
+ * FieldHint's "?" — which says so with `tap="toggle"`.
272
+ *
273
+ * Why not show the label on a long press, as Android does for its own icons? Because the
274
+ * web gives no reliable long press on a control: browsers disagree on whether the release
275
+ * that ends one still clicks the button, and the button's own long press — the context
276
+ * menu, text selection, a row's drag — claims the gesture first on others. A gesture meant
277
+ * to ask "what does this do" must not risk doing it. Screen readers do not need it (the
278
+ * bubble is in `aria-describedby`, and an IconButton's label is its name), and a tap on
279
+ * anything that does nothing already shows it. No scroll listener either: every touch scroll begins with a `pointerdown`, which
280
+ * already closes the bubble, and closing on scroll would close a keyboard user's bubble
281
+ * the moment Tab scrolled its control into view.
193
282
  */
194
- declare function Tooltip({ label, side, className, portal, lazy, redact, children, ...rest }: TooltipProps): react.JSX.Element;
283
+ declare function Tooltip({ label, side, className, portal, lazy, redact, tap, children, ...rest }: TooltipProps): react.JSX.Element;
195
284
  interface TooltipSize {
196
285
  width: number;
197
286
  height: number;
@@ -230,4 +319,4 @@ interface TooltipPlacement {
230
319
  */
231
320
  declare function placeTooltip(r: AnchorRect, side: PhysicalSide, size: TooltipSize, viewport: TooltipViewport): TooltipPlacement;
232
321
 
233
- export { Tooltip, type TooltipPlacement, type TooltipProps, type TooltipSide, type TooltipSize, type TooltipViewport, placeTooltip };
322
+ export { Tooltip, type TooltipPlacement, type TooltipProps, type TooltipSide, type TooltipSize, type TooltipTap, type TooltipViewport, placeTooltip };
@@ -3,6 +3,7 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import {
4
4
  cloneElement,
5
5
  isValidElement,
6
+ useEffect,
6
7
  useId,
7
8
  useLayoutEffect,
8
9
  useRef,
@@ -10,7 +11,7 @@ import {
10
11
  } from "react";
11
12
  import { createPortal } from "react-dom";
12
13
  import { cn } from "../lib/cn.js";
13
- import { useEscapeKey } from "../hooks/use-dismiss.js";
14
+ import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss.js";
14
15
  import { useAnchoredRect } from "../hooks/use-anchored-rect.js";
15
16
  import { dirOf } from "../lib/direction.js";
16
17
  import { hasClippingAncestor } from "../lib/clipping.js";
@@ -38,6 +39,7 @@ function Tooltip({
38
39
  portal,
39
40
  lazy = false,
40
41
  redact = false,
42
+ tap = "auto",
41
43
  children,
42
44
  ...rest
43
45
  }) {
@@ -50,6 +52,7 @@ function Tooltip({
50
52
  side,
51
53
  className,
52
54
  redact,
55
+ tap,
53
56
  ...rest,
54
57
  children
55
58
  }
@@ -64,11 +67,152 @@ function Tooltip({
64
67
  redact,
65
68
  detect: portal === void 0,
66
69
  lazy,
70
+ tap,
67
71
  ...rest,
68
72
  children
69
73
  }
70
74
  );
71
75
  }
76
+ function isTap(pointerType) {
77
+ return pointerType === "touch" || pointerType === "pen";
78
+ }
79
+ const ACTIVATES = [
80
+ "a[href]",
81
+ "button",
82
+ "input",
83
+ "select",
84
+ "textarea",
85
+ "label",
86
+ "summary",
87
+ '[contenteditable=""]',
88
+ '[contenteditable="true"]',
89
+ "tr[tabindex]",
90
+ ...[
91
+ "button",
92
+ "link",
93
+ "checkbox",
94
+ "radio",
95
+ "switch",
96
+ "tab",
97
+ "menuitem",
98
+ "menuitemcheckbox",
99
+ "menuitemradio",
100
+ "option",
101
+ "treeitem",
102
+ "slider",
103
+ "spinbutton",
104
+ "combobox"
105
+ ].map((role) => `[role="${role}"]`)
106
+ ].join(",");
107
+ function tapShows(tap, target) {
108
+ if (tap !== "auto") return tap === "toggle";
109
+ if (!(target instanceof Element)) return true;
110
+ const control = target.closest(ACTIVATES);
111
+ return control === null || control.matches(":disabled") || control.closest('[aria-disabled="true"]') !== null;
112
+ }
113
+ let lastInput = null;
114
+ let trackingInput = false;
115
+ function trackInput() {
116
+ if (trackingInput || typeof document === "undefined") return;
117
+ trackingInput = true;
118
+ document.addEventListener(
119
+ "pointerdown",
120
+ (e) => {
121
+ lastInput = e.pointerType;
122
+ },
123
+ { capture: true, passive: true }
124
+ );
125
+ document.addEventListener(
126
+ "keydown",
127
+ (e) => {
128
+ if (e.key === "Unidentified" || e.isComposing || e.ctrlKey || e.metaKey || e.altKey) return;
129
+ lastInput = "keyboard";
130
+ },
131
+ { capture: true, passive: true }
132
+ );
133
+ }
134
+ function focusShows(target, pressedByMouse) {
135
+ if (lastInput !== null && isTap(lastInput)) return false;
136
+ if (!pressedByMouse || !(target instanceof Element)) return true;
137
+ try {
138
+ return target.matches(":focus-visible");
139
+ } catch {
140
+ return true;
141
+ }
142
+ }
143
+ function useTooltipTrigger(triggerRef, tap, passed, onArm) {
144
+ const [hovered, setHovered] = useState(false);
145
+ const [focused, setFocused] = useState(false);
146
+ const [tapped, setTapped] = useState(false);
147
+ const [dismissed, setDismissed] = useState(false);
148
+ const pressedByTouch = useRef(false);
149
+ const pressedByMouse = useRef(false);
150
+ const press = useRef(null);
151
+ useEffect(trackInput, []);
152
+ const open = (hovered || focused || tapped) && !dismissed;
153
+ const close = () => {
154
+ setHovered(false);
155
+ setFocused(false);
156
+ setTapped(false);
157
+ };
158
+ useEscapeKey(() => setDismissed(true), open);
159
+ useOutsideClick(triggerRef, close, open);
160
+ const arm = (el) => {
161
+ setDismissed(false);
162
+ onArm(el);
163
+ };
164
+ const handlers = {
165
+ onPointerEnter: (e) => {
166
+ if (e.pointerType === "touch") pressedByTouch.current = true;
167
+ else if (e.buttons === 0) pressedByTouch.current = false;
168
+ passed.onPointerEnter?.(e);
169
+ },
170
+ onPointerDown: (e) => {
171
+ if (isTap(e.pointerType)) {
172
+ pressedByTouch.current = true;
173
+ press.current = { wasOpen: open };
174
+ close();
175
+ } else {
176
+ pressedByTouch.current = false;
177
+ pressedByMouse.current = true;
178
+ }
179
+ passed.onPointerDown?.(e);
180
+ },
181
+ onPointerUp: (e) => {
182
+ pressedByMouse.current = false;
183
+ const began = press.current;
184
+ press.current = null;
185
+ if (began && !began.wasOpen && isTap(e.pointerType) && tapShows(tap, e.target)) {
186
+ setTapped(true);
187
+ arm(e.currentTarget);
188
+ }
189
+ passed.onPointerUp?.(e);
190
+ },
191
+ // The browser took the gesture over — a scroll, a pinch: not a tap.
192
+ onPointerCancel: (e) => {
193
+ pressedByMouse.current = false;
194
+ press.current = null;
195
+ passed.onPointerCancel?.(e);
196
+ },
197
+ onMouseEnter: (e) => {
198
+ if (pressedByTouch.current) return;
199
+ setHovered(true);
200
+ arm(e.currentTarget);
201
+ },
202
+ onMouseLeave: () => setHovered(false),
203
+ onFocus: (e) => {
204
+ if (!focusShows(e.target, pressedByMouse.current)) return;
205
+ setFocused(true);
206
+ arm(e.currentTarget);
207
+ },
208
+ onBlur: (e) => {
209
+ setFocused(false);
210
+ if (e.relatedTarget instanceof Node && e.currentTarget.contains(e.relatedTarget)) return;
211
+ setTapped(false);
212
+ }
213
+ };
214
+ return { open, dismissed, handlers };
215
+ }
72
216
  function InPlaceTooltip({
73
217
  label,
74
218
  side,
@@ -76,57 +220,40 @@ function InPlaceTooltip({
76
220
  redact,
77
221
  detect,
78
222
  lazy,
223
+ tap,
79
224
  children,
80
225
  ...rest
81
226
  }) {
82
227
  const id = useId();
83
228
  const triggerRef = useRef(null);
84
229
  const [clipped, setClipped] = useState(false);
85
- const [hovered, setHovered] = useState(false);
86
- const [focused, setFocused] = useState(false);
87
- const [dismissed, setDismissed] = useState(false);
88
230
  const [dir, setDir] = useState("ltr");
89
- const open = (hovered || focused) && !dismissed;
90
- useEscapeKey(() => setDismissed(true), open);
231
+ const { open, dismissed, handlers } = useTooltipTrigger(triggerRef, tap, rest, (el) => {
232
+ setDir(dirOf(el));
233
+ if (detect) setClipped(hasClippingAncestor(el));
234
+ });
91
235
  const bubbleRef = useRef(null);
92
- const shift = useViewportClamp(bubbleRef, open && !clipped, side, label);
236
+ const { shift, measuring } = useViewportClamp(bubbleRef, open && !clipped, side, label, dir);
93
237
  useLayoutEffect(() => {
94
238
  if (detect) setClipped(hasClippingAncestor(triggerRef.current));
95
239
  }, [detect]);
96
- const arm = (el) => {
97
- setDismissed(false);
98
- if (!detect) return;
99
- setDir(dirOf(el));
100
- setClipped(hasClippingAncestor(el));
101
- };
102
240
  return (
103
- /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- the handlers track
104
- whether the bubble is up and activate nothing; the caller's child is the
105
- interactive element, keeps its own handlers, and its focus shows the bubble too. */
241
+ // No role for this span: its handlers track whether the bubble is up and activate
242
+ // nothing; the caller's child is the interactive element, keeps its own handlers,
243
+ // and its focus shows the bubble too.
106
244
  /* @__PURE__ */ jsxs(
107
245
  "span",
108
246
  {
109
247
  ...rest,
110
248
  ref: triggerRef,
249
+ style: measuring ? { ...rest.style, overflow: "clip" } : rest.style,
111
250
  className: cn("relative inline-flex", !clipped && "group/tooltip", className),
112
- onMouseEnter: (e) => {
113
- setHovered(true);
114
- arm(e.currentTarget);
115
- },
116
- onMouseLeave: () => setHovered(false),
117
- onFocus: (e) => {
118
- setFocused(true);
119
- arm(e.currentTarget);
120
- },
121
- onBlur: () => setFocused(false),
251
+ ...handlers,
122
252
  children: [
123
253
  describedBy(children, clipped || lazy ? open ? id : void 0 : dismissed ? void 0 : id),
124
254
  clipped ? open && /* @__PURE__ */ jsx(PortalBubble, { triggerRef, id, label, side, dir, redact }) : lazy ? (
125
255
  // keksdose F6: the in-place slot and classes, the portalled lifetime. Mounted only
126
- // while up, so it is visible whenever it exists — `opacity-100` outright rather
127
- // than the `group-hover` switch, which would also be right but would make the
128
- // bubble's visibility depend on two sources (the state that mounted it and the
129
- // CSS that shows it) that can disagree for a frame after Escape.
256
+ // while up, so it is visible whenever it exists.
130
257
  open && /* @__PURE__ */ jsx(
131
258
  "span",
132
259
  {
@@ -150,7 +277,8 @@ function InPlaceTooltip({
150
277
  "data-private": redact ? "" : void 0,
151
278
  className: cn(
152
279
  TOOLTIP_SURFACE,
153
- "pointer-events-none absolute z-50 opacity-0 group-hover/tooltip:opacity-100 group-focus-within/tooltip:opacity-100",
280
+ "pointer-events-none absolute z-50",
281
+ open ? "opacity-100" : "hidden",
154
282
  sidePositionClass[side]
155
283
  ),
156
284
  children: label
@@ -174,31 +302,46 @@ function isEmptyLabel(label) {
174
302
  const TOOLTIP_GAP = 4;
175
303
  const TOOLTIP_MARGIN = 4;
176
304
  const NO_SHIFT = { x: 0, y: 0 };
177
- function slideInto(low, high, extent) {
178
- if (low < TOOLTIP_MARGIN) return TOOLTIP_MARGIN - low;
179
- if (high > extent - TOOLTIP_MARGIN) return Math.max(extent - TOOLTIP_MARGIN - high, TOOLTIP_MARGIN - low);
305
+ const UNPLACED = { shift: NO_SHIFT, for: null };
306
+ function slideInto(low, high, extent, keepHigh = false) {
307
+ const min = TOOLTIP_MARGIN;
308
+ const max = extent - TOOLTIP_MARGIN;
309
+ if (high - low > max - min) return keepHigh ? max - high : min - low;
310
+ if (low < min) return min - low;
311
+ if (high > max) return max - high;
180
312
  return 0;
181
313
  }
182
- function useViewportClamp(bubbleRef, active, side, label) {
183
- const [shift, setShift] = useState(NO_SHIFT);
314
+ function viewportSize() {
315
+ return {
316
+ width: document.documentElement.clientWidth || window.innerWidth,
317
+ height: window.innerHeight
318
+ };
319
+ }
320
+ function useViewportClamp(bubbleRef, active, side, label, dir) {
321
+ const [placement, setPlacement] = useState(UNPLACED);
322
+ const measured = placement.for !== null && placement.for.side === side && placement.for.label === label && placement.for.dir === dir;
184
323
  useLayoutEffect(() => {
185
324
  const el = bubbleRef.current;
186
325
  if (!active || !el) {
187
- setShift(NO_SHIFT);
326
+ setPlacement(UNPLACED);
188
327
  return;
189
328
  }
329
+ if (measured) return;
190
330
  const r = el.getBoundingClientRect();
191
- if (r.width === 0 && r.height === 0) return;
331
+ const unlaid = r.width === 0 && r.height === 0;
192
332
  const horizontal = side === "top" || side === "bottom";
193
- setShift((previous) => {
194
- const next = horizontal ? { x: slideInto(r.left - previous.x, r.right - previous.x, window.innerWidth), y: 0 } : { x: 0, y: slideInto(r.top - previous.y, r.bottom - previous.y, window.innerHeight) };
195
- return next.x === previous.x && next.y === previous.y ? previous : next;
333
+ const viewport = viewportSize();
334
+ setPlacement(({ shift: previous }) => {
335
+ const next = unlaid ? previous : horizontal ? { x: slideInto(r.left - previous.x, r.right - previous.x, viewport.width, dir === "rtl"), y: 0 } : { x: 0, y: slideInto(r.top - previous.y, r.bottom - previous.y, viewport.height) };
336
+ const shift = next.x === previous.x && next.y === previous.y ? previous : next;
337
+ return { shift, for: { side, label, dir } };
196
338
  });
197
- }, [bubbleRef, active, side, label]);
198
- return shift;
339
+ }, [bubbleRef, active, measured, side, label, dir]);
340
+ return { shift: placement.shift, measuring: active && !measured };
199
341
  }
200
342
  function shiftStyle(shift) {
201
- return shift.x === 0 && shift.y === 0 ? void 0 : { transform: `translate(${shift.x}px, ${shift.y}px)` };
343
+ if (shift.x === 0 && shift.y === 0) return void 0;
344
+ return shift.x !== 0 ? { marginLeft: shift.x } : { marginTop: shift.y };
202
345
  }
203
346
  const portalTransformBySide = {
204
347
  right: "translate(0, -50%)",
@@ -257,18 +400,14 @@ function PortalTooltip({
257
400
  side,
258
401
  className,
259
402
  redact,
403
+ tap,
260
404
  children,
261
405
  ...rest
262
406
  }) {
263
407
  const triggerRef = useRef(null);
264
- const [visible, setVisible] = useState(false);
265
408
  const [dir, setDir] = useState("ltr");
266
- const show = (el) => {
267
- setDir(dirOf(el));
268
- setVisible(true);
269
- };
270
409
  const id = useId();
271
- useEscapeKey(() => setVisible(false), visible);
410
+ const { open, handlers } = useTooltipTrigger(triggerRef, tap, rest, (el) => setDir(dirOf(el)));
272
411
  return /* @__PURE__ */ jsxs(Fragment, { children: [
273
412
  /* @__PURE__ */ jsx(
274
413
  "span",
@@ -276,14 +415,11 @@ function PortalTooltip({
276
415
  ...rest,
277
416
  ref: triggerRef,
278
417
  className: cn("relative inline-flex", className),
279
- onMouseEnter: (e) => show(e.currentTarget),
280
- onMouseLeave: () => setVisible(false),
281
- onFocus: (e) => show(e.currentTarget),
282
- onBlur: () => setVisible(false),
283
- children: describedBy(children, visible ? id : void 0)
418
+ ...handlers,
419
+ children: describedBy(children, open ? id : void 0)
284
420
  }
285
421
  ),
286
- visible && /* @__PURE__ */ jsx(PortalBubble, { triggerRef, id, label, side, dir, redact })
422
+ open && /* @__PURE__ */ jsx(PortalBubble, { triggerRef, id, label, side, dir, redact })
287
423
  ] });
288
424
  }
289
425
  function PortalBubble({
@@ -303,7 +439,8 @@ function PortalBubble({
303
439
  if (!measured) return null;
304
440
  const next = {
305
441
  size: { width: measured.width, height: measured.height },
306
- viewport: { width: window.innerWidth, height: window.innerHeight }
442
+ // The glass, not the window: see viewportSize (live #381).
443
+ viewport: viewportSize()
307
444
  };
308
445
  return previous && sameRoom(previous, next) ? previous : next;
309
446
  });