@eifi1/ui-kit 0.9.0 → 0.11.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.
Files changed (243) hide show
  1. package/README.md +37 -23
  2. package/dist/components/alert-banner.d.ts +57 -5
  3. package/dist/components/alert-banner.js +63 -17
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +6 -0
  6. package/dist/components/breadcrumbs.d.ts +60 -0
  7. package/dist/components/breadcrumbs.js +88 -0
  8. package/dist/components/breadcrumbs.js.map +1 -0
  9. package/dist/components/bulk-action-bar.d.ts +112 -0
  10. package/dist/components/bulk-action-bar.js +196 -0
  11. package/dist/components/bulk-action-bar.js.map +1 -0
  12. package/dist/components/button-group.d.ts +58 -3
  13. package/dist/components/button-group.js +56 -5
  14. package/dist/components/button-group.js.map +1 -1
  15. package/dist/components/calculator.d.ts +6 -0
  16. package/dist/components/calendar-heatmap.d.ts +124 -0
  17. package/dist/components/calendar-heatmap.js +295 -0
  18. package/dist/components/calendar-heatmap.js.map +1 -0
  19. package/dist/components/chip.d.ts +31 -5
  20. package/dist/components/chip.js +85 -5
  21. package/dist/components/chip.js.map +1 -1
  22. package/dist/components/choice-card.d.ts +34 -2
  23. package/dist/components/choice-card.js +49 -0
  24. package/dist/components/choice-card.js.map +1 -1
  25. package/dist/components/copy-button.d.ts +14 -4
  26. package/dist/components/copy-button.js +17 -4
  27. package/dist/components/copy-button.js.map +1 -1
  28. package/dist/components/data-table.js +2 -0
  29. package/dist/components/data-table.js.map +1 -1
  30. package/dist/components/date-picker.d.ts +22 -2
  31. package/dist/components/date-picker.js +11 -3
  32. package/dist/components/date-picker.js.map +1 -1
  33. package/dist/components/description-list.d.ts +43 -6
  34. package/dist/components/description-list.js +91 -11
  35. package/dist/components/description-list.js.map +1 -1
  36. package/dist/components/dialog-frame.d.ts +7 -0
  37. package/dist/components/dialog-frame.js.map +1 -1
  38. package/dist/components/disclosure.d.ts +57 -2
  39. package/dist/components/disclosure.js +24 -12
  40. package/dist/components/disclosure.js.map +1 -1
  41. package/dist/components/field.d.ts +43 -0
  42. package/dist/components/field.js +52 -0
  43. package/dist/components/field.js.map +1 -0
  44. package/dist/components/file-button.d.ts +1 -0
  45. package/dist/components/file-dropzone.d.ts +4 -4
  46. package/dist/components/file-dropzone.js +2 -1
  47. package/dist/components/file-dropzone.js.map +1 -1
  48. package/dist/components/floating-panel.d.ts +147 -4
  49. package/dist/components/floating-panel.js +188 -28
  50. package/dist/components/floating-panel.js.map +1 -1
  51. package/dist/components/full-bleed-dialog.d.ts +23 -4
  52. package/dist/components/full-bleed-dialog.js +11 -2
  53. package/dist/components/full-bleed-dialog.js.map +1 -1
  54. package/dist/components/list.d.ts +187 -0
  55. package/dist/components/list.js +221 -0
  56. package/dist/components/list.js.map +1 -0
  57. package/dist/components/menu-item.d.ts +109 -0
  58. package/dist/components/menu-item.js +88 -0
  59. package/dist/components/menu-item.js.map +1 -0
  60. package/dist/components/mini-calendar.d.ts +70 -5
  61. package/dist/components/mini-calendar.js +166 -62
  62. package/dist/components/mini-calendar.js.map +1 -1
  63. package/dist/components/modal.d.ts +23 -1
  64. package/dist/components/modal.js +36 -10
  65. package/dist/components/modal.js.map +1 -1
  66. package/dist/components/nav-pills.d.ts +66 -0
  67. package/dist/components/nav-pills.js +65 -0
  68. package/dist/components/nav-pills.js.map +1 -0
  69. package/dist/components/number-field.d.ts +6 -0
  70. package/dist/components/number-input.d.ts +6 -0
  71. package/dist/components/numpad-sheet.d.ts +6 -0
  72. package/dist/components/page-contents.js +1 -2
  73. package/dist/components/page-contents.js.map +1 -1
  74. package/dist/components/page-header.d.ts +37 -0
  75. package/dist/components/page-header.js +36 -0
  76. package/dist/components/page-header.js.map +1 -0
  77. package/dist/components/progress-bar.d.ts +31 -2
  78. package/dist/components/progress-bar.js +73 -7
  79. package/dist/components/progress-bar.js.map +1 -1
  80. package/dist/components/series-chart-labels.d.ts +3 -0
  81. package/dist/components/series-chart-labels.js +2 -1
  82. package/dist/components/series-chart-labels.js.map +1 -1
  83. package/dist/components/series-chart.d.ts +32 -0
  84. package/dist/components/series-chart.js +141 -4
  85. package/dist/components/series-chart.js.map +1 -1
  86. package/dist/components/settings-fields.d.ts +1 -0
  87. package/dist/components/status-dot.d.ts +53 -0
  88. package/dist/components/status-dot.js +57 -0
  89. package/dist/components/status-dot.js.map +1 -0
  90. package/dist/components/table.d.ts +78 -13
  91. package/dist/components/table.js +84 -11
  92. package/dist/components/table.js.map +1 -1
  93. package/dist/components/text.d.ts +70 -0
  94. package/dist/components/text.js +24 -0
  95. package/dist/components/text.js.map +1 -0
  96. package/dist/components/time-input.d.ts +1 -0
  97. package/dist/components/toast.d.ts +167 -0
  98. package/dist/components/toast.js +226 -0
  99. package/dist/components/toast.js.map +1 -0
  100. package/dist/components/toggle-group.d.ts +44 -1
  101. package/dist/components/toggle-group.js +73 -4
  102. package/dist/components/toggle-group.js.map +1 -1
  103. package/dist/components/tooltip.d.ts +46 -15
  104. package/dist/components/tooltip.js +87 -47
  105. package/dist/components/tooltip.js.map +1 -1
  106. package/dist/components/ui.d.ts +158 -7
  107. package/dist/components/ui.js +191 -10
  108. package/dist/components/ui.js.map +1 -1
  109. package/dist/components/user-avatar.d.ts +25 -3
  110. package/dist/components/user-avatar.js +32 -3
  111. package/dist/components/user-avatar.js.map +1 -1
  112. package/dist/hooks/use-close-transition.d.ts +7 -1
  113. package/dist/hooks/use-close-transition.js +5 -2
  114. package/dist/hooks/use-close-transition.js.map +1 -1
  115. package/dist/hooks/use-copy-to-clipboard.js +1 -1
  116. package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
  117. package/dist/hooks/use-file-drop.d.ts +1 -0
  118. package/dist/i18n/defaults.d.ts +6 -0
  119. package/dist/i18n/defaults.js +11 -1
  120. package/dist/i18n/defaults.js.map +1 -1
  121. package/dist/i18n/kit-labels.d.ts +11 -0
  122. package/dist/i18n/kit-labels.js.map +1 -1
  123. package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
  124. package/dist/i18n/locales/de-CH.d.ts +6 -0
  125. package/dist/i18n/locales/de-informal.d.ts +6 -0
  126. package/dist/i18n/locales/de.d.ts +6 -0
  127. package/dist/i18n/locales/de.js +30 -2
  128. package/dist/i18n/locales/de.js.map +1 -1
  129. package/dist/i18n/locales/es.d.ts +6 -0
  130. package/dist/i18n/locales/es.js +30 -2
  131. package/dist/i18n/locales/es.js.map +1 -1
  132. package/dist/i18n/locales/fr.d.ts +6 -0
  133. package/dist/i18n/locales/fr.js +30 -2
  134. package/dist/i18n/locales/fr.js.map +1 -1
  135. package/dist/i18n/locales/hu.d.ts +6 -0
  136. package/dist/i18n/locales/hu.js +30 -2
  137. package/dist/i18n/locales/hu.js.map +1 -1
  138. package/dist/i18n/locales/it.d.ts +6 -0
  139. package/dist/i18n/locales/it.js +30 -2
  140. package/dist/i18n/locales/it.js.map +1 -1
  141. package/dist/i18n/locales/zh.d.ts +6 -0
  142. package/dist/i18n/locales/zh.js +30 -2
  143. package/dist/i18n/locales/zh.js.map +1 -1
  144. package/dist/index.d.ts +27 -15
  145. package/dist/index.js +15 -0
  146. package/dist/index.js.map +1 -1
  147. package/dist/lib/clipping.d.ts +25 -0
  148. package/dist/lib/clipping.js +17 -0
  149. package/dist/lib/clipping.js.map +1 -0
  150. package/dist/lib/dates.d.ts +17 -1
  151. package/dist/lib/dates.js +19 -0
  152. package/dist/lib/dates.js.map +1 -1
  153. package/dist/rhf/form.d.ts +1 -0
  154. package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
  155. package/dist/rhf/use-rhf-wizard-step.js +38 -0
  156. package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
  157. package/dist/rhf.d.ts +2 -0
  158. package/dist/rhf.js +1 -0
  159. package/dist/rhf.js.map +1 -1
  160. package/dist/search/command-palette.d.ts +19 -2
  161. package/dist/search/command-palette.js +16 -5
  162. package/dist/search/command-palette.js.map +1 -1
  163. package/dist/search/global-search.d.ts +26 -1
  164. package/dist/search/global-search.js +29 -13
  165. package/dist/search/global-search.js.map +1 -1
  166. package/dist/search.d.ts +1 -1
  167. package/dist/shell/topbar-action-menu.d.ts +73 -3
  168. package/dist/shell/topbar-action-menu.js +100 -27
  169. package/dist/shell/topbar-action-menu.js.map +1 -1
  170. package/dist/shell.d.ts +2 -1
  171. package/dist/wizard/stepper-nav.d.ts +1 -0
  172. package/dist/wizard/stepper-nav.js +1 -1
  173. package/dist/wizard/stepper-nav.js.map +1 -1
  174. package/dist/wizard/types.d.ts +2 -2
  175. package/dist/wizard/types.js.map +1 -1
  176. package/dist/wizard/use-wizard.js +1 -1
  177. package/dist/wizard/use-wizard.js.map +1 -1
  178. package/dist/wizard/wizard-context.d.ts +7 -1
  179. package/dist/wizard/wizard-context.js +4 -0
  180. package/dist/wizard/wizard-context.js.map +1 -1
  181. package/dist/wizard/wizard-summary.js +3 -3
  182. package/dist/wizard/wizard-summary.js.map +1 -1
  183. package/dist/wizard.d.ts +2 -1
  184. package/package.json +16 -12
  185. package/src/components/alert-banner.tsx +149 -21
  186. package/src/components/breadcrumbs.tsx +168 -0
  187. package/src/components/bulk-action-bar.tsx +362 -0
  188. package/src/components/button-group.tsx +124 -4
  189. package/src/components/calendar-heatmap.tsx +504 -0
  190. package/src/components/chip.tsx +128 -7
  191. package/src/components/choice-card.tsx +97 -1
  192. package/src/components/copy-button.tsx +29 -7
  193. package/src/components/data-table.tsx +7 -0
  194. package/src/components/date-picker.tsx +49 -1
  195. package/src/components/description-list.tsx +168 -15
  196. package/src/components/dialog-frame.tsx +7 -0
  197. package/src/components/disclosure.tsx +93 -21
  198. package/src/components/field.tsx +137 -0
  199. package/src/components/file-dropzone.tsx +9 -10
  200. package/src/components/floating-panel.tsx +366 -15
  201. package/src/components/full-bleed-dialog.tsx +42 -5
  202. package/src/components/list.tsx +443 -0
  203. package/src/components/menu-item.tsx +235 -0
  204. package/src/components/mini-calendar.tsx +245 -54
  205. package/src/components/modal.tsx +80 -17
  206. package/src/components/nav-pills.tsx +145 -0
  207. package/src/components/page-contents.tsx +4 -4
  208. package/src/components/page-header.tsx +68 -0
  209. package/src/components/progress-bar.tsx +129 -11
  210. package/src/components/series-chart-labels.ts +4 -0
  211. package/src/components/series-chart.tsx +274 -3
  212. package/src/components/status-dot.tsx +107 -0
  213. package/src/components/table.tsx +176 -17
  214. package/src/components/text.tsx +97 -0
  215. package/src/components/toast.tsx +441 -0
  216. package/src/components/toggle-group.tsx +128 -4
  217. package/src/components/tooltip.tsx +200 -100
  218. package/src/components/ui.tsx +381 -14
  219. package/src/components/user-avatar.tsx +57 -3
  220. package/src/hooks/use-close-transition.ts +14 -5
  221. package/src/hooks/use-copy-to-clipboard.ts +1 -1
  222. package/src/i18n/defaults.ts +10 -0
  223. package/src/i18n/kit-labels.tsx +10 -0
  224. package/src/i18n/locales/de.ts +29 -0
  225. package/src/i18n/locales/es.ts +29 -0
  226. package/src/i18n/locales/fr.ts +29 -0
  227. package/src/i18n/locales/hu.ts +29 -0
  228. package/src/i18n/locales/it.ts +29 -0
  229. package/src/i18n/locales/zh.ts +29 -0
  230. package/src/index.ts +31 -0
  231. package/src/lib/clipping.ts +34 -0
  232. package/src/lib/dates.ts +37 -0
  233. package/src/rhf/use-rhf-wizard-step.ts +113 -0
  234. package/src/rhf.ts +2 -0
  235. package/src/search/command-palette.tsx +42 -5
  236. package/src/search/global-search.tsx +59 -18
  237. package/src/shell/topbar-action-menu.tsx +239 -49
  238. package/src/wizard/stepper-nav.tsx +2 -1
  239. package/src/wizard/types.ts +2 -2
  240. package/src/wizard/use-wizard.ts +3 -3
  241. package/src/wizard/wizard-context.tsx +9 -0
  242. package/src/wizard/wizard-summary.tsx +14 -10
  243. package/tokens.css +97 -0
@@ -8,12 +8,21 @@ import {
8
8
  type ComponentPropsWithoutRef,
9
9
  type ReactElement,
10
10
  type ReactNode,
11
+ type RefObject,
11
12
  } from "react";
12
13
  import { createPortal } from "react-dom";
13
14
  import { cn } from "../lib/cn";
14
15
  import { useEscapeKey } from "../hooks/use-dismiss";
15
16
  import { useAnchoredRect, type AnchorRect } from "../hooks/use-anchored-rect";
16
17
  import { dirOf, type Direction } from "../lib/direction";
18
+ import { hasClippingAncestor } from "../lib/clipping";
19
+
20
+ /** The attribute that marks an element as a clipping container for {@link Tooltip}'s
21
+ * auto-portal, whatever its computed `overflow` — `<div data-clips>` or
22
+ * `<div {...{ [CLIPS_ATTRIBUTE]: "" }}>`. Put it on an app's own scroller so a test
23
+ * environment without stylesheets (jsdom) portals the same tooltips the browser
24
+ * does. DataTable's body and Table's wrapper already carry it. */
25
+ export { CLIPS_ATTRIBUTE } from "../lib/clipping";
17
26
 
18
27
  /** Where the bubble sits. `start` / `end` follow the reading direction — `end` is the
19
28
  * right in LTR and the left in RTL — and are what a layout that mirrors should ask
@@ -70,6 +79,13 @@ export interface TooltipProps extends ComponentPropsWithoutRef<"span"> {
70
79
  label: ReactNode;
71
80
  side?: TooltipSide;
72
81
  className?: string;
82
+ /**
83
+ * Where the bubble lives. Left out (the default since 0.10.0), the tooltip decides for
84
+ * itself: the bubble stays next to the trigger unless an ancestor clips or scrolls
85
+ * (`overflow` other than `visible`, or the {@link CLIPS_ATTRIBUTE} marker), in which
86
+ * case it is portalled — see "Inside a scroll container" below. `true` always portals, `false` never does; both are exactly
87
+ * what they were before the default existed.
88
+ */
73
89
  portal?: boolean;
74
90
  /** Tag the bubble `data-private`, for a label that repeats the user's own data. */
75
91
  redact?: boolean;
@@ -83,11 +99,10 @@ type TooltipVariantProps = Omit<TooltipProps, "side" | "portal"> & { side: Toolt
83
99
  /**
84
100
  * Hover/focus label for a control.
85
101
  *
86
- * Two implementations, and the choice matters more than it looks. The default is
87
- * CSS-only: the bubble is always mounted next to the trigger and fades in on
88
- * `:hover`, which costs no state and works in a plain render test. The `portal`
89
- * variant mounts the bubble in `document.body` only while hovered, positioned by
90
- * measurement.
102
+ * Two placements, and the choice matters more than it looks. In place, the bubble is
103
+ * always mounted next to the trigger and fades in on `:hover`, which costs no state and
104
+ * works in a plain render test. Portalled, the bubble is mounted in `document.body`
105
+ * only while it is up, positioned by measurement.
91
106
  *
92
107
  * ⚠️ **A bubble that repeats a value has to be redactable.** The consuming app blurs
93
108
  * `[data-private]` under a `demo-mode` class on `<html>` — and the portalled bubble is
@@ -119,24 +134,48 @@ type TooltipVariantProps = Omit<TooltipProps, "side" | "portal"> & { side: Toolt
119
134
  * point somewhere else — which is precisely what you cannot do when what you need to
120
135
  * read is underneath it. The listener is the document's rather than the wrapper's
121
136
  * because the pointer opens this with the keyboard focus somewhere else entirely, and
122
- * it is subscribed only while a bubble is actually up: the CSS variant is always
137
+ * it is subscribed only while a bubble is actually up: the in-place bubble is always
123
138
  * mounted, and a table of forty tooltips must not mean forty keydown listeners.
124
139
  *
125
- * ⚠️ **Inside a scroll container, use `portal`.** An always-mounted bubble is
126
- * absolutely positioned, but an absolutely positioned descendant still counts
127
- * towards its scroll-container ancestor's scrollable overflow — so an invisible
128
- * bubble on a control near the right edge makes the container scroll sideways
129
- * with nothing to reveal. That is what Keksdose feedback dev#488 reported on the
130
- * admin roster: 66px of horizontal scroll on a table that fit, 44px of it owed to
131
- * tooltips nobody could see. The portalled bubble is `position: fixed` and absent
132
- * until hovered, so it adds no width — and, being outside the container, it also
133
- * cannot be clipped by it.
140
+ * ⚠️ **Inside a scroll container, the bubble has to be portalled — and by default it
141
+ * now is.** An always-mounted bubble is absolutely positioned, but an absolutely
142
+ * positioned descendant still counts towards its scroll-container ancestor's
143
+ * scrollable overflow — so an invisible bubble on a control near the right edge makes
144
+ * the container scroll sideways with nothing to reveal. That is what Keksdose feedback
145
+ * dev#488 reported on the admin roster: 66px of horizontal scroll on a table that fit,
146
+ * 44px of it owed to tooltips nobody could see. And a visible one is clipped by the
147
+ * container's edge. The portalled bubble is `position: fixed` and absent until hovered,
148
+ * so it adds no width and cannot be clipped.
149
+ *
150
+ * Until 0.10.0 the cure was `portal` at the call site, and kastlan asked for it to stop
151
+ * being one: every tooltip in a table, a drawer or a scrolling card had to remember it,
152
+ * and the ones that forgot were only found by someone scrolling sideways. So with
153
+ * `portal` left out, the tooltip looks for a clipping ancestor itself — any element
154
+ * between it and `<body>` whose computed `overflow-x` / `overflow-y` is not `visible`,
155
+ * or that carries {@link CLIPS_ATTRIBUTE} (`data-clips`). The marker is what makes the
156
+ * answer the same under test: jsdom computes no Tailwind, so there every scroller
157
+ * reads `visible` and a table-cell tooltip used to stay in place — its always-mounted
158
+ * bubble then repeated the label in the cell's accessible name and `textContent`
159
+ * ("CheckingChecking"), and apps pinned `portal` to stop it. The kit's own scrollers
160
+ * are marked, so a tooltip in a DataTable or Table cell portals in jsdom exactly as it
161
+ * does in the browser.
162
+ * It looks at MOUNT, not only on open: dev#488's phantom scroll is caused by a bubble
163
+ * nobody opened, so a check that waited for the hover would find the damage already
164
+ * done. It looks again on every open, for a container that started scrolling after
165
+ * the tooltip mounted (a table that grew). Only the bubble changes place; the trigger
166
+ * and the caller's child stay mounted, so a switch never costs a focused button its
167
+ * focus. Outside any such container the in-place bubble is kept, which is still the
168
+ * cheaper one and the one a plain render test can find without a hover.
169
+ *
170
+ * Why not simply portal everything? Because the in-place bubble is the one existing
171
+ * app tests rely on (it is in the DOM without a hover), and because it follows its
172
+ * trigger through a scroll or an animation for free — the portalled one re-measures.
134
173
  */
135
174
  export function Tooltip({
136
175
  label,
137
176
  side = "top",
138
177
  className,
139
- portal = false,
178
+ portal,
140
179
  redact = false,
141
180
  children,
142
181
  ...rest
@@ -146,7 +185,7 @@ export function Tooltip({
146
185
  // wrapper on this branch, which is the documented limit of the pass-through: there is
147
186
  // no element left to put an attribute on.
148
187
  if (isEmptyLabel(label)) return <>{children}</>;
149
- if (portal) {
188
+ if (portal === true) {
150
189
  return (
151
190
  <PortalTooltip
152
191
  label={label}
@@ -163,36 +202,73 @@ export function Tooltip({
163
202
  // hooks: the empty-label branch above returns before either of them, and a hook after
164
203
  // a conditional return is a hooks-order bug rather than a style violation.
165
204
  return (
166
- <CssTooltip label={label} side={side} className={className} redact={redact} {...rest}>
205
+ <InPlaceTooltip
206
+ label={label}
207
+ side={side}
208
+ className={className}
209
+ redact={redact}
210
+ detect={portal === undefined}
211
+ {...rest}
212
+ >
167
213
  {children}
168
- </CssTooltip>
214
+ </InPlaceTooltip>
169
215
  );
170
216
  }
171
217
 
172
- /** The always-mounted variant: the bubble sits next to the trigger and CSS fades it in.
218
+
219
+ /** The variant that lives next to its trigger, and — when `detect` is on, which is the
220
+ * default — moves its bubble to `<body>` when that turns out to be inside a clipping
221
+ * container (see "Inside a scroll container" on {@link Tooltip}).
222
+ *
223
+ * In place it holds the little state it does for the two things CSS cannot express —
224
+ * which element to point `aria-describedby` at, and Escape — and not for the fade,
225
+ * which is still `group-hover`/`group-focus-within` and still costs a render nothing.
173
226
  *
174
- * It holds the little state it does for the two things CSS cannot express — which
175
- * element to point `aria-describedby` at, and Escape — and not for the fade, which is
176
- * still `group-hover`/`group-focus-within` and still costs a render nothing. */
177
- function CssTooltip({
227
+ * ONE component for both placements rather than a switch between the two variants: a
228
+ * switch would be a different component at the same place in the tree, and React
229
+ * would remount the caller's child with it — a button that loses focus the moment
230
+ * its own tooltip decided where to go. Here the wrapper and the child stay put and
231
+ * only the bubble's slot changes. */
232
+ function InPlaceTooltip({
178
233
  label,
179
234
  side,
180
235
  className,
181
236
  redact,
237
+ detect,
182
238
  children,
183
239
  ...rest
184
- }: TooltipVariantProps) {
240
+ }: TooltipVariantProps & { detect: boolean }) {
185
241
  const id = useId();
242
+ const triggerRef = useRef<HTMLSpanElement | null>(null);
243
+ const [clipped, setClipped] = useState(false);
186
244
  const [hovered, setHovered] = useState(false);
187
245
  const [focused, setFocused] = useState(false);
188
246
  const [dismissed, setDismissed] = useState(false);
189
- useEscapeKey(() => setDismissed(true), (hovered || focused) && !dismissed);
247
+ const [dir, setDir] = useState<Direction>("ltr");
248
+ const open = (hovered || focused) && !dismissed;
249
+ useEscapeKey(() => setDismissed(true), open);
250
+ // At mount, before the first paint: the in-place bubble inside a scroller is the
251
+ // phantom-scroll bug whether or not anyone opens it.
252
+ useLayoutEffect(() => {
253
+ if (detect) setClipped(hasClippingAncestor(triggerRef.current));
254
+ }, [detect]);
255
+ // Re-armed by the next hover or focus rather than by an effect watching those flags:
256
+ // coming back to a trigger is a fresh request for its label, and an effect would also
257
+ // re-show the bubble under a pointer that never left. The clipping check is repeated
258
+ // here for a container that began to scroll after mount.
259
+ const arm = (el: Element) => {
260
+ setDismissed(false);
261
+ if (!detect) return;
262
+ setDir(dirOf(el));
263
+ setClipped(hasClippingAncestor(el));
264
+ };
190
265
  return (
191
266
  <span
192
267
  // `...rest` first: the four handlers below are what decides whether a bubble is
193
268
  // up, and a caller passing an `onFocus` of its own must not replace them.
194
269
  {...rest}
195
- className={cn("relative inline-flex group/tooltip", className)}
270
+ ref={triggerRef}
271
+ className={cn("relative inline-flex", !clipped && "group/tooltip", className)}
196
272
  // These four track WHETHER A BUBBLE IS UP. They activate nothing — the only thing
197
273
  // here that can be activated is the caller's child, which keeps every handler it
198
274
  // arrived with — so this wrapper needs no role and no key handling of its own.
@@ -200,38 +276,41 @@ function CssTooltip({
200
276
  // already does about the portal variant's identical trigger below; both are left
201
277
  // visible rather than silenced, because a rule this package ratchets should be
202
278
  // argued with in the backlog and not in a disable comment.
203
- //
204
- // Re-armed by the next hover or focus rather than by an effect watching those
205
- // flags: coming back to a trigger is a fresh request for its label, and an effect
206
- // would also re-show the bubble under a pointer that never left.
207
- onMouseEnter={() => {
279
+ onMouseEnter={(e) => {
208
280
  setHovered(true);
209
- setDismissed(false);
281
+ arm(e.currentTarget);
210
282
  }}
211
283
  onMouseLeave={() => setHovered(false)}
212
- onFocus={() => {
284
+ onFocus={(e) => {
213
285
  setFocused(true);
214
- setDismissed(false);
286
+ arm(e.currentTarget);
215
287
  }}
216
288
  onBlur={() => setFocused(false)}
217
289
  >
218
- {describedBy(children, dismissed ? undefined : id)}
219
- <span
220
- id={id}
221
- role="tooltip"
222
- // The `hidden` ATTRIBUTE, not an opacity class: dismissing has to take the
223
- // bubble out of the accessibility tree as well as off the screen, or a screen
224
- // reader still reads out the description of a bubble the user just closed.
225
- hidden={dismissed || undefined}
226
- data-private={redact ? "" : undefined}
227
- className={cn(
228
- TOOLTIP_SURFACE,
229
- "pointer-events-none absolute z-50 opacity-0 group-hover/tooltip:opacity-100 group-focus-within/tooltip:opacity-100",
230
- sidePositionClass[side],
231
- )}
232
- >
233
- {label}
234
- </span>
290
+ {/* In place the bubble is always there to point at; portalled, only while up. */}
291
+ {describedBy(children, clipped ? (open ? id : undefined) : dismissed ? undefined : id)}
292
+ {clipped ? (
293
+ open && (
294
+ <PortalBubble triggerRef={triggerRef} id={id} label={label} side={side} dir={dir} redact={redact} />
295
+ )
296
+ ) : (
297
+ <span
298
+ id={id}
299
+ role="tooltip"
300
+ // The `hidden` ATTRIBUTE, not an opacity class: dismissing has to take the
301
+ // bubble out of the accessibility tree as well as off the screen, or a screen
302
+ // reader still reads out the description of a bubble the user just closed.
303
+ hidden={dismissed || undefined}
304
+ data-private={redact ? "" : undefined}
305
+ className={cn(
306
+ TOOLTIP_SURFACE,
307
+ "pointer-events-none absolute z-50 opacity-0 group-hover/tooltip:opacity-100 group-focus-within/tooltip:opacity-100",
308
+ sidePositionClass[side],
309
+ )}
310
+ >
311
+ {label}
312
+ </span>
313
+ )}
235
314
  </span>
236
315
  );
237
316
  }
@@ -410,7 +489,6 @@ function PortalTooltip({
410
489
  ...rest
411
490
  }: TooltipVariantProps) {
412
491
  const triggerRef = useRef<HTMLSpanElement | null>(null);
413
- const bubbleRef = useRef<HTMLSpanElement | null>(null);
414
492
  const [visible, setVisible] = useState(false);
415
493
  // The trigger's reading direction, read when the bubble is asked for (an event, not a
416
494
  // render): it resolves `start` / `end`, and the portalled bubble — which has left the
@@ -425,9 +503,53 @@ function PortalTooltip({
425
503
  // shown. The next mouseenter/focus brings it back, which is the behaviour WCAG
426
504
  // 1.4.13 asks for: dismissible now, still available when you ask again.
427
505
  useEscapeKey(() => setVisible(false), visible);
506
+
507
+ return (
508
+ <>
509
+ <span
510
+ // As in `InPlaceTooltip`: the caller's attributes first, the four handlers that
511
+ // run this component after them. The BUBBLE is deliberately not given them — it
512
+ // is portalled to `<body>`, and an id or a tour anchor duplicated onto a node
513
+ // that only exists while hovered would match twice or match nothing.
514
+ {...rest}
515
+ ref={triggerRef}
516
+ className={cn("relative inline-flex", className)}
517
+ onMouseEnter={(e) => show(e.currentTarget)}
518
+ onMouseLeave={() => setVisible(false)}
519
+ onFocus={(e) => show(e.currentTarget)}
520
+ onBlur={() => setVisible(false)}
521
+ >
522
+ {describedBy(children, visible ? id : undefined)}
523
+ </span>
524
+ {visible && (
525
+ <PortalBubble triggerRef={triggerRef} id={id} label={label} side={side} dir={dir} redact={redact} />
526
+ )}
527
+ </>
528
+ );
529
+ }
530
+
531
+ /** The measured, `position: fixed` bubble on `<body>`, mounted only while it is up.
532
+ * Shared by {@link PortalTooltip} and the in-place variant's clipped mode, so the two
533
+ * cannot place a bubble differently. */
534
+ function PortalBubble({
535
+ triggerRef,
536
+ id,
537
+ label,
538
+ side,
539
+ dir,
540
+ redact,
541
+ }: {
542
+ triggerRef: RefObject<HTMLSpanElement | null>;
543
+ id: string;
544
+ label: ReactNode;
545
+ side: TooltipSide;
546
+ dir: Direction;
547
+ redact: boolean | undefined;
548
+ }) {
549
+ const bubbleRef = useRef<HTMLSpanElement | null>(null);
428
550
  // The measure + scroll/resize-tracking lifecycle is owned by useAnchoredRect;
429
551
  // here we only map the rect to a side-specific anchor point.
430
- const rect = useAnchoredRect(triggerRef, visible);
552
+ const rect = useAnchoredRect(triggerRef, true);
431
553
  // The bubble's own size and the window it has to fit in — neither of which is
432
554
  // knowable in render: the width is whatever the label wrapped to inside the
433
555
  // cap, and reading `window` while rendering is not a pure thing to do. Both
@@ -435,8 +557,7 @@ function PortalTooltip({
435
557
  // paints and there is no frame in which the label sits off the screen.
436
558
  const [room, setRoom] = useState<{ size: TooltipSize; viewport: TooltipViewport } | null>(null);
437
559
  useLayoutEffect(() => {
438
- const measured = visible ? bubbleRef.current?.getBoundingClientRect() : undefined;
439
- // eslint-disable-next-line react-hooks/set-state-in-effect -- a measurement is the one thing a layout effect is for
560
+ const measured = bubbleRef.current?.getBoundingClientRect();
440
561
  setRoom((previous) => {
441
562
  if (!measured) return null;
442
563
  const next = {
@@ -448,7 +569,7 @@ function PortalTooltip({
448
569
  // bubble forever.
449
570
  return previous && sameRoom(previous, next) ? previous : next;
450
571
  });
451
- }, [visible, rect, label]);
572
+ }, [rect, label]);
452
573
 
453
574
  const physical = physicalSide(side, dir);
454
575
  const point = rect ? tooltipAnchor(rect, physical) : null;
@@ -457,49 +578,28 @@ function PortalTooltip({
457
578
  // size is known and the placement is decided properly.
458
579
  const placed = rect && room ? placeTooltip(rect, physical, room.size, room.viewport) : null;
459
580
 
460
- return (
461
- <>
462
- <span
463
- // As in `CssTooltip`: the caller's attributes first, the four handlers that run
464
- // this component after them. The BUBBLE is deliberately not given them — it is
465
- // portalled to `<body>`, and an id or a tour anchor duplicated onto a node that
466
- // only exists while hovered would match twice or match nothing.
467
- {...rest}
468
- ref={triggerRef}
469
- className={cn("relative inline-flex", className)}
470
- onMouseEnter={(e) => show(e.currentTarget)}
471
- onMouseLeave={() => setVisible(false)}
472
- onFocus={(e) => show(e.currentTarget)}
473
- onBlur={() => setVisible(false)}
474
- >
475
- {describedBy(children, visible ? id : undefined)}
476
- </span>
477
- {visible &&
478
- point &&
479
- typeof document !== "undefined" &&
480
- createPortal(
481
- <span
482
- ref={bubbleRef}
483
- id={id}
484
- role="tooltip"
485
- dir={dir}
486
- data-private={redact ? "" : undefined}
487
- style={
488
- placed
489
- ? { position: "fixed", left: placed.left, top: placed.top }
490
- : {
491
- position: "fixed",
492
- left: point.left,
493
- top: point.top,
494
- transform: portalTransformBySide[physical],
495
- }
581
+ if (!point || typeof document === "undefined") return null;
582
+ return createPortal(
583
+ <span
584
+ ref={bubbleRef}
585
+ id={id}
586
+ role="tooltip"
587
+ dir={dir}
588
+ data-private={redact ? "" : undefined}
589
+ style={
590
+ placed
591
+ ? { position: "fixed", left: placed.left, top: placed.top }
592
+ : {
593
+ position: "fixed",
594
+ left: point.left,
595
+ top: point.top,
596
+ transform: portalTransformBySide[physical],
496
597
  }
497
- className={cn(TOOLTIP_SURFACE, "pointer-events-none z-50")}
498
- >
499
- {label}
500
- </span>,
501
- document.body,
502
- )}
503
- </>
598
+ }
599
+ className={cn(TOOLTIP_SURFACE, "pointer-events-none z-50")}
600
+ >
601
+ {label}
602
+ </span>,
603
+ document.body,
504
604
  );
505
605
  }