@eifi1/ui-kit 0.9.0 → 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 (176) hide show
  1. package/README.md +21 -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 +4 -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 +4 -0
  13. package/dist/components/chip.d.ts +31 -5
  14. package/dist/components/chip.js +85 -5
  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/date-picker.d.ts +22 -2
  20. package/dist/components/date-picker.js +11 -3
  21. package/dist/components/date-picker.js.map +1 -1
  22. package/dist/components/description-list.d.ts +43 -6
  23. package/dist/components/description-list.js +91 -11
  24. package/dist/components/description-list.js.map +1 -1
  25. package/dist/components/dialog-frame.d.ts +7 -0
  26. package/dist/components/dialog-frame.js.map +1 -1
  27. package/dist/components/disclosure.d.ts +57 -2
  28. package/dist/components/disclosure.js +24 -12
  29. package/dist/components/disclosure.js.map +1 -1
  30. package/dist/components/file-dropzone.d.ts +3 -4
  31. package/dist/components/file-dropzone.js +2 -1
  32. package/dist/components/file-dropzone.js.map +1 -1
  33. package/dist/components/full-bleed-dialog.d.ts +23 -4
  34. package/dist/components/full-bleed-dialog.js +11 -2
  35. package/dist/components/full-bleed-dialog.js.map +1 -1
  36. package/dist/components/list.d.ts +174 -0
  37. package/dist/components/list.js +215 -0
  38. package/dist/components/list.js.map +1 -0
  39. package/dist/components/menu-item.d.ts +99 -0
  40. package/dist/components/menu-item.js +83 -0
  41. package/dist/components/menu-item.js.map +1 -0
  42. package/dist/components/modal.d.ts +23 -1
  43. package/dist/components/modal.js +36 -10
  44. package/dist/components/modal.js.map +1 -1
  45. package/dist/components/number-field.d.ts +4 -0
  46. package/dist/components/number-input.d.ts +4 -0
  47. package/dist/components/numpad-sheet.d.ts +4 -0
  48. package/dist/components/page-contents.js +1 -2
  49. package/dist/components/page-contents.js.map +1 -1
  50. package/dist/components/page-header.d.ts +37 -0
  51. package/dist/components/page-header.js +36 -0
  52. package/dist/components/page-header.js.map +1 -0
  53. package/dist/components/series-chart-labels.d.ts +3 -0
  54. package/dist/components/series-chart-labels.js +2 -1
  55. package/dist/components/series-chart-labels.js.map +1 -1
  56. package/dist/components/series-chart.d.ts +16 -0
  57. package/dist/components/series-chart.js +137 -3
  58. package/dist/components/series-chart.js.map +1 -1
  59. package/dist/components/status-dot.d.ts +49 -0
  60. package/dist/components/status-dot.js +52 -0
  61. package/dist/components/status-dot.js.map +1 -0
  62. package/dist/components/table.d.ts +39 -6
  63. package/dist/components/table.js +43 -4
  64. package/dist/components/table.js.map +1 -1
  65. package/dist/components/text.d.ts +63 -0
  66. package/dist/components/text.js +23 -0
  67. package/dist/components/text.js.map +1 -0
  68. package/dist/components/toast.d.ts +167 -0
  69. package/dist/components/toast.js +226 -0
  70. package/dist/components/toast.js.map +1 -0
  71. package/dist/components/toggle-group.d.ts +29 -1
  72. package/dist/components/toggle-group.js +52 -3
  73. package/dist/components/toggle-group.js.map +1 -1
  74. package/dist/components/tooltip.d.ts +38 -15
  75. package/dist/components/tooltip.js +85 -47
  76. package/dist/components/tooltip.js.map +1 -1
  77. package/dist/components/ui.d.ts +104 -4
  78. package/dist/components/ui.js +112 -8
  79. package/dist/components/ui.js.map +1 -1
  80. package/dist/hooks/use-close-transition.d.ts +7 -1
  81. package/dist/hooks/use-close-transition.js +5 -2
  82. package/dist/hooks/use-close-transition.js.map +1 -1
  83. package/dist/hooks/use-copy-to-clipboard.js +1 -1
  84. package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
  85. package/dist/i18n/defaults.d.ts +4 -0
  86. package/dist/i18n/defaults.js +9 -1
  87. package/dist/i18n/defaults.js.map +1 -1
  88. package/dist/i18n/kit-labels.d.ts +8 -0
  89. package/dist/i18n/kit-labels.js.map +1 -1
  90. package/dist/i18n/locales/de-CH-informal.d.ts +4 -0
  91. package/dist/i18n/locales/de-CH.d.ts +4 -0
  92. package/dist/i18n/locales/de-informal.d.ts +4 -0
  93. package/dist/i18n/locales/de.d.ts +4 -0
  94. package/dist/i18n/locales/de.js +21 -1
  95. package/dist/i18n/locales/de.js.map +1 -1
  96. package/dist/i18n/locales/es.d.ts +4 -0
  97. package/dist/i18n/locales/es.js +21 -1
  98. package/dist/i18n/locales/es.js.map +1 -1
  99. package/dist/i18n/locales/fr.d.ts +4 -0
  100. package/dist/i18n/locales/fr.js +21 -1
  101. package/dist/i18n/locales/fr.js.map +1 -1
  102. package/dist/i18n/locales/hu.d.ts +4 -0
  103. package/dist/i18n/locales/hu.js +21 -1
  104. package/dist/i18n/locales/hu.js.map +1 -1
  105. package/dist/i18n/locales/it.d.ts +4 -0
  106. package/dist/i18n/locales/it.js +21 -1
  107. package/dist/i18n/locales/it.js.map +1 -1
  108. package/dist/i18n/locales/zh.d.ts +4 -0
  109. package/dist/i18n/locales/zh.js +21 -1
  110. package/dist/i18n/locales/zh.js.map +1 -1
  111. package/dist/index.d.ts +15 -7
  112. package/dist/index.js +12 -0
  113. package/dist/index.js.map +1 -1
  114. package/dist/lib/clipping.d.ts +9 -0
  115. package/dist/lib/clipping.js +14 -0
  116. package/dist/lib/clipping.js.map +1 -0
  117. package/dist/search/command-palette.d.ts +19 -2
  118. package/dist/search/command-palette.js +16 -5
  119. package/dist/search/command-palette.js.map +1 -1
  120. package/dist/search/global-search.d.ts +26 -1
  121. package/dist/search/global-search.js +29 -13
  122. package/dist/search/global-search.js.map +1 -1
  123. package/dist/search.d.ts +1 -1
  124. package/dist/wizard/stepper-nav.js +1 -1
  125. package/dist/wizard/stepper-nav.js.map +1 -1
  126. package/dist/wizard/types.d.ts +2 -2
  127. package/dist/wizard/types.js.map +1 -1
  128. package/dist/wizard/use-wizard.js +1 -1
  129. package/dist/wizard/use-wizard.js.map +1 -1
  130. package/dist/wizard/wizard-summary.js +3 -3
  131. package/dist/wizard/wizard-summary.js.map +1 -1
  132. package/package.json +16 -12
  133. package/src/components/alert-banner.tsx +117 -18
  134. package/src/components/breadcrumbs.tsx +168 -0
  135. package/src/components/bulk-action-bar.tsx +256 -0
  136. package/src/components/chip.tsx +128 -7
  137. package/src/components/copy-button.tsx +29 -7
  138. package/src/components/date-picker.tsx +49 -1
  139. package/src/components/description-list.tsx +168 -15
  140. package/src/components/dialog-frame.tsx +7 -0
  141. package/src/components/disclosure.tsx +93 -21
  142. package/src/components/file-dropzone.tsx +9 -10
  143. package/src/components/full-bleed-dialog.tsx +42 -5
  144. package/src/components/list.tsx +417 -0
  145. package/src/components/menu-item.tsx +214 -0
  146. package/src/components/modal.tsx +80 -17
  147. package/src/components/page-contents.tsx +4 -4
  148. package/src/components/page-header.tsx +68 -0
  149. package/src/components/series-chart-labels.ts +4 -0
  150. package/src/components/series-chart.tsx +253 -3
  151. package/src/components/status-dot.tsx +99 -0
  152. package/src/components/table.tsx +91 -6
  153. package/src/components/text.tsx +89 -0
  154. package/src/components/toast.tsx +441 -0
  155. package/src/components/toggle-group.tsx +82 -3
  156. package/src/components/tooltip.tsx +186 -100
  157. package/src/components/ui.tsx +237 -8
  158. package/src/hooks/use-close-transition.ts +14 -5
  159. package/src/hooks/use-copy-to-clipboard.ts +1 -1
  160. package/src/i18n/defaults.ts +8 -0
  161. package/src/i18n/kit-labels.tsx +8 -0
  162. package/src/i18n/locales/de.ts +20 -0
  163. package/src/i18n/locales/es.ts +20 -0
  164. package/src/i18n/locales/fr.ts +20 -0
  165. package/src/i18n/locales/hu.ts +20 -0
  166. package/src/i18n/locales/it.ts +20 -0
  167. package/src/i18n/locales/zh.ts +20 -0
  168. package/src/index.ts +26 -0
  169. package/src/lib/clipping.ts +16 -0
  170. package/src/search/command-palette.tsx +42 -5
  171. package/src/search/global-search.tsx +59 -18
  172. package/src/wizard/stepper-nav.tsx +2 -1
  173. package/src/wizard/types.ts +2 -2
  174. package/src/wizard/use-wizard.ts +3 -3
  175. package/src/wizard/wizard-summary.tsx +14 -10
  176. package/tokens.css +97 -0
@@ -13,6 +13,7 @@ import { cn } from "../lib/cn.js";
13
13
  import { useEscapeKey } from "../hooks/use-dismiss.js";
14
14
  import { useAnchoredRect } from "../hooks/use-anchored-rect.js";
15
15
  import { dirOf } from "../lib/direction.js";
16
+ import { hasClippingAncestor } from "../lib/clipping.js";
16
17
  function physicalSide(side, dir) {
17
18
  if (side === "start") return dir === "rtl" ? "right" : "left";
18
19
  if (side === "end") return dir === "rtl" ? "left" : "right";
@@ -33,13 +34,13 @@ function Tooltip({
33
34
  label,
34
35
  side = "top",
35
36
  className,
36
- portal = false,
37
+ portal,
37
38
  redact = false,
38
39
  children,
39
40
  ...rest
40
41
  }) {
41
42
  if (isEmptyLabel(label)) return /* @__PURE__ */ jsx(Fragment, { children });
42
- if (portal) {
43
+ if (portal === true) {
43
44
  return /* @__PURE__ */ jsx(
44
45
  PortalTooltip,
45
46
  {
@@ -52,39 +53,65 @@ function Tooltip({
52
53
  }
53
54
  );
54
55
  }
55
- return /* @__PURE__ */ jsx(CssTooltip, { label, side, className, redact, ...rest, children });
56
+ return /* @__PURE__ */ jsx(
57
+ InPlaceTooltip,
58
+ {
59
+ label,
60
+ side,
61
+ className,
62
+ redact,
63
+ detect: portal === void 0,
64
+ ...rest,
65
+ children
66
+ }
67
+ );
56
68
  }
57
- function CssTooltip({
69
+ function InPlaceTooltip({
58
70
  label,
59
71
  side,
60
72
  className,
61
73
  redact,
74
+ detect,
62
75
  children,
63
76
  ...rest
64
77
  }) {
65
78
  const id = useId();
79
+ const triggerRef = useRef(null);
80
+ const [clipped, setClipped] = useState(false);
66
81
  const [hovered, setHovered] = useState(false);
67
82
  const [focused, setFocused] = useState(false);
68
83
  const [dismissed, setDismissed] = useState(false);
69
- useEscapeKey(() => setDismissed(true), (hovered || focused) && !dismissed);
84
+ const [dir, setDir] = useState("ltr");
85
+ const open = (hovered || focused) && !dismissed;
86
+ useEscapeKey(() => setDismissed(true), open);
87
+ useLayoutEffect(() => {
88
+ if (detect) setClipped(hasClippingAncestor(triggerRef.current));
89
+ }, [detect]);
90
+ const arm = (el) => {
91
+ setDismissed(false);
92
+ if (!detect) return;
93
+ setDir(dirOf(el));
94
+ setClipped(hasClippingAncestor(el));
95
+ };
70
96
  return /* @__PURE__ */ jsxs(
71
97
  "span",
72
98
  {
73
99
  ...rest,
74
- className: cn("relative inline-flex group/tooltip", className),
75
- onMouseEnter: () => {
100
+ ref: triggerRef,
101
+ className: cn("relative inline-flex", !clipped && "group/tooltip", className),
102
+ onMouseEnter: (e) => {
76
103
  setHovered(true);
77
- setDismissed(false);
104
+ arm(e.currentTarget);
78
105
  },
79
106
  onMouseLeave: () => setHovered(false),
80
- onFocus: () => {
107
+ onFocus: (e) => {
81
108
  setFocused(true);
82
- setDismissed(false);
109
+ arm(e.currentTarget);
83
110
  },
84
111
  onBlur: () => setFocused(false),
85
112
  children: [
86
- describedBy(children, dismissed ? void 0 : id),
87
- /* @__PURE__ */ jsx(
113
+ describedBy(children, clipped ? open ? id : void 0 : dismissed ? void 0 : id),
114
+ clipped ? open && /* @__PURE__ */ jsx(PortalBubble, { triggerRef, id, label, side, dir, redact }) : /* @__PURE__ */ jsx(
88
115
  "span",
89
116
  {
90
117
  id,
@@ -176,7 +203,6 @@ function PortalTooltip({
176
203
  ...rest
177
204
  }) {
178
205
  const triggerRef = useRef(null);
179
- const bubbleRef = useRef(null);
180
206
  const [visible, setVisible] = useState(false);
181
207
  const [dir, setDir] = useState("ltr");
182
208
  const show = (el) => {
@@ -185,10 +211,36 @@ function PortalTooltip({
185
211
  };
186
212
  const id = useId();
187
213
  useEscapeKey(() => setVisible(false), visible);
188
- const rect = useAnchoredRect(triggerRef, visible);
214
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
215
+ /* @__PURE__ */ jsx(
216
+ "span",
217
+ {
218
+ ...rest,
219
+ ref: triggerRef,
220
+ className: cn("relative inline-flex", className),
221
+ onMouseEnter: (e) => show(e.currentTarget),
222
+ onMouseLeave: () => setVisible(false),
223
+ onFocus: (e) => show(e.currentTarget),
224
+ onBlur: () => setVisible(false),
225
+ children: describedBy(children, visible ? id : void 0)
226
+ }
227
+ ),
228
+ visible && /* @__PURE__ */ jsx(PortalBubble, { triggerRef, id, label, side, dir, redact })
229
+ ] });
230
+ }
231
+ function PortalBubble({
232
+ triggerRef,
233
+ id,
234
+ label,
235
+ side,
236
+ dir,
237
+ redact
238
+ }) {
239
+ const bubbleRef = useRef(null);
240
+ const rect = useAnchoredRect(triggerRef, true);
189
241
  const [room, setRoom] = useState(null);
190
242
  useLayoutEffect(() => {
191
- const measured = visible ? bubbleRef.current?.getBoundingClientRect() : void 0;
243
+ const measured = bubbleRef.current?.getBoundingClientRect();
192
244
  setRoom((previous) => {
193
245
  if (!measured) return null;
194
246
  const next = {
@@ -197,46 +249,32 @@ function PortalTooltip({
197
249
  };
198
250
  return previous && sameRoom(previous, next) ? previous : next;
199
251
  });
200
- }, [visible, rect, label]);
252
+ }, [rect, label]);
201
253
  const physical = physicalSide(side, dir);
202
254
  const point = rect ? tooltipAnchor(rect, physical) : null;
203
255
  const placed = rect && room ? placeTooltip(rect, physical, room.size, room.viewport) : null;
204
- return /* @__PURE__ */ jsxs(Fragment, { children: [
256
+ if (!point || typeof document === "undefined") return null;
257
+ return createPortal(
205
258
  /* @__PURE__ */ jsx(
206
259
  "span",
207
260
  {
208
- ...rest,
209
- ref: triggerRef,
210
- className: cn("relative inline-flex", className),
211
- onMouseEnter: (e) => show(e.currentTarget),
212
- onMouseLeave: () => setVisible(false),
213
- onFocus: (e) => show(e.currentTarget),
214
- onBlur: () => setVisible(false),
215
- children: describedBy(children, visible ? id : void 0)
261
+ ref: bubbleRef,
262
+ id,
263
+ role: "tooltip",
264
+ dir,
265
+ "data-private": redact ? "" : void 0,
266
+ style: placed ? { position: "fixed", left: placed.left, top: placed.top } : {
267
+ position: "fixed",
268
+ left: point.left,
269
+ top: point.top,
270
+ transform: portalTransformBySide[physical]
271
+ },
272
+ className: cn(TOOLTIP_SURFACE, "pointer-events-none z-50"),
273
+ children: label
216
274
  }
217
275
  ),
218
- visible && point && typeof document !== "undefined" && createPortal(
219
- /* @__PURE__ */ jsx(
220
- "span",
221
- {
222
- ref: bubbleRef,
223
- id,
224
- role: "tooltip",
225
- dir,
226
- "data-private": redact ? "" : void 0,
227
- style: placed ? { position: "fixed", left: placed.left, top: placed.top } : {
228
- position: "fixed",
229
- left: point.left,
230
- top: point.top,
231
- transform: portalTransformBySide[physical]
232
- },
233
- className: cn(TOOLTIP_SURFACE, "pointer-events-none z-50"),
234
- children: label
235
- }
236
- ),
237
- document.body
238
- )
239
- ] });
276
+ document.body
277
+ );
240
278
  }
241
279
  export {
242
280
  Tooltip,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/tooltip.tsx"],"sourcesContent":["import {\n cloneElement,\n isValidElement,\n useId,\n useLayoutEffect,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n type ReactElement,\n type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"../lib/cn\";\nimport { useEscapeKey } from \"../hooks/use-dismiss\";\nimport { useAnchoredRect, type AnchorRect } from \"../hooks/use-anchored-rect\";\nimport { dirOf, type Direction } from \"../lib/direction\";\n\n/** Where the bubble sits. `start` / `end` follow the reading direction — `end` is the\n * right in LTR and the left in RTL — and are what a layout that mirrors should ask\n * for. `left` / `right` stay physical, for a bubble tied to something that does not\n * mirror (a chart axis, a map). */\nexport type TooltipSide = \"top\" | \"bottom\" | \"left\" | \"right\" | \"start\" | \"end\";\n\n/** The placement the maths works in: a logical side resolved against the trigger. */\ntype PhysicalSide = \"top\" | \"bottom\" | \"left\" | \"right\";\n\nfunction physicalSide(side: TooltipSide, dir: Direction): PhysicalSide {\n if (side === \"start\") return dir === \"rtl\" ? \"right\" : \"left\";\n if (side === \"end\") return dir === \"rtl\" ? \"left\" : \"right\";\n return side;\n}\n\n/** The floating bubble itself. Uses the shared surface/border/text tokens so it\n * reads as part of the app's chrome (like the top bar and cards) rather than the\n * cold slate pill it used to be.\n *\n * `w-max` keeps a short label on one line — the old `whitespace-nowrap` did that\n * too, but it also let a sentence-length label grow without bound, and a bubble\n * wider than the space beside its trigger gets clipped by whatever overflow\n * container it sits in. So cap it and let long text wrap instead. The cap tracks\n * the viewport as well, for narrow screens where 20rem is already most of it.\n * `side` is a preference rather than an instruction for the PORTALLED variant,\n * which measures the bubble and turns it round when it would not fit\n * (Steering Design feedback #126). The CSS-only one never learns its own size,\n * so there `side` is still the whole of the placement. */\nconst TOOLTIP_SURFACE =\n \"w-max max-w-[min(20rem,calc(100vw-1rem))] rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 text-xs font-medium text-[var(--text-primary)] shadow-lg\";\n\nconst sidePositionClass: Record<TooltipSide, string> = {\n top: \"bottom-full left-1/2 -translate-x-1/2 mb-1\",\n bottom: \"top-full left-1/2 -translate-x-1/2 mt-1\",\n left: \"right-full top-1/2 -translate-y-1/2 mr-1\",\n right: \"left-full top-1/2 -translate-y-1/2 ml-1\",\n // Logical insets, so CSS resolves the side from the inherited direction with no\n // JavaScript: `end-full` pins the bubble's END edge to the trigger's start.\n start: \"end-full top-1/2 -translate-y-1/2 me-1\",\n end: \"start-full top-1/2 -translate-y-1/2 ms-1\",\n};\n\n/**\n * `extends ComponentPropsWithoutRef<\"span\">` because the wrapper this renders IS a span,\n * and a tooltip is the component a caller most often needs to reach past: it sits\n * between the layout and the control, so a `data-tour` anchor, a test id or an\n * `aria-label` aimed at the trigger used to be swallowed by it.\n *\n * ⚠️ On the empty-label branch there is no wrapper at all, and therefore nothing for\n * those attributes to land on — see the note in the body.\n */\nexport interface TooltipProps extends ComponentPropsWithoutRef<\"span\"> {\n label: ReactNode;\n side?: TooltipSide;\n className?: string;\n portal?: boolean;\n /** Tag the bubble `data-private`, for a label that repeats the user's own data. */\n redact?: boolean;\n children: ReactNode;\n}\n\n/** What each variant below takes: the resolved `side`, and every span attribute the\n * caller handed {@link Tooltip}, forwarded to that variant's own wrapper. */\ntype TooltipVariantProps = Omit<TooltipProps, \"side\" | \"portal\"> & { side: TooltipSide };\n\n/**\n * Hover/focus label for a control.\n *\n * Two implementations, and the choice matters more than it looks. The default is\n * CSS-only: the bubble is always mounted next to the trigger and fades in on\n * `:hover`, which costs no state and works in a plain render test. The `portal`\n * variant mounts the bubble in `document.body` only while hovered, positioned by\n * measurement.\n *\n * ⚠️ **A bubble that repeats a value has to be redactable.** The consuming app blurs\n * `[data-private]` under a `demo-mode` class on `<html>` — and the portalled bubble is\n * mounted on `document.body`, which is INSIDE that class, so the rule reaches it as\n * long as the bubble is tagged. It is not tagged by default, because most labels are\n * UI strings; pass `redact` on the ones that repeat the user's own data (a truncated\n * payee, an account name, a memo). Getting this wrong is silent: the trigger blurs,\n * the bubble spells the value out on hover.\n *\n * ⚠️ **An empty label renders nothing at all.** `title={payee ?? \"\"}` is an ordinary\n * shape at a call site that reveals truncated text, and the native attribute answers\n * it by showing no tooltip. A component that faithfully rendered an empty bubble\n * would be a worse `title`, so the emptiness check is here rather than at every call\n * site that could forget it.\n *\n * ⚠️ **The bubble describes its trigger, which means cloning it.** `role=\"tooltip\"` is\n * a name for a box, not a relationship — so for as long as nothing referenced the\n * bubble, the label reached the pointer and nobody else. That is worst on the call\n * sites that need it most: the kit's icon-only buttons, where the tooltip IS the\n * label. `aria-describedby` has to sit on the focusable element, which is the\n * caller's child and not this component's wrapper, so the child is CLONED to carry\n * it. A description the caller already set is appended to, never replaced — a field's\n * error text and its hint bubble both describe it. Children that cannot take props (a\n * fragment, a bare string, several elements) are left exactly as they were.\n *\n * ⚠️ **Escape dismisses it (WCAG 1.4.13).** Anything that appears on hover or focus has\n * to be dismissible without moving the pointer, and a bubble is opaque: it lands over\n * the row, field or figure you were reading, and the only way out of it used to be to\n * point somewhere else — which is precisely what you cannot do when what you need to\n * read is underneath it. The listener is the document's rather than the wrapper's\n * because the pointer opens this with the keyboard focus somewhere else entirely, and\n * it is subscribed only while a bubble is actually up: the CSS variant is always\n * mounted, and a table of forty tooltips must not mean forty keydown listeners.\n *\n * ⚠️ **Inside a scroll container, use `portal`.** An always-mounted bubble is\n * absolutely positioned, but an absolutely positioned descendant still counts\n * towards its scroll-container ancestor's scrollable overflow — so an invisible\n * bubble on a control near the right edge makes the container scroll sideways\n * with nothing to reveal. That is what Keksdose feedback dev#488 reported on the\n * admin roster: 66px of horizontal scroll on a table that fit, 44px of it owed to\n * tooltips nobody could see. The portalled bubble is `position: fixed` and absent\n * until hovered, so it adds no width — and, being outside the container, it also\n * cannot be clipped by it.\n */\nexport function Tooltip({\n label,\n side = \"top\",\n className,\n portal = false,\n redact = false,\n children,\n ...rest\n}: TooltipProps) {\n // No label, no bubble — and no wrapper either, so a conditional tooltip costs the\n // layout nothing on the branch where it does not apply. `...rest` goes with the\n // wrapper on this branch, which is the documented limit of the pass-through: there is\n // no element left to put an attribute on.\n if (isEmptyLabel(label)) return <>{children}</>;\n if (portal) {\n return (\n <PortalTooltip\n label={label}\n side={side}\n className={className}\n redact={redact}\n {...rest}\n >\n {children}\n </PortalTooltip>\n );\n }\n // Both variants are separate components so that `Tooltip` itself can keep calling NO\n // hooks: the empty-label branch above returns before either of them, and a hook after\n // a conditional return is a hooks-order bug rather than a style violation.\n return (\n <CssTooltip label={label} side={side} className={className} redact={redact} {...rest}>\n {children}\n </CssTooltip>\n );\n}\n\n/** The always-mounted variant: the bubble sits next to the trigger and CSS fades it in.\n *\n * It holds the little state it does for the two things CSS cannot express — which\n * element to point `aria-describedby` at, and Escape — and not for the fade, which is\n * still `group-hover`/`group-focus-within` and still costs a render nothing. */\nfunction CssTooltip({\n label,\n side,\n className,\n redact,\n children,\n ...rest\n}: TooltipVariantProps) {\n const id = useId();\n const [hovered, setHovered] = useState(false);\n const [focused, setFocused] = useState(false);\n const [dismissed, setDismissed] = useState(false);\n useEscapeKey(() => setDismissed(true), (hovered || focused) && !dismissed);\n return (\n <span\n // `...rest` first: the four handlers below are what decides whether a bubble is\n // up, and a caller passing an `onFocus` of its own must not replace them.\n {...rest}\n className={cn(\"relative inline-flex group/tooltip\", className)}\n // These four track WHETHER A BUBBLE IS UP. They activate nothing — the only thing\n // here that can be activated is the caller's child, which keeps every handler it\n // arrived with — so this wrapper needs no role and no key handling of its own.\n // `jsx-a11y/no-static-element-interactions` warns about it all the same, as it\n // already does about the portal variant's identical trigger below; both are left\n // visible rather than silenced, because a rule this package ratchets should be\n // argued with in the backlog and not in a disable comment.\n //\n // Re-armed by the next hover or focus rather than by an effect watching those\n // flags: coming back to a trigger is a fresh request for its label, and an effect\n // would also re-show the bubble under a pointer that never left.\n onMouseEnter={() => {\n setHovered(true);\n setDismissed(false);\n }}\n onMouseLeave={() => setHovered(false)}\n onFocus={() => {\n setFocused(true);\n setDismissed(false);\n }}\n onBlur={() => setFocused(false)}\n >\n {describedBy(children, dismissed ? undefined : id)}\n <span\n id={id}\n role=\"tooltip\"\n // The `hidden` ATTRIBUTE, not an opacity class: dismissing has to take the\n // bubble out of the accessibility tree as well as off the screen, or a screen\n // reader still reads out the description of a bubble the user just closed.\n hidden={dismissed || undefined}\n data-private={redact ? \"\" : undefined}\n className={cn(\n TOOLTIP_SURFACE,\n \"pointer-events-none absolute z-50 opacity-0 group-hover/tooltip:opacity-100 group-focus-within/tooltip:opacity-100\",\n sidePositionClass[side],\n )}\n >\n {label}\n </span>\n </span>\n );\n}\n\n/** Hand `children` the bubble's id as an `aria-describedby`, if it is an element that\n * can hold one. `id` is undefined while there is no bubble to point at — a dangling\n * reference describes the trigger as nothing at all, which is worse than silence. */\nfunction describedBy(children: ReactNode, id: string | undefined): ReactNode {\n if (id === undefined || !isValidElement(children)) return children;\n const child = children as ReactElement<{ \"aria-describedby\"?: string }>;\n // Fragments, Suspense and friends are symbol-typed and take no DOM props; cloning one\n // with an aria attribute warns in development and drops it in production.\n if (typeof child.type === \"symbol\") return children;\n const own = child.props[\"aria-describedby\"];\n return cloneElement(child, { \"aria-describedby\": own ? `${own} ${id}` : id });\n}\n\n/** \"Would this bubble be blank.\" Only the values a call site actually produces when\n * it has nothing to say — `\"\"`, `null`, `undefined`, `false` from a `&&` guard. A\n * numeric `0` is a real label and stays one. */\nfunction isEmptyLabel(label: ReactNode): boolean {\n return (\n label == null ||\n label === false ||\n (typeof label === \"string\" && label.trim() === \"\")\n );\n}\n\nconst TOOLTIP_GAP = 4;\n\n/** How close to the viewport edge a bubble may sit. Not zero: a label flush\n * against the glass reads as clipped even when every character is on screen. */\nconst TOOLTIP_MARGIN = 4;\n\nconst portalTransformBySide: Record<PhysicalSide, string> = {\n right: \"translate(0, -50%)\",\n left: \"translate(-100%, -50%)\",\n top: \"translate(-50%, -100%)\",\n bottom: \"translate(-50%, 0)\",\n};\n\n/** Anchor point (viewport px) for the tooltip on the given side of `r`. Paired\n * with {@link portalTransformBySide}, which shifts the box onto that point. */\nfunction tooltipAnchor(\n r: AnchorRect,\n side: PhysicalSide,\n): { left: number; top: number } {\n switch (side) {\n case \"right\":\n return { left: r.right + TOOLTIP_GAP, top: r.top + r.height / 2 };\n case \"left\":\n return { left: r.left - TOOLTIP_GAP, top: r.top + r.height / 2 };\n case \"top\":\n return { left: r.left + r.width / 2, top: r.top - TOOLTIP_GAP };\n case \"bottom\":\n return { left: r.left + r.width / 2, top: r.bottom + TOOLTIP_GAP };\n }\n}\n\nexport interface TooltipSize {\n width: number;\n height: number;\n}\n\nexport interface TooltipViewport {\n width: number;\n height: number;\n}\n\nexport interface TooltipPlacement {\n left: number;\n top: number;\n /** Which side it ended up on, which need not be the one that was asked for. */\n side: PhysicalSide;\n}\n\nconst opposite: Record<PhysicalSide, PhysicalSide> = {\n left: \"right\",\n right: \"left\",\n top: \"bottom\",\n bottom: \"top\",\n};\n\n/** Whether the bubble clears the viewport edge on `side` of the trigger. */\nfunction roomOn(\n r: AnchorRect,\n side: PhysicalSide,\n size: TooltipSize,\n viewport: TooltipViewport,\n): boolean {\n switch (side) {\n case \"left\":\n return r.left - TOOLTIP_GAP - size.width >= TOOLTIP_MARGIN;\n case \"right\":\n return r.right + TOOLTIP_GAP + size.width <= viewport.width - TOOLTIP_MARGIN;\n case \"top\":\n return r.top - TOOLTIP_GAP - size.height >= TOOLTIP_MARGIN;\n case \"bottom\":\n return r.bottom + TOOLTIP_GAP + size.height <= viewport.height - TOOLTIP_MARGIN;\n }\n}\n\nfunction sameRoom(\n a: { size: TooltipSize; viewport: TooltipViewport },\n b: { size: TooltipSize; viewport: TooltipViewport },\n): boolean {\n return (\n a.size.width === b.size.width &&\n a.size.height === b.size.height &&\n a.viewport.width === b.viewport.width &&\n a.viewport.height === b.viewport.height\n );\n}\n\nfunction clamp(value: number, low: number, high: number): number {\n // `high` first, so a bubble taller or wider than the viewport is pinned to the\n // top-left corner rather than to the bottom-right one — the start of a label\n // is the half worth keeping.\n return Math.max(low, Math.min(value, high));\n}\n\n/**\n * Where the bubble actually goes, given how big it turned out to be.\n *\n * Two rules, and they are separate because they fix separate failures.\n *\n * **Turn round when the preferred side has no room.** `side` says which side of\n * the trigger the label reads best on, and on a form near the left edge of the\n * window that side is off the screen — the capped bubble can only wrap, not\n * move, so what the reader gets is a sentence with its first half outside the\n * glass. Flipped only when the *other* side is genuinely better: a trigger in a\n * viewport too narrow for the bubble either way keeps the side it asked for, and\n * the clamp below does what it can.\n *\n * **Then clamp both axes.** The cross axis is the one that needs it — a `top`\n * bubble is centred on the trigger, so a trigger near the left edge pushes half\n * the label off even though the side it is on is right — and clamping the main\n * axis too costs nothing and covers the flip having nowhere to land.\n *\n * Pure, and measured in viewport pixels throughout, so it can be tested without\n * a layout: the caller supplies the trigger's rect, the bubble's own size and\n * the window.\n */\nexport function placeTooltip(\n r: AnchorRect,\n side: PhysicalSide,\n size: TooltipSize,\n viewport: TooltipViewport,\n): TooltipPlacement {\n const chosen =\n roomOn(r, side, size, viewport) || !roomOn(r, opposite[side], size, viewport)\n ? side\n : opposite[side];\n const point = tooltipAnchor(r, chosen);\n const box =\n chosen === \"left\"\n ? { left: point.left - size.width, top: point.top - size.height / 2 }\n : chosen === \"right\"\n ? { left: point.left, top: point.top - size.height / 2 }\n : chosen === \"top\"\n ? { left: point.left - size.width / 2, top: point.top - size.height }\n : { left: point.left - size.width / 2, top: point.top };\n return {\n left: clamp(box.left, TOOLTIP_MARGIN, viewport.width - size.width - TOOLTIP_MARGIN),\n top: clamp(box.top, TOOLTIP_MARGIN, viewport.height - size.height - TOOLTIP_MARGIN),\n side: chosen,\n };\n}\n\nfunction PortalTooltip({\n label,\n side,\n className,\n redact,\n children,\n ...rest\n}: TooltipVariantProps) {\n const triggerRef = useRef<HTMLSpanElement | null>(null);\n const bubbleRef = useRef<HTMLSpanElement | null>(null);\n const [visible, setVisible] = useState(false);\n // The trigger's reading direction, read when the bubble is asked for (an event, not a\n // render): it resolves `start` / `end`, and the portalled bubble — which has left the\n // subtree it would have inherited `dir` from — carries it too.\n const [dir, setDir] = useState<Direction>(\"ltr\");\n const show = (el: Element) => {\n setDir(dirOf(el));\n setVisible(true);\n };\n const id = useId();\n // Escape closes it outright, since this variant's bubble only exists while it is\n // shown. The next mouseenter/focus brings it back, which is the behaviour WCAG\n // 1.4.13 asks for: dismissible now, still available when you ask again.\n useEscapeKey(() => setVisible(false), visible);\n // The measure + scroll/resize-tracking lifecycle is owned by useAnchoredRect;\n // here we only map the rect to a side-specific anchor point.\n const rect = useAnchoredRect(triggerRef, visible);\n // The bubble's own size and the window it has to fit in — neither of which is\n // knowable in render: the width is whatever the label wrapped to inside the\n // cap, and reading `window` while rendering is not a pure thing to do. Both\n // are taken in a LAYOUT effect, so the correction lands before the browser\n // paints and there is no frame in which the label sits off the screen.\n const [room, setRoom] = useState<{ size: TooltipSize; viewport: TooltipViewport } | null>(null);\n useLayoutEffect(() => {\n const measured = visible ? bubbleRef.current?.getBoundingClientRect() : undefined;\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a measurement is the one thing a layout effect is for\n setRoom((previous) => {\n if (!measured) return null;\n const next = {\n size: { width: measured.width, height: measured.height },\n viewport: { width: window.innerWidth, height: window.innerHeight },\n };\n // Only publish what actually CHANGED: every re-measure allocates a fresh\n // object, and a new object on every scroll event would re-render the\n // bubble forever.\n return previous && sameRoom(previous, next) ? previous : next;\n });\n }, [visible, rect, label]);\n\n const physical = physicalSide(side, dir);\n const point = rect ? tooltipAnchor(rect, physical) : null;\n // Unmeasured on the very first pass, where the anchor point plus the side's\n // own transform is exactly what this always did. One layout effect later the\n // size is known and the placement is decided properly.\n const placed = rect && room ? placeTooltip(rect, physical, room.size, room.viewport) : null;\n\n return (\n <>\n <span\n // As in `CssTooltip`: the caller's attributes first, the four handlers that run\n // this component after them. The BUBBLE is deliberately not given them — it is\n // portalled to `<body>`, and an id or a tour anchor duplicated onto a node that\n // only exists while hovered would match twice or match nothing.\n {...rest}\n ref={triggerRef}\n className={cn(\"relative inline-flex\", className)}\n onMouseEnter={(e) => show(e.currentTarget)}\n onMouseLeave={() => setVisible(false)}\n onFocus={(e) => show(e.currentTarget)}\n onBlur={() => setVisible(false)}\n >\n {describedBy(children, visible ? id : undefined)}\n </span>\n {visible &&\n point &&\n typeof document !== \"undefined\" &&\n createPortal(\n <span\n ref={bubbleRef}\n id={id}\n role=\"tooltip\"\n dir={dir}\n data-private={redact ? \"\" : undefined}\n style={\n placed\n ? { position: \"fixed\", left: placed.left, top: placed.top }\n : {\n position: \"fixed\",\n left: point.left,\n top: point.top,\n transform: portalTransformBySide[physical],\n }\n }\n className={cn(TOOLTIP_SURFACE, \"pointer-events-none z-50\")}\n >\n {label}\n </span>,\n document.body,\n )}\n </>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/tooltip.tsx"],"sourcesContent":["import {\n cloneElement,\n isValidElement,\n useId,\n useLayoutEffect,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n type ReactElement,\n type ReactNode,\n type RefObject,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"../lib/cn\";\nimport { useEscapeKey } from \"../hooks/use-dismiss\";\nimport { useAnchoredRect, type AnchorRect } from \"../hooks/use-anchored-rect\";\nimport { dirOf, type Direction } from \"../lib/direction\";\nimport { hasClippingAncestor } from \"../lib/clipping\";\n\n/** Where the bubble sits. `start` / `end` follow the reading direction — `end` is the\n * right in LTR and the left in RTL — and are what a layout that mirrors should ask\n * for. `left` / `right` stay physical, for a bubble tied to something that does not\n * mirror (a chart axis, a map). */\nexport type TooltipSide = \"top\" | \"bottom\" | \"left\" | \"right\" | \"start\" | \"end\";\n\n/** The placement the maths works in: a logical side resolved against the trigger. */\ntype PhysicalSide = \"top\" | \"bottom\" | \"left\" | \"right\";\n\nfunction physicalSide(side: TooltipSide, dir: Direction): PhysicalSide {\n if (side === \"start\") return dir === \"rtl\" ? \"right\" : \"left\";\n if (side === \"end\") return dir === \"rtl\" ? \"left\" : \"right\";\n return side;\n}\n\n/** The floating bubble itself. Uses the shared surface/border/text tokens so it\n * reads as part of the app's chrome (like the top bar and cards) rather than the\n * cold slate pill it used to be.\n *\n * `w-max` keeps a short label on one line — the old `whitespace-nowrap` did that\n * too, but it also let a sentence-length label grow without bound, and a bubble\n * wider than the space beside its trigger gets clipped by whatever overflow\n * container it sits in. So cap it and let long text wrap instead. The cap tracks\n * the viewport as well, for narrow screens where 20rem is already most of it.\n * `side` is a preference rather than an instruction for the PORTALLED variant,\n * which measures the bubble and turns it round when it would not fit\n * (Steering Design feedback #126). The CSS-only one never learns its own size,\n * so there `side` is still the whole of the placement. */\nconst TOOLTIP_SURFACE =\n \"w-max max-w-[min(20rem,calc(100vw-1rem))] rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 text-xs font-medium text-[var(--text-primary)] shadow-lg\";\n\nconst sidePositionClass: Record<TooltipSide, string> = {\n top: \"bottom-full left-1/2 -translate-x-1/2 mb-1\",\n bottom: \"top-full left-1/2 -translate-x-1/2 mt-1\",\n left: \"right-full top-1/2 -translate-y-1/2 mr-1\",\n right: \"left-full top-1/2 -translate-y-1/2 ml-1\",\n // Logical insets, so CSS resolves the side from the inherited direction with no\n // JavaScript: `end-full` pins the bubble's END edge to the trigger's start.\n start: \"end-full top-1/2 -translate-y-1/2 me-1\",\n end: \"start-full top-1/2 -translate-y-1/2 ms-1\",\n};\n\n/**\n * `extends ComponentPropsWithoutRef<\"span\">` because the wrapper this renders IS a span,\n * and a tooltip is the component a caller most often needs to reach past: it sits\n * between the layout and the control, so a `data-tour` anchor, a test id or an\n * `aria-label` aimed at the trigger used to be swallowed by it.\n *\n * ⚠️ On the empty-label branch there is no wrapper at all, and therefore nothing for\n * those attributes to land on — see the note in the body.\n */\nexport interface TooltipProps extends ComponentPropsWithoutRef<\"span\"> {\n label: ReactNode;\n side?: TooltipSide;\n className?: string;\n /**\n * Where the bubble lives. Left out (the default since 0.10.0), the tooltip decides for\n * itself: the bubble stays next to the trigger unless an ancestor clips or scrolls\n * (`overflow` other than `visible`), in which case it is portalled — see \"Inside a\n * scroll container\" below. `true` always portals, `false` never does; both are exactly\n * what they were before the default existed.\n */\n portal?: boolean;\n /** Tag the bubble `data-private`, for a label that repeats the user's own data. */\n redact?: boolean;\n children: ReactNode;\n}\n\n/** What each variant below takes: the resolved `side`, and every span attribute the\n * caller handed {@link Tooltip}, forwarded to that variant's own wrapper. */\ntype TooltipVariantProps = Omit<TooltipProps, \"side\" | \"portal\"> & { side: TooltipSide };\n\n/**\n * Hover/focus label for a control.\n *\n * Two placements, and the choice matters more than it looks. In place, the bubble is\n * always mounted next to the trigger and fades in on `:hover`, which costs no state and\n * works in a plain render test. Portalled, the bubble is mounted in `document.body`\n * only while it is up, positioned by measurement.\n *\n * ⚠️ **A bubble that repeats a value has to be redactable.** The consuming app blurs\n * `[data-private]` under a `demo-mode` class on `<html>` — and the portalled bubble is\n * mounted on `document.body`, which is INSIDE that class, so the rule reaches it as\n * long as the bubble is tagged. It is not tagged by default, because most labels are\n * UI strings; pass `redact` on the ones that repeat the user's own data (a truncated\n * payee, an account name, a memo). Getting this wrong is silent: the trigger blurs,\n * the bubble spells the value out on hover.\n *\n * ⚠️ **An empty label renders nothing at all.** `title={payee ?? \"\"}` is an ordinary\n * shape at a call site that reveals truncated text, and the native attribute answers\n * it by showing no tooltip. A component that faithfully rendered an empty bubble\n * would be a worse `title`, so the emptiness check is here rather than at every call\n * site that could forget it.\n *\n * ⚠️ **The bubble describes its trigger, which means cloning it.** `role=\"tooltip\"` is\n * a name for a box, not a relationship — so for as long as nothing referenced the\n * bubble, the label reached the pointer and nobody else. That is worst on the call\n * sites that need it most: the kit's icon-only buttons, where the tooltip IS the\n * label. `aria-describedby` has to sit on the focusable element, which is the\n * caller's child and not this component's wrapper, so the child is CLONED to carry\n * it. A description the caller already set is appended to, never replaced — a field's\n * error text and its hint bubble both describe it. Children that cannot take props (a\n * fragment, a bare string, several elements) are left exactly as they were.\n *\n * ⚠️ **Escape dismisses it (WCAG 1.4.13).** Anything that appears on hover or focus has\n * to be dismissible without moving the pointer, and a bubble is opaque: it lands over\n * the row, field or figure you were reading, and the only way out of it used to be to\n * point somewhere else — which is precisely what you cannot do when what you need to\n * read is underneath it. The listener is the document's rather than the wrapper's\n * because the pointer opens this with the keyboard focus somewhere else entirely, and\n * it is subscribed only while a bubble is actually up: the in-place bubble is always\n * mounted, and a table of forty tooltips must not mean forty keydown listeners.\n *\n * ⚠️ **Inside a scroll container, the bubble has to be portalled — and by default it\n * now is.** An always-mounted bubble is absolutely positioned, but an absolutely\n * positioned descendant still counts towards its scroll-container ancestor's\n * scrollable overflow — so an invisible bubble on a control near the right edge makes\n * the container scroll sideways with nothing to reveal. That is what Keksdose feedback\n * dev#488 reported on the admin roster: 66px of horizontal scroll on a table that fit,\n * 44px of it owed to tooltips nobody could see. And a visible one is clipped by the\n * container's edge. The portalled bubble is `position: fixed` and absent until hovered,\n * so it adds no width and cannot be clipped.\n *\n * Until 0.10.0 the cure was `portal` at the call site, and kastlan asked for it to stop\n * being one: every tooltip in a table, a drawer or a scrolling card had to remember it,\n * and the ones that forgot were only found by someone scrolling sideways. So with\n * `portal` left out, the tooltip looks for a clipping ancestor itself — any element\n * between it and `<body>` whose computed `overflow-x` / `overflow-y` is not `visible`.\n * It looks at MOUNT, not only on open: dev#488's phantom scroll is caused by a bubble\n * nobody opened, so a check that waited for the hover would find the damage already\n * done. It looks again on every open, for a container that started scrolling after\n * the tooltip mounted (a table that grew). Only the bubble changes place; the trigger\n * and the caller's child stay mounted, so a switch never costs a focused button its\n * focus. Outside any such container the in-place bubble is kept, which is still the\n * cheaper one and the one a plain render test can find without a hover.\n *\n * Why not simply portal everything? Because the in-place bubble is the one existing\n * app tests rely on (it is in the DOM without a hover), and because it follows its\n * trigger through a scroll or an animation for free — the portalled one re-measures.\n */\nexport function Tooltip({\n label,\n side = \"top\",\n className,\n portal,\n redact = false,\n children,\n ...rest\n}: TooltipProps) {\n // No label, no bubble — and no wrapper either, so a conditional tooltip costs the\n // layout nothing on the branch where it does not apply. `...rest` goes with the\n // wrapper on this branch, which is the documented limit of the pass-through: there is\n // no element left to put an attribute on.\n if (isEmptyLabel(label)) return <>{children}</>;\n if (portal === true) {\n return (\n <PortalTooltip\n label={label}\n side={side}\n className={className}\n redact={redact}\n {...rest}\n >\n {children}\n </PortalTooltip>\n );\n }\n // Both variants are separate components so that `Tooltip` itself can keep calling NO\n // hooks: the empty-label branch above returns before either of them, and a hook after\n // a conditional return is a hooks-order bug rather than a style violation.\n return (\n <InPlaceTooltip\n label={label}\n side={side}\n className={className}\n redact={redact}\n detect={portal === undefined}\n {...rest}\n >\n {children}\n </InPlaceTooltip>\n );\n}\n\n\n/** The variant that lives next to its trigger, and — when `detect` is on, which is the\n * default — moves its bubble to `<body>` when that turns out to be inside a clipping\n * container (see \"Inside a scroll container\" on {@link Tooltip}).\n *\n * In place it holds the little state it does for the two things CSS cannot express —\n * which element to point `aria-describedby` at, and Escape — and not for the fade,\n * which is still `group-hover`/`group-focus-within` and still costs a render nothing.\n *\n * ONE component for both placements rather than a switch between the two variants: a\n * switch would be a different component at the same place in the tree, and React\n * would remount the caller's child with it — a button that loses focus the moment\n * its own tooltip decided where to go. Here the wrapper and the child stay put and\n * only the bubble's slot changes. */\nfunction InPlaceTooltip({\n label,\n side,\n className,\n redact,\n detect,\n children,\n ...rest\n}: TooltipVariantProps & { detect: boolean }) {\n const id = useId();\n const triggerRef = useRef<HTMLSpanElement | null>(null);\n const [clipped, setClipped] = useState(false);\n const [hovered, setHovered] = useState(false);\n const [focused, setFocused] = useState(false);\n const [dismissed, setDismissed] = useState(false);\n const [dir, setDir] = useState<Direction>(\"ltr\");\n const open = (hovered || focused) && !dismissed;\n useEscapeKey(() => setDismissed(true), open);\n // At mount, before the first paint: the in-place bubble inside a scroller is the\n // phantom-scroll bug whether or not anyone opens it.\n useLayoutEffect(() => {\n if (detect) setClipped(hasClippingAncestor(triggerRef.current));\n }, [detect]);\n // Re-armed by the next hover or focus rather than by an effect watching those flags:\n // coming back to a trigger is a fresh request for its label, and an effect would also\n // re-show the bubble under a pointer that never left. The clipping check is repeated\n // here for a container that began to scroll after mount.\n const arm = (el: Element) => {\n setDismissed(false);\n if (!detect) return;\n setDir(dirOf(el));\n setClipped(hasClippingAncestor(el));\n };\n return (\n <span\n // `...rest` first: the four handlers below are what decides whether a bubble is\n // up, and a caller passing an `onFocus` of its own must not replace them.\n {...rest}\n ref={triggerRef}\n className={cn(\"relative inline-flex\", !clipped && \"group/tooltip\", className)}\n // These four track WHETHER A BUBBLE IS UP. They activate nothing — the only thing\n // here that can be activated is the caller's child, which keeps every handler it\n // arrived with — so this wrapper needs no role and no key handling of its own.\n // `jsx-a11y/no-static-element-interactions` warns about it all the same, as it\n // already does about the portal variant's identical trigger below; both are left\n // visible rather than silenced, because a rule this package ratchets should be\n // argued with in the backlog and not in a disable comment.\n onMouseEnter={(e) => {\n setHovered(true);\n arm(e.currentTarget);\n }}\n onMouseLeave={() => setHovered(false)}\n onFocus={(e) => {\n setFocused(true);\n arm(e.currentTarget);\n }}\n onBlur={() => setFocused(false)}\n >\n {/* In place the bubble is always there to point at; portalled, only while up. */}\n {describedBy(children, clipped ? (open ? id : undefined) : dismissed ? undefined : id)}\n {clipped ? (\n open && (\n <PortalBubble triggerRef={triggerRef} id={id} label={label} side={side} dir={dir} redact={redact} />\n )\n ) : (\n <span\n id={id}\n role=\"tooltip\"\n // The `hidden` ATTRIBUTE, not an opacity class: dismissing has to take the\n // bubble out of the accessibility tree as well as off the screen, or a screen\n // reader still reads out the description of a bubble the user just closed.\n hidden={dismissed || undefined}\n data-private={redact ? \"\" : undefined}\n className={cn(\n TOOLTIP_SURFACE,\n \"pointer-events-none absolute z-50 opacity-0 group-hover/tooltip:opacity-100 group-focus-within/tooltip:opacity-100\",\n sidePositionClass[side],\n )}\n >\n {label}\n </span>\n )}\n </span>\n );\n}\n\n/** Hand `children` the bubble's id as an `aria-describedby`, if it is an element that\n * can hold one. `id` is undefined while there is no bubble to point at — a dangling\n * reference describes the trigger as nothing at all, which is worse than silence. */\nfunction describedBy(children: ReactNode, id: string | undefined): ReactNode {\n if (id === undefined || !isValidElement(children)) return children;\n const child = children as ReactElement<{ \"aria-describedby\"?: string }>;\n // Fragments, Suspense and friends are symbol-typed and take no DOM props; cloning one\n // with an aria attribute warns in development and drops it in production.\n if (typeof child.type === \"symbol\") return children;\n const own = child.props[\"aria-describedby\"];\n return cloneElement(child, { \"aria-describedby\": own ? `${own} ${id}` : id });\n}\n\n/** \"Would this bubble be blank.\" Only the values a call site actually produces when\n * it has nothing to say — `\"\"`, `null`, `undefined`, `false` from a `&&` guard. A\n * numeric `0` is a real label and stays one. */\nfunction isEmptyLabel(label: ReactNode): boolean {\n return (\n label == null ||\n label === false ||\n (typeof label === \"string\" && label.trim() === \"\")\n );\n}\n\nconst TOOLTIP_GAP = 4;\n\n/** How close to the viewport edge a bubble may sit. Not zero: a label flush\n * against the glass reads as clipped even when every character is on screen. */\nconst TOOLTIP_MARGIN = 4;\n\nconst portalTransformBySide: Record<PhysicalSide, string> = {\n right: \"translate(0, -50%)\",\n left: \"translate(-100%, -50%)\",\n top: \"translate(-50%, -100%)\",\n bottom: \"translate(-50%, 0)\",\n};\n\n/** Anchor point (viewport px) for the tooltip on the given side of `r`. Paired\n * with {@link portalTransformBySide}, which shifts the box onto that point. */\nfunction tooltipAnchor(\n r: AnchorRect,\n side: PhysicalSide,\n): { left: number; top: number } {\n switch (side) {\n case \"right\":\n return { left: r.right + TOOLTIP_GAP, top: r.top + r.height / 2 };\n case \"left\":\n return { left: r.left - TOOLTIP_GAP, top: r.top + r.height / 2 };\n case \"top\":\n return { left: r.left + r.width / 2, top: r.top - TOOLTIP_GAP };\n case \"bottom\":\n return { left: r.left + r.width / 2, top: r.bottom + TOOLTIP_GAP };\n }\n}\n\nexport interface TooltipSize {\n width: number;\n height: number;\n}\n\nexport interface TooltipViewport {\n width: number;\n height: number;\n}\n\nexport interface TooltipPlacement {\n left: number;\n top: number;\n /** Which side it ended up on, which need not be the one that was asked for. */\n side: PhysicalSide;\n}\n\nconst opposite: Record<PhysicalSide, PhysicalSide> = {\n left: \"right\",\n right: \"left\",\n top: \"bottom\",\n bottom: \"top\",\n};\n\n/** Whether the bubble clears the viewport edge on `side` of the trigger. */\nfunction roomOn(\n r: AnchorRect,\n side: PhysicalSide,\n size: TooltipSize,\n viewport: TooltipViewport,\n): boolean {\n switch (side) {\n case \"left\":\n return r.left - TOOLTIP_GAP - size.width >= TOOLTIP_MARGIN;\n case \"right\":\n return r.right + TOOLTIP_GAP + size.width <= viewport.width - TOOLTIP_MARGIN;\n case \"top\":\n return r.top - TOOLTIP_GAP - size.height >= TOOLTIP_MARGIN;\n case \"bottom\":\n return r.bottom + TOOLTIP_GAP + size.height <= viewport.height - TOOLTIP_MARGIN;\n }\n}\n\nfunction sameRoom(\n a: { size: TooltipSize; viewport: TooltipViewport },\n b: { size: TooltipSize; viewport: TooltipViewport },\n): boolean {\n return (\n a.size.width === b.size.width &&\n a.size.height === b.size.height &&\n a.viewport.width === b.viewport.width &&\n a.viewport.height === b.viewport.height\n );\n}\n\nfunction clamp(value: number, low: number, high: number): number {\n // `high` first, so a bubble taller or wider than the viewport is pinned to the\n // top-left corner rather than to the bottom-right one — the start of a label\n // is the half worth keeping.\n return Math.max(low, Math.min(value, high));\n}\n\n/**\n * Where the bubble actually goes, given how big it turned out to be.\n *\n * Two rules, and they are separate because they fix separate failures.\n *\n * **Turn round when the preferred side has no room.** `side` says which side of\n * the trigger the label reads best on, and on a form near the left edge of the\n * window that side is off the screen — the capped bubble can only wrap, not\n * move, so what the reader gets is a sentence with its first half outside the\n * glass. Flipped only when the *other* side is genuinely better: a trigger in a\n * viewport too narrow for the bubble either way keeps the side it asked for, and\n * the clamp below does what it can.\n *\n * **Then clamp both axes.** The cross axis is the one that needs it — a `top`\n * bubble is centred on the trigger, so a trigger near the left edge pushes half\n * the label off even though the side it is on is right — and clamping the main\n * axis too costs nothing and covers the flip having nowhere to land.\n *\n * Pure, and measured in viewport pixels throughout, so it can be tested without\n * a layout: the caller supplies the trigger's rect, the bubble's own size and\n * the window.\n */\nexport function placeTooltip(\n r: AnchorRect,\n side: PhysicalSide,\n size: TooltipSize,\n viewport: TooltipViewport,\n): TooltipPlacement {\n const chosen =\n roomOn(r, side, size, viewport) || !roomOn(r, opposite[side], size, viewport)\n ? side\n : opposite[side];\n const point = tooltipAnchor(r, chosen);\n const box =\n chosen === \"left\"\n ? { left: point.left - size.width, top: point.top - size.height / 2 }\n : chosen === \"right\"\n ? { left: point.left, top: point.top - size.height / 2 }\n : chosen === \"top\"\n ? { left: point.left - size.width / 2, top: point.top - size.height }\n : { left: point.left - size.width / 2, top: point.top };\n return {\n left: clamp(box.left, TOOLTIP_MARGIN, viewport.width - size.width - TOOLTIP_MARGIN),\n top: clamp(box.top, TOOLTIP_MARGIN, viewport.height - size.height - TOOLTIP_MARGIN),\n side: chosen,\n };\n}\n\nfunction PortalTooltip({\n label,\n side,\n className,\n redact,\n children,\n ...rest\n}: TooltipVariantProps) {\n const triggerRef = useRef<HTMLSpanElement | null>(null);\n const [visible, setVisible] = useState(false);\n // The trigger's reading direction, read when the bubble is asked for (an event, not a\n // render): it resolves `start` / `end`, and the portalled bubble — which has left the\n // subtree it would have inherited `dir` from — carries it too.\n const [dir, setDir] = useState<Direction>(\"ltr\");\n const show = (el: Element) => {\n setDir(dirOf(el));\n setVisible(true);\n };\n const id = useId();\n // Escape closes it outright, since this variant's bubble only exists while it is\n // shown. The next mouseenter/focus brings it back, which is the behaviour WCAG\n // 1.4.13 asks for: dismissible now, still available when you ask again.\n useEscapeKey(() => setVisible(false), visible);\n\n return (\n <>\n <span\n // As in `InPlaceTooltip`: the caller's attributes first, the four handlers that\n // run this component after them. The BUBBLE is deliberately not given them — it\n // is portalled to `<body>`, and an id or a tour anchor duplicated onto a node\n // that only exists while hovered would match twice or match nothing.\n {...rest}\n ref={triggerRef}\n className={cn(\"relative inline-flex\", className)}\n onMouseEnter={(e) => show(e.currentTarget)}\n onMouseLeave={() => setVisible(false)}\n onFocus={(e) => show(e.currentTarget)}\n onBlur={() => setVisible(false)}\n >\n {describedBy(children, visible ? id : undefined)}\n </span>\n {visible && (\n <PortalBubble triggerRef={triggerRef} id={id} label={label} side={side} dir={dir} redact={redact} />\n )}\n </>\n );\n}\n\n/** The measured, `position: fixed` bubble on `<body>`, mounted only while it is up.\n * Shared by {@link PortalTooltip} and the in-place variant's clipped mode, so the two\n * cannot place a bubble differently. */\nfunction PortalBubble({\n triggerRef,\n id,\n label,\n side,\n dir,\n redact,\n}: {\n triggerRef: RefObject<HTMLSpanElement | null>;\n id: string;\n label: ReactNode;\n side: TooltipSide;\n dir: Direction;\n redact: boolean | undefined;\n}) {\n const bubbleRef = useRef<HTMLSpanElement | null>(null);\n // The measure + scroll/resize-tracking lifecycle is owned by useAnchoredRect;\n // here we only map the rect to a side-specific anchor point.\n const rect = useAnchoredRect(triggerRef, true);\n // The bubble's own size and the window it has to fit in — neither of which is\n // knowable in render: the width is whatever the label wrapped to inside the\n // cap, and reading `window` while rendering is not a pure thing to do. Both\n // are taken in a LAYOUT effect, so the correction lands before the browser\n // paints and there is no frame in which the label sits off the screen.\n const [room, setRoom] = useState<{ size: TooltipSize; viewport: TooltipViewport } | null>(null);\n useLayoutEffect(() => {\n const measured = bubbleRef.current?.getBoundingClientRect();\n setRoom((previous) => {\n if (!measured) return null;\n const next = {\n size: { width: measured.width, height: measured.height },\n viewport: { width: window.innerWidth, height: window.innerHeight },\n };\n // Only publish what actually CHANGED: every re-measure allocates a fresh\n // object, and a new object on every scroll event would re-render the\n // bubble forever.\n return previous && sameRoom(previous, next) ? previous : next;\n });\n }, [rect, label]);\n\n const physical = physicalSide(side, dir);\n const point = rect ? tooltipAnchor(rect, physical) : null;\n // Unmeasured on the very first pass, where the anchor point plus the side's\n // own transform is exactly what this always did. One layout effect later the\n // size is known and the placement is decided properly.\n const placed = rect && room ? placeTooltip(rect, physical, room.size, room.viewport) : null;\n\n if (!point || typeof document === \"undefined\") return null;\n return createPortal(\n <span\n ref={bubbleRef}\n id={id}\n role=\"tooltip\"\n dir={dir}\n data-private={redact ? \"\" : undefined}\n style={\n placed\n ? { position: \"fixed\", left: placed.left, top: placed.top }\n : {\n position: \"fixed\",\n left: point.left,\n top: point.top,\n transform: portalTransformBySide[physical],\n }\n }\n className={cn(TOOLTIP_SURFACE, \"pointer-events-none z-50\")}\n >\n {label}\n </span>,\n document.body,\n );\n}\n"],"mappings":";AA4KkC,wBA+E9B,YA/E8B;AA5KlC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAKK;AACP,SAAS,oBAAoB;AAC7B,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAC7B,SAAS,uBAAwC;AACjD,SAAS,aAA6B;AACtC,SAAS,2BAA2B;AAWpC,SAAS,aAAa,MAAmB,KAA8B;AACrE,MAAI,SAAS,QAAS,QAAO,QAAQ,QAAQ,UAAU;AACvD,MAAI,SAAS,MAAO,QAAO,QAAQ,QAAQ,SAAS;AACpD,SAAO;AACT;AAeA,MAAM,kBACJ;AAEF,MAAM,oBAAiD;AAAA,EACrD,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,OAAO;AAAA;AAAA;AAAA,EAGP,OAAO;AAAA,EACP,KAAK;AACP;AAoGO,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA,OAAO;AAAA,EACP;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA,GAAG;AACL,GAAiB;AAKf,MAAI,aAAa,KAAK,EAAG,QAAO,gCAAG,UAAS;AAC5C,MAAI,WAAW,MAAM;AACnB,WACE;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACC,GAAG;AAAA,QAEH;AAAA;AAAA,IACH;AAAA,EAEJ;AAIA,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,QAAQ,WAAW;AAAA,MAClB,GAAG;AAAA,MAEH;AAAA;AAAA,EACH;AAEJ;AAgBA,SAAS,eAAe;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAA8C;AAC5C,QAAM,KAAK,MAAM;AACjB,QAAM,aAAa,OAA+B,IAAI;AACtD,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAC5C,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAC5C,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAC5C,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,KAAK;AAChD,QAAM,CAAC,KAAK,MAAM,IAAI,SAAoB,KAAK;AAC/C,QAAM,QAAQ,WAAW,YAAY,CAAC;AACtC,eAAa,MAAM,aAAa,IAAI,GAAG,IAAI;AAG3C,kBAAgB,MAAM;AACpB,QAAI,OAAQ,YAAW,oBAAoB,WAAW,OAAO,CAAC;AAAA,EAChE,GAAG,CAAC,MAAM,CAAC;AAKX,QAAM,MAAM,CAAC,OAAgB;AAC3B,iBAAa,KAAK;AAClB,QAAI,CAAC,OAAQ;AACb,WAAO,MAAM,EAAE,CAAC;AAChB,eAAW,oBAAoB,EAAE,CAAC;AAAA,EACpC;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MAGE,GAAG;AAAA,MACJ,KAAK;AAAA,MACL,WAAW,GAAG,wBAAwB,CAAC,WAAW,iBAAiB,SAAS;AAAA,MAQ5E,cAAc,CAAC,MAAM;AACnB,mBAAW,IAAI;AACf,YAAI,EAAE,aAAa;AAAA,MACrB;AAAA,MACA,cAAc,MAAM,WAAW,KAAK;AAAA,MACpC,SAAS,CAAC,MAAM;AACd,mBAAW,IAAI;AACf,YAAI,EAAE,aAAa;AAAA,MACrB;AAAA,MACA,QAAQ,MAAM,WAAW,KAAK;AAAA,MAG7B;AAAA,oBAAY,UAAU,UAAW,OAAO,KAAK,SAAa,YAAY,SAAY,EAAE;AAAA,QACpF,UACC,QACE,oBAAC,gBAAa,YAAwB,IAAQ,OAAc,MAAY,KAAU,QAAgB,IAGpG;AAAA,UAAC;AAAA;AAAA,YACC;AAAA,YACA,MAAK;AAAA,YAIL,QAAQ,aAAa;AAAA,YACrB,gBAAc,SAAS,KAAK;AAAA,YAC5B,WAAW;AAAA,cACT;AAAA,cACA;AAAA,cACA,kBAAkB,IAAI;AAAA,YACxB;AAAA,YAEC;AAAA;AAAA,QACH;AAAA;AAAA;AAAA,EAEJ;AAEJ;AAKA,SAAS,YAAY,UAAqB,IAAmC;AAC3E,MAAI,OAAO,UAAa,CAAC,eAAe,QAAQ,EAAG,QAAO;AAC1D,QAAM,QAAQ;AAGd,MAAI,OAAO,MAAM,SAAS,SAAU,QAAO;AAC3C,QAAM,MAAM,MAAM,MAAM,kBAAkB;AAC1C,SAAO,aAAa,OAAO,EAAE,oBAAoB,MAAM,GAAG,GAAG,IAAI,EAAE,KAAK,GAAG,CAAC;AAC9E;AAKA,SAAS,aAAa,OAA2B;AAC/C,SACE,SAAS,QACT,UAAU,SACT,OAAO,UAAU,YAAY,MAAM,KAAK,MAAM;AAEnD;AAEA,MAAM,cAAc;AAIpB,MAAM,iBAAiB;AAEvB,MAAM,wBAAsD;AAAA,EAC1D,OAAO;AAAA,EACP,MAAM;AAAA,EACN,KAAK;AAAA,EACL,QAAQ;AACV;AAIA,SAAS,cACP,GACA,MAC+B;AAC/B,UAAQ,MAAM;AAAA,IACZ,KAAK;AACH,aAAO,EAAE,MAAM,EAAE,QAAQ,aAAa,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE;AAAA,IAClE,KAAK;AACH,aAAO,EAAE,MAAM,EAAE,OAAO,aAAa,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE;AAAA,IACjE,KAAK;AACH,aAAO,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,GAAG,KAAK,EAAE,MAAM,YAAY;AAAA,IAChE,KAAK;AACH,aAAO,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,GAAG,KAAK,EAAE,SAAS,YAAY;AAAA,EACrE;AACF;AAmBA,MAAM,WAA+C;AAAA,EACnD,MAAM;AAAA,EACN,OAAO;AAAA,EACP,KAAK;AAAA,EACL,QAAQ;AACV;AAGA,SAAS,OACP,GACA,MACA,MACA,UACS;AACT,UAAQ,MAAM;AAAA,IACZ,KAAK;AACH,aAAO,EAAE,OAAO,cAAc,KAAK,SAAS;AAAA,IAC9C,KAAK;AACH,aAAO,EAAE,QAAQ,cAAc,KAAK,SAAS,SAAS,QAAQ;AAAA,IAChE,KAAK;AACH,aAAO,EAAE,MAAM,cAAc,KAAK,UAAU;AAAA,IAC9C,KAAK;AACH,aAAO,EAAE,SAAS,cAAc,KAAK,UAAU,SAAS,SAAS;AAAA,EACrE;AACF;AAEA,SAAS,SACP,GACA,GACS;AACT,SACE,EAAE,KAAK,UAAU,EAAE,KAAK,SACxB,EAAE,KAAK,WAAW,EAAE,KAAK,UACzB,EAAE,SAAS,UAAU,EAAE,SAAS,SAChC,EAAE,SAAS,WAAW,EAAE,SAAS;AAErC;AAEA,SAAS,MAAM,OAAe,KAAa,MAAsB;AAI/D,SAAO,KAAK,IAAI,KAAK,KAAK,IAAI,OAAO,IAAI,CAAC;AAC5C;AAwBO,SAAS,aACd,GACA,MACA,MACA,UACkB;AAClB,QAAM,SACJ,OAAO,GAAG,MAAM,MAAM,QAAQ,KAAK,CAAC,OAAO,GAAG,SAAS,IAAI,GAAG,MAAM,QAAQ,IACxE,OACA,SAAS,IAAI;AACnB,QAAM,QAAQ,cAAc,GAAG,MAAM;AACrC,QAAM,MACJ,WAAW,SACP,EAAE,MAAM,MAAM,OAAO,KAAK,OAAO,KAAK,MAAM,MAAM,KAAK,SAAS,EAAE,IAClE,WAAW,UACT,EAAE,MAAM,MAAM,MAAM,KAAK,MAAM,MAAM,KAAK,SAAS,EAAE,IACrD,WAAW,QACT,EAAE,MAAM,MAAM,OAAO,KAAK,QAAQ,GAAG,KAAK,MAAM,MAAM,KAAK,OAAO,IAClE,EAAE,MAAM,MAAM,OAAO,KAAK,QAAQ,GAAG,KAAK,MAAM,IAAI;AAC9D,SAAO;AAAA,IACL,MAAM,MAAM,IAAI,MAAM,gBAAgB,SAAS,QAAQ,KAAK,QAAQ,cAAc;AAAA,IAClF,KAAK,MAAM,IAAI,KAAK,gBAAgB,SAAS,SAAS,KAAK,SAAS,cAAc;AAAA,IAClF,MAAM;AAAA,EACR;AACF;AAEA,SAAS,cAAc;AAAA,EACrB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAwB;AACtB,QAAM,aAAa,OAA+B,IAAI;AACtD,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAI5C,QAAM,CAAC,KAAK,MAAM,IAAI,SAAoB,KAAK;AAC/C,QAAM,OAAO,CAAC,OAAgB;AAC5B,WAAO,MAAM,EAAE,CAAC;AAChB,eAAW,IAAI;AAAA,EACjB;AACA,QAAM,KAAK,MAAM;AAIjB,eAAa,MAAM,WAAW,KAAK,GAAG,OAAO;AAE7C,SACE,iCACE;AAAA;AAAA,MAAC;AAAA;AAAA,QAKE,GAAG;AAAA,QACJ,KAAK;AAAA,QACL,WAAW,GAAG,wBAAwB,SAAS;AAAA,QAC/C,cAAc,CAAC,MAAM,KAAK,EAAE,aAAa;AAAA,QACzC,cAAc,MAAM,WAAW,KAAK;AAAA,QACpC,SAAS,CAAC,MAAM,KAAK,EAAE,aAAa;AAAA,QACpC,QAAQ,MAAM,WAAW,KAAK;AAAA,QAE7B,sBAAY,UAAU,UAAU,KAAK,MAAS;AAAA;AAAA,IACjD;AAAA,IACC,WACC,oBAAC,gBAAa,YAAwB,IAAQ,OAAc,MAAY,KAAU,QAAgB;AAAA,KAEtG;AAEJ;AAKA,SAAS,aAAa;AAAA,EACpB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAOG;AACD,QAAM,YAAY,OAA+B,IAAI;AAGrD,QAAM,OAAO,gBAAgB,YAAY,IAAI;AAM7C,QAAM,CAAC,MAAM,OAAO,IAAI,SAAkE,IAAI;AAC9F,kBAAgB,MAAM;AACpB,UAAM,WAAW,UAAU,SAAS,sBAAsB;AAC1D,YAAQ,CAAC,aAAa;AACpB,UAAI,CAAC,SAAU,QAAO;AACtB,YAAM,OAAO;AAAA,QACX,MAAM,EAAE,OAAO,SAAS,OAAO,QAAQ,SAAS,OAAO;AAAA,QACvD,UAAU,EAAE,OAAO,OAAO,YAAY,QAAQ,OAAO,YAAY;AAAA,MACnE;AAIA,aAAO,YAAY,SAAS,UAAU,IAAI,IAAI,WAAW;AAAA,IAC3D,CAAC;AAAA,EACH,GAAG,CAAC,MAAM,KAAK,CAAC;AAEhB,QAAM,WAAW,aAAa,MAAM,GAAG;AACvC,QAAM,QAAQ,OAAO,cAAc,MAAM,QAAQ,IAAI;AAIrD,QAAM,SAAS,QAAQ,OAAO,aAAa,MAAM,UAAU,KAAK,MAAM,KAAK,QAAQ,IAAI;AAEvF,MAAI,CAAC,SAAS,OAAO,aAAa,YAAa,QAAO;AACtD,SAAO;AAAA,IACL;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL;AAAA,QACA,MAAK;AAAA,QACL;AAAA,QACA,gBAAc,SAAS,KAAK;AAAA,QAC5B,OACE,SACI,EAAE,UAAU,SAAS,MAAM,OAAO,MAAM,KAAK,OAAO,IAAI,IACxD;AAAA,UACE,UAAU;AAAA,UACV,MAAM,MAAM;AAAA,UACZ,KAAK,MAAM;AAAA,UACX,WAAW,sBAAsB,QAAQ;AAAA,QAC3C;AAAA,QAEN,WAAW,GAAG,iBAAiB,0BAA0B;AAAA,QAExD;AAAA;AAAA,IACH;AAAA,IACA,SAAS;AAAA,EACX;AACF;","names":[]}
@@ -1,10 +1,27 @@
1
1
  import * as react from 'react';
2
- import { ButtonHTMLAttributes, Ref, SelectHTMLAttributes, ReactNode, InputHTMLAttributes, ComponentPropsWithoutRef, TextareaHTMLAttributes } from 'react';
2
+ import { ButtonHTMLAttributes, Ref, ReactNode, SelectHTMLAttributes, InputHTMLAttributes, ComponentPropsWithoutRef, TextareaHTMLAttributes } from 'react';
3
3
  import { TooltipSide } from './tooltip.js';
4
4
  import '../hooks/use-anchored-rect.js';
5
5
 
6
6
  type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
7
7
  type ButtonSize = "sm" | "md";
8
+ /**
9
+ * The text colour of a `link` or `ghost` button, over the variant's own.
10
+ *
11
+ * `muted` is the QUIET link keksdose hand-rolls in six places (tours-page:321's "Mark
12
+ * undone", transaction-fields:176/215's "Add line" / "Fill total",
13
+ * invoice-lines-table:376, invoice-review:464): secondary text that darkens to the
14
+ * body colour under the pointer, for an action that must be findable but not compete
15
+ * with the brand-coloured one beside it. `danger` is the same quiet look turning
16
+ * `--danger` on hover — transaction-editor:401's "Remove split", which is a text
17
+ * action that destroys something.
18
+ *
19
+ * A `tone` rather than a `link-muted` variant, because it is the axis IconButton
20
+ * already has (`tone="muted"`, `tone="danger"`, same quiet-until-hover meaning), and
21
+ * because the same two looks are wanted on `ghost`. The filled and bordered variants
22
+ * carry their meaning in the box, not the text, so a tone on them does nothing.
23
+ */
24
+ type ButtonTone = "default" | "muted" | "danger";
8
25
  /** The second argument of {@link buttonClasses} in its options form. */
9
26
  interface ButtonClassesOptions {
10
27
  /** See {@link ButtonProps.size}. */
@@ -44,8 +61,41 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
44
61
  * ordinary prop, so it rides `...rest` onto the element with no `forwardRef` —
45
62
  * declared here only because `ButtonHTMLAttributes` does not carry it. */
46
63
  ref?: Ref<HTMLButtonElement>;
64
+ /** Text colour for `link` and `ghost`: `muted` (quiet, body colour on hover) or
65
+ * `danger` (quiet, `--danger` on hover). Ignored by the other variants. See
66
+ * {@link ButtonTone}. */
67
+ tone?: ButtonTone;
68
+ /**
69
+ * Make it a toggle button, as {@link IconButton}'s `pressed` does. `true` sets
70
+ * `aria-pressed="true"` and draws the "on" look — on a `link`, the brand colour and a
71
+ * medium weight; on the boxed variants, the quiet brand fill IconButton uses — and
72
+ * `false` sets `aria-pressed="false"` with the ordinary look. Left out, no
73
+ * `aria-pressed` (or the caller's own). For lenkbank's "All speeds" text toggle
74
+ * (gear/hysteresis-charts.tsx:482), which is a hand-rolled `<button aria-pressed>`
75
+ * swapping two class strings; with `variant="link" tone="muted"` it is this.
76
+ */
77
+ pressed?: boolean;
78
+ /**
79
+ * Why the action is not available — the button's half of DangerConfirm's
80
+ * `lockedReason` (kastlan handover-detail-page.tsx:197 locks a signed handover).
81
+ *
82
+ * A disabled button that cannot say why is a dead end: the native `disabled` takes it
83
+ * out of the tab order, so a keyboard user never lands on it, and a pointer gets a
84
+ * `not-allowed` cursor and nothing else. With a reason the button is `aria-disabled`
85
+ * instead — still focusable, still hoverable — clicks (and the Enter/Space and form
86
+ * submission they stand for) do nothing, and the reason is shown in the kit
87
+ * {@link Tooltip} and attached with `aria-describedby`, so a screen reader hears it
88
+ * on focus. It wins over `disabled`: passing both keeps the button reachable, which is
89
+ * the point of giving a reason.
90
+ *
91
+ * The description is a `hidden` copy of the reason rather than the bubble itself: the
92
+ * bubble exists only while hovered or focused once it is portalled, and a description
93
+ * that comes and goes is read inconsistently. The bubble is kept visual, the way
94
+ * DangerConfirm keeps its own.
95
+ */
96
+ disabledReason?: ReactNode;
47
97
  }
48
- declare function Button({ variant, size, stretch, className, ...rest }: ButtonProps): react.JSX.Element;
98
+ declare function Button({ variant, size, stretch, tone, pressed, disabledReason, className, onClick, ...rest }: ButtonProps): react.JSX.Element;
49
99
  type IconButtonSize = "lg" | "md" | "sm" | "xs" | "2xs";
50
100
  type IconButtonTone = "default" | "muted" | "danger" | "warning" | "info";
51
101
  interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
@@ -116,6 +166,33 @@ interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
116
166
  * The pair look exactly like the nested version, click exactly like it, and
117
167
  * are two tab stops a screen reader can tell apart. */
118
168
  stopPropagation?: boolean;
169
+ /**
170
+ * The button's name, said once: it becomes the `aria-label` AND the text of a kit
171
+ * {@link Tooltip} round the button.
172
+ *
173
+ * Every app writes the pair by hand. lenkbank's nine icon actions all carry
174
+ * `aria-label={t(x)} title={t(x)}` (shared/lib/table-columns.tsx:103,
175
+ * projects-page.tsx:83, setpoint/segment-list.tsx:198/207, profile-bar.tsx:128/142,
176
+ * shortcut-dialog.tsx:815/857/1007) — and `title` is the browser's own tooltip,
177
+ * which shows late, never on focus and never on touch, and looks like no other
178
+ * label in the kit. kastlan's RowAction (shared/components/data-table/row-action.tsx)
179
+ * and keksdose's icon wrappers each exist to put a Tooltip round an IconButton.
180
+ *
181
+ * A caller's own `aria-label` still wins, for the rare name that should be longer
182
+ * than the bubble. The bubble is visual only: it would otherwise describe the button
183
+ * with its own name, and a screen reader would read the same word twice. Tooltip's
184
+ * default placement applies — in place, or portalled inside a scroll container —
185
+ * unless `tooltipPortal` says otherwise.
186
+ */
187
+ label?: string;
188
+ /** Show `label` as a tooltip. Default `true`; `false` keeps `label` as the
189
+ * accessible name only — for a button whose glyph is universally read (a close ✕ in
190
+ * a dialog header) or that already sits under a tooltip of its own. */
191
+ tooltip?: boolean;
192
+ /** Where the `label` tooltip opens. See {@link Tooltip}'s `side`. */
193
+ tooltipSide?: TooltipSide;
194
+ /** Passed to the `label` tooltip's `portal`. Left out, Tooltip decides (see there). */
195
+ tooltipPortal?: boolean;
119
196
  }
120
197
  declare const IconButton: react.ForwardRefExoticComponent<IconButtonProps & react.RefAttributes<HTMLButtonElement>>;
121
198
  declare const FIELD_BASE: string;
@@ -441,8 +518,31 @@ interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "childre
441
518
  * wrap and centre as one row), kastlan's "Create a rule". Buttons or links the
442
519
  * caller renders; the box only places them. */
443
520
  action?: ReactNode;
521
+ /**
522
+ * `box` (default): the dashed, padded card that stands in for a whole section.
523
+ * `inline`: one quiet line with no box — for the empty state INSIDE something that
524
+ * already has its own frame, where a dashed card would be a box in a box. keksdose
525
+ * writes that line by hand five times as a muted `<p className="py-6 text-center">`:
526
+ * the notification inbox (notification-inbox:140), a support thread
527
+ * (support-thread:144), the assistant (assistant-page:321), the admin support panel
528
+ * (support-panel:265) and the funding dialog (funding-dialog:89). The icon shrinks
529
+ * to the text's size and sits before the title, the hint follows on the same line,
530
+ * and the row wraps only when it must. Centred like the box; `className="justify-start"`
531
+ * for a list that reads from the start edge (funding-dialog).
532
+ */
533
+ variant?: "box" | "inline";
534
+ /**
535
+ * Colour the icon and the title: `danger` for an error standing where the content
536
+ * should be — kastlan's ErrorState (shared/components/feedback/error-state.tsx) and
537
+ * its error boundary's fallback (error-boundary.tsx:16), both a red triangle over a
538
+ * message — and `success` for an empty state that is GOOD news, kastlan's "No defects
539
+ * recorded for this room" (handover defects-step.tsx:142, a green check). Left out,
540
+ * both stay muted. The hint stays muted either way: it is the explanation, not the
541
+ * verdict.
542
+ */
543
+ tone?: "danger" | "success";
444
544
  }
445
- declare function EmptyState({ title, hint, icon, action, headingAs, className, ...rest }: EmptyStateProps): react.JSX.Element;
545
+ declare function EmptyState({ title, hint, icon, action, headingAs, variant, tone, className, ...rest }: EmptyStateProps): react.JSX.Element;
446
546
  /** One tab of a {@link Tabs} strip. */
447
547
  interface TabItem<T extends string> {
448
548
  id: T;
@@ -627,4 +727,4 @@ interface TabsProps<T extends string> extends Omit<ComponentPropsWithoutRef<"div
627
727
  }
628
728
  declare function Tabs<T extends string>({ tabs, active, onChange, className, wrap, label, panelId, onRemove, removeOn, onAdd, addLabel, labels, busy, orientation, "aria-label": ariaLabel, ...rest }: TabsProps<T>): react.JSX.Element;
629
729
 
630
- export { Button, type ButtonClassesOptions, type ButtonProps, type ButtonSize, type ButtonVariant, Card, CardAction, type CardActionProps, CardContent, type CardContentProps, CardDescription, type CardDescriptionProps, CardFooter, type CardFooterProps, CardHeader, type CardHeaderProps, type CardProps, CardTitle, type CardTitleProps, DEFAULT_PASSWORD_REVEAL_LABELS, DEFAULT_TABS_LABELS, EmptyState, type EmptyStateProps, FIELD_BASE, FIELD_DISPLAY, FIELD_FLOATING_PAD, FIELD_INVALID, FIELD_TRIGGER, FIELD_WRITABLE_LOOK, FLOATING_INPUT_CLASS, FLOATING_LABEL_CLASS, FLOATING_LABEL_STATIC, FieldChevron, type FieldChevronProps, FieldHint, type FieldHintProps, FieldLabel, type FieldLabelProps, FloatingField, type FloatingFieldProps, IconButton, type IconButtonProps, type IconButtonSize, type IconButtonTone, Input, type InputProps, Label, type LabelProps, PHONE_QUERY, type PasswordRevealLabels, Select, type SelectProps, Spinner, type SpinnerProps, type TabItem, Tabs, type TabsLabels, type TabsProps, Textarea, type TextareaProps, buttonClasses, resolvePasswordRevealLabels };
730
+ export { Button, type ButtonClassesOptions, type ButtonProps, type ButtonSize, type ButtonTone, type ButtonVariant, Card, CardAction, type CardActionProps, CardContent, type CardContentProps, CardDescription, type CardDescriptionProps, CardFooter, type CardFooterProps, CardHeader, type CardHeaderProps, type CardProps, CardTitle, type CardTitleProps, DEFAULT_PASSWORD_REVEAL_LABELS, DEFAULT_TABS_LABELS, EmptyState, type EmptyStateProps, FIELD_BASE, FIELD_DISPLAY, FIELD_FLOATING_PAD, FIELD_INVALID, FIELD_TRIGGER, FIELD_WRITABLE_LOOK, FLOATING_INPUT_CLASS, FLOATING_LABEL_CLASS, FLOATING_LABEL_STATIC, FieldChevron, type FieldChevronProps, FieldHint, type FieldHintProps, FieldLabel, type FieldLabelProps, FloatingField, type FloatingFieldProps, IconButton, type IconButtonProps, type IconButtonSize, type IconButtonTone, Input, type InputProps, Label, type LabelProps, PHONE_QUERY, type PasswordRevealLabels, Select, type SelectProps, Spinner, type SpinnerProps, type TabItem, Tabs, type TabsLabels, type TabsProps, Textarea, type TextareaProps, buttonClasses, resolvePasswordRevealLabels };