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