@eifi1/ui-kit 0.4.0 → 0.5.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 (410) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +498 -57
  3. package/dist/chart.d.ts +10 -0
  4. package/dist/chart.js +10 -0
  5. package/dist/chart.js.map +1 -0
  6. package/dist/components/account-settings.d.ts +11 -0
  7. package/dist/components/account-settings.js +19 -12
  8. package/dist/components/account-settings.js.map +1 -1
  9. package/dist/components/alert-banner.d.ts +10 -6
  10. package/dist/components/alert-banner.js +20 -16
  11. package/dist/components/alert-banner.js.map +1 -1
  12. package/dist/components/amount-input.d.ts +30 -4
  13. package/dist/components/amount-input.js +36 -14
  14. package/dist/components/amount-input.js.map +1 -1
  15. package/dist/components/calculator.d.ts +24 -7
  16. package/dist/components/calculator.js +37 -20
  17. package/dist/components/calculator.js.map +1 -1
  18. package/dist/components/chart-zoom.d.ts +156 -0
  19. package/dist/components/chart-zoom.js +262 -0
  20. package/dist/components/chart-zoom.js.map +1 -0
  21. package/dist/components/chart.d.ts +28 -3
  22. package/dist/components/chart.js +71 -32
  23. package/dist/components/chart.js.map +1 -1
  24. package/dist/components/checkbox.d.ts +51 -0
  25. package/dist/components/checkbox.js +123 -0
  26. package/dist/components/checkbox.js.map +1 -0
  27. package/dist/components/chip.d.ts +128 -0
  28. package/dist/components/chip.js +357 -0
  29. package/dist/components/chip.js.map +1 -0
  30. package/dist/components/choice-parts.d.ts +24 -0
  31. package/dist/components/choice-parts.js +18 -0
  32. package/dist/components/choice-parts.js.map +1 -0
  33. package/dist/components/combobox-core.d.ts +52 -9
  34. package/dist/components/combobox-core.js +91 -20
  35. package/dist/components/combobox-core.js.map +1 -1
  36. package/dist/components/combobox.d.ts +43 -33
  37. package/dist/components/combobox.js +409 -340
  38. package/dist/components/combobox.js.map +1 -1
  39. package/dist/components/currency-select.d.ts +48 -8
  40. package/dist/components/currency-select.js +107 -65
  41. package/dist/components/currency-select.js.map +1 -1
  42. package/dist/components/data-table-filter-popover.d.ts +15 -3
  43. package/dist/components/data-table-filter-popover.js +23 -17
  44. package/dist/components/data-table-filter-popover.js.map +1 -1
  45. package/dist/components/data-table-filters.d.ts +1 -1
  46. package/dist/components/data-table-filters.js +3 -2
  47. package/dist/components/data-table-filters.js.map +1 -1
  48. package/dist/components/data-table-labels.d.ts +53 -2
  49. package/dist/components/data-table-labels.js +31 -2
  50. package/dist/components/data-table-labels.js.map +1 -1
  51. package/dist/components/data-table-pagination.d.ts +3 -1
  52. package/dist/components/data-table-pagination.js +16 -10
  53. package/dist/components/data-table-pagination.js.map +1 -1
  54. package/dist/components/data-table-sort.js +1 -0
  55. package/dist/components/data-table-sort.js.map +1 -1
  56. package/dist/components/data-table.d.ts +1 -1
  57. package/dist/components/data-table.js +201 -85
  58. package/dist/components/data-table.js.map +1 -1
  59. package/dist/components/date-picker.d.ts +25 -9
  60. package/dist/components/date-picker.js +232 -94
  61. package/dist/components/date-picker.js.map +1 -1
  62. package/dist/components/dropdown.d.ts +96 -20
  63. package/dist/components/dropdown.js +77 -33
  64. package/dist/components/dropdown.js.map +1 -1
  65. package/dist/components/entity-combobox.d.ts +5 -4
  66. package/dist/components/entity-combobox.js +107 -76
  67. package/dist/components/entity-combobox.js.map +1 -1
  68. package/dist/components/facing-pair.d.ts +36 -0
  69. package/dist/components/facing-pair.js +40 -0
  70. package/dist/components/facing-pair.js.map +1 -0
  71. package/dist/components/field-sync.d.ts +164 -0
  72. package/dist/components/field-sync.js +264 -0
  73. package/dist/components/field-sync.js.map +1 -0
  74. package/dist/components/file-dropzone.d.ts +38 -6
  75. package/dist/components/file-dropzone.js +34 -9
  76. package/dist/components/file-dropzone.js.map +1 -1
  77. package/dist/components/full-bleed-dialog.d.ts +37 -26
  78. package/dist/components/full-bleed-dialog.js +20 -5
  79. package/dist/components/full-bleed-dialog.js.map +1 -1
  80. package/dist/components/grouped-picker.d.ts +21 -10
  81. package/dist/components/grouped-picker.js +66 -52
  82. package/dist/components/grouped-picker.js.map +1 -1
  83. package/dist/components/hover-menu.d.ts +20 -4
  84. package/dist/components/hover-menu.js +15 -4
  85. package/dist/components/hover-menu.js.map +1 -1
  86. package/dist/components/mini-calendar.d.ts +102 -14
  87. package/dist/components/mini-calendar.js +256 -70
  88. package/dist/components/mini-calendar.js.map +1 -1
  89. package/dist/components/modal.d.ts +9 -3
  90. package/dist/components/modal.js +34 -45
  91. package/dist/components/modal.js.map +1 -1
  92. package/dist/components/month-picker.d.ts +79 -0
  93. package/dist/components/month-picker.js +342 -0
  94. package/dist/components/month-picker.js.map +1 -0
  95. package/dist/components/multi-entity-combobox.d.ts +7 -6
  96. package/dist/components/multi-entity-combobox.js +112 -72
  97. package/dist/components/multi-entity-combobox.js.map +1 -1
  98. package/dist/components/multi-select.d.ts +13 -11
  99. package/dist/components/multi-select.js +161 -84
  100. package/dist/components/multi-select.js.map +1 -1
  101. package/dist/components/number-field.d.ts +92 -0
  102. package/dist/components/number-field.js +111 -0
  103. package/dist/components/number-field.js.map +1 -0
  104. package/dist/components/number-input.d.ts +28 -2
  105. package/dist/components/number-input.js +12 -2
  106. package/dist/components/number-input.js.map +1 -1
  107. package/dist/components/numpad-sheet.d.ts +32 -4
  108. package/dist/components/numpad-sheet.js +30 -13
  109. package/dist/components/numpad-sheet.js.map +1 -1
  110. package/dist/components/page-contents.d.ts +90 -0
  111. package/dist/components/page-contents.js +170 -0
  112. package/dist/components/page-contents.js.map +1 -0
  113. package/dist/components/password-strength.d.ts +106 -0
  114. package/dist/components/password-strength.js +132 -0
  115. package/dist/components/password-strength.js.map +1 -0
  116. package/dist/components/picker-sheet.d.ts +23 -15
  117. package/dist/components/picker-sheet.js +34 -14
  118. package/dist/components/picker-sheet.js.map +1 -1
  119. package/dist/components/popover.d.ts +62 -4
  120. package/dist/components/popover.js +52 -2
  121. package/dist/components/popover.js.map +1 -1
  122. package/dist/components/search-field.d.ts +25 -7
  123. package/dist/components/search-field.js +7 -5
  124. package/dist/components/search-field.js.map +1 -1
  125. package/dist/components/series-chart-labels.d.ts +14 -0
  126. package/dist/components/series-chart-labels.js +11 -0
  127. package/dist/components/series-chart-labels.js.map +1 -0
  128. package/dist/components/series-chart.d.ts +207 -0
  129. package/dist/components/series-chart.js +223 -0
  130. package/dist/components/series-chart.js.map +1 -0
  131. package/dist/components/settings-fields.d.ts +14 -4
  132. package/dist/components/settings-fields.js +19 -8
  133. package/dist/components/settings-fields.js.map +1 -1
  134. package/dist/components/signature-pad.d.ts +119 -0
  135. package/dist/components/signature-pad.js +405 -0
  136. package/dist/components/signature-pad.js.map +1 -0
  137. package/dist/components/slider.d.ts +80 -0
  138. package/dist/components/slider.js +155 -0
  139. package/dist/components/slider.js.map +1 -0
  140. package/dist/components/sparkline.d.ts +93 -0
  141. package/dist/components/sparkline.js +205 -0
  142. package/dist/components/sparkline.js.map +1 -0
  143. package/dist/components/stat-tile.d.ts +151 -0
  144. package/dist/components/stat-tile.js +236 -0
  145. package/dist/components/stat-tile.js.map +1 -0
  146. package/dist/components/swipeable-row.d.ts +25 -2
  147. package/dist/components/swipeable-row.js +31 -0
  148. package/dist/components/swipeable-row.js.map +1 -1
  149. package/dist/components/switch.d.ts +60 -0
  150. package/dist/components/switch.js +81 -0
  151. package/dist/components/switch.js.map +1 -0
  152. package/dist/components/time-input.d.ts +58 -0
  153. package/dist/components/time-input.js +50 -0
  154. package/dist/components/time-input.js.map +1 -0
  155. package/dist/components/toggle-group.d.ts +18 -3
  156. package/dist/components/toggle-group.js +10 -6
  157. package/dist/components/toggle-group.js.map +1 -1
  158. package/dist/components/toggle-legend.d.ts +115 -0
  159. package/dist/components/toggle-legend.js +114 -0
  160. package/dist/components/toggle-legend.js.map +1 -0
  161. package/dist/components/tooltip.d.ts +40 -11
  162. package/dist/components/tooltip.js +77 -20
  163. package/dist/components/tooltip.js.map +1 -1
  164. package/dist/components/treemap.d.ts +132 -0
  165. package/dist/components/treemap.js +179 -0
  166. package/dist/components/treemap.js.map +1 -0
  167. package/dist/components/ui.d.ts +159 -46
  168. package/dist/components/ui.js +220 -115
  169. package/dist/components/ui.js.map +1 -1
  170. package/dist/components/use-mobile-reveal.js +1 -0
  171. package/dist/components/use-mobile-reveal.js.map +1 -1
  172. package/dist/components/use-table-state.d.ts +1 -1
  173. package/dist/components/use-table-state.js +38 -17
  174. package/dist/components/use-table-state.js.map +1 -1
  175. package/dist/components/user-avatar.d.ts +15 -4
  176. package/dist/components/user-avatar.js +5 -4
  177. package/dist/components/user-avatar.js.map +1 -1
  178. package/dist/components/wizard-stepper.js +3 -2
  179. package/dist/components/wizard-stepper.js.map +1 -1
  180. package/dist/{data-table-filters-aXyf0Xub.d.ts → data-table-filters-DM1auUfH.d.ts} +27 -5
  181. package/dist/data-table.d.ts +7 -0
  182. package/dist/data-table.js +12 -0
  183. package/dist/data-table.js.map +1 -0
  184. package/dist/feedback/feedback-attachment.js +12 -11
  185. package/dist/feedback/feedback-attachment.js.map +1 -1
  186. package/dist/feedback/feedback-dialog.js +2 -1
  187. package/dist/feedback/feedback-dialog.js.map +1 -1
  188. package/dist/feedback/feedback-inbox.d.ts +33 -7
  189. package/dist/feedback/feedback-inbox.js +49 -37
  190. package/dist/feedback/feedback-inbox.js.map +1 -1
  191. package/dist/feedback.d.ts +5 -0
  192. package/dist/feedback.js +5 -0
  193. package/dist/feedback.js.map +1 -0
  194. package/dist/hooks/use-anchored-panel.js +1 -0
  195. package/dist/hooks/use-anchored-panel.js.map +1 -1
  196. package/dist/hooks/use-anchored-rect.js +1 -0
  197. package/dist/hooks/use-anchored-rect.js.map +1 -1
  198. package/dist/hooks/use-announce.d.ts +46 -0
  199. package/dist/hooks/use-announce.js +37 -0
  200. package/dist/hooks/use-announce.js.map +1 -0
  201. package/dist/hooks/use-body-scroll-lock.js +1 -0
  202. package/dist/hooks/use-body-scroll-lock.js.map +1 -1
  203. package/dist/hooks/use-close-transition.js +1 -0
  204. package/dist/hooks/use-close-transition.js.map +1 -1
  205. package/dist/hooks/use-dismiss.js +1 -0
  206. package/dist/hooks/use-dismiss.js.map +1 -1
  207. package/dist/hooks/use-focus-trap.d.ts +16 -0
  208. package/dist/hooks/use-focus-trap.js +67 -0
  209. package/dist/hooks/use-focus-trap.js.map +1 -0
  210. package/dist/hooks/use-media-query.js +1 -0
  211. package/dist/hooks/use-media-query.js.map +1 -1
  212. package/dist/hooks/use-overlay-history.js +27 -1
  213. package/dist/hooks/use-overlay-history.js.map +1 -1
  214. package/dist/hooks/use-row-swipe.js +1 -0
  215. package/dist/hooks/use-row-swipe.js.map +1 -1
  216. package/dist/i18n/defaults.d.ts +31 -0
  217. package/dist/i18n/defaults.js +63 -0
  218. package/dist/i18n/defaults.js.map +1 -0
  219. package/dist/i18n/kit-labels.d.ts +250 -0
  220. package/dist/i18n/kit-labels.js +184 -0
  221. package/dist/i18n/kit-labels.js.map +1 -0
  222. package/dist/index.d.ts +47 -22
  223. package/dist/index.js +28 -0
  224. package/dist/index.js.map +1 -1
  225. package/dist/lib/calc.js +1 -0
  226. package/dist/lib/calc.js.map +1 -1
  227. package/dist/lib/cn.js +1 -0
  228. package/dist/lib/cn.js.map +1 -1
  229. package/dist/lib/dates.js +1 -0
  230. package/dist/lib/dates.js.map +1 -1
  231. package/dist/lib/logger.js +1 -0
  232. package/dist/lib/logger.js.map +1 -1
  233. package/dist/lib/safe-storage.js +1 -0
  234. package/dist/lib/safe-storage.js.map +1 -1
  235. package/dist/search/command-palette.d.ts +11 -1
  236. package/dist/search/command-palette.js +21 -15
  237. package/dist/search/command-palette.js.map +1 -1
  238. package/dist/search.d.ts +2 -0
  239. package/dist/search.js +3 -0
  240. package/dist/search.js.map +1 -0
  241. package/dist/shell/app-shell.d.ts +33 -4
  242. package/dist/shell/app-shell.js +275 -78
  243. package/dist/shell/app-shell.js.map +1 -1
  244. package/dist/shell/option-switcher-menu.js +3 -2
  245. package/dist/shell/option-switcher-menu.js.map +1 -1
  246. package/dist/shell/role-switcher.d.ts +6 -0
  247. package/dist/shell/role-switcher.js +7 -4
  248. package/dist/shell/role-switcher.js.map +1 -1
  249. package/dist/shell/top-bar.d.ts +15 -7
  250. package/dist/shell/top-bar.js +3 -5
  251. package/dist/shell/top-bar.js.map +1 -1
  252. package/dist/shell/topbar-action-menu.js +3 -2
  253. package/dist/shell/topbar-action-menu.js.map +1 -1
  254. package/dist/shell/topbar-controls.d.ts +5 -2
  255. package/dist/shell/topbar-controls.js +20 -15
  256. package/dist/shell/topbar-controls.js.map +1 -1
  257. package/dist/shell.d.ts +13 -0
  258. package/dist/shell.js +8 -0
  259. package/dist/shell.js.map +1 -0
  260. package/dist/theme/chart-palette.d.ts +19 -12
  261. package/dist/theme/chart-palette.js +24 -30
  262. package/dist/theme/chart-palette.js.map +1 -1
  263. package/dist/theme/color.d.ts +78 -0
  264. package/dist/theme/color.js +148 -0
  265. package/dist/theme/color.js.map +1 -0
  266. package/dist/theme/palette-derive.d.ts +140 -0
  267. package/dist/theme/palette-derive.js +376 -0
  268. package/dist/theme/palette-derive.js.map +1 -0
  269. package/dist/theme/palette-presets.d.ts +27 -5
  270. package/dist/theme/palette-presets.js +231 -33
  271. package/dist/theme/palette-presets.js.map +1 -1
  272. package/dist/theme/palette-store.js +1 -0
  273. package/dist/theme/palette-store.js.map +1 -1
  274. package/dist/theme/theme-store.js +1 -0
  275. package/dist/theme/theme-store.js.map +1 -1
  276. package/dist/tour/tour.d.ts +2 -1
  277. package/dist/tour/tour.js +15 -9
  278. package/dist/tour/tour.js.map +1 -1
  279. package/dist/tour.d.ts +2 -0
  280. package/dist/tour.js +3 -0
  281. package/dist/tour.js.map +1 -0
  282. package/dist/wizard/stepper-nav.js +10 -8
  283. package/dist/wizard/stepper-nav.js.map +1 -1
  284. package/dist/wizard/types.d.ts +18 -2
  285. package/dist/wizard/types.js +4 -1
  286. package/dist/wizard/types.js.map +1 -1
  287. package/dist/wizard/use-wizard.js +12 -4
  288. package/dist/wizard/use-wizard.js.map +1 -1
  289. package/dist/wizard/validation.js +1 -0
  290. package/dist/wizard/validation.js.map +1 -1
  291. package/dist/wizard/wizard-context.js +1 -0
  292. package/dist/wizard/wizard-context.js.map +1 -1
  293. package/dist/wizard/wizard-step.js +1 -0
  294. package/dist/wizard/wizard-step.js.map +1 -1
  295. package/dist/wizard/wizard-summary.js +5 -3
  296. package/dist/wizard/wizard-summary.js.map +1 -1
  297. package/dist/wizard.d.ts +8 -0
  298. package/dist/wizard.js +9 -0
  299. package/dist/wizard.js.map +1 -0
  300. package/package.json +67 -14
  301. package/src/chart.ts +22 -0
  302. package/src/components/account-settings.tsx +275 -0
  303. package/src/components/alert-banner.tsx +75 -0
  304. package/src/components/amount-input.tsx +471 -0
  305. package/src/components/calculator.tsx +252 -0
  306. package/src/components/chart-zoom.tsx +499 -0
  307. package/src/components/chart.tsx +363 -0
  308. package/src/components/checkbox.tsx +222 -0
  309. package/src/components/chip.tsx +570 -0
  310. package/src/components/choice-parts.ts +33 -0
  311. package/src/components/combobox-core.tsx +540 -0
  312. package/src/components/combobox.tsx +1026 -0
  313. package/src/components/currency-select.tsx +269 -0
  314. package/src/components/data-table-filter-popover.tsx +252 -0
  315. package/src/components/data-table-filters.ts +176 -0
  316. package/src/components/data-table-labels.ts +190 -0
  317. package/src/components/data-table-pagination.tsx +126 -0
  318. package/src/components/data-table-sort.ts +70 -0
  319. package/src/components/data-table.tsx +1740 -0
  320. package/src/components/date-picker.tsx +631 -0
  321. package/src/components/dropdown.tsx +341 -0
  322. package/src/components/entity-combobox.tsx +215 -0
  323. package/src/components/facing-pair.ts +86 -0
  324. package/src/components/field-sync.tsx +472 -0
  325. package/src/components/file-dropzone.tsx +181 -0
  326. package/src/components/full-bleed-dialog.tsx +179 -0
  327. package/src/components/grouped-picker.tsx +157 -0
  328. package/src/components/hover-menu.tsx +183 -0
  329. package/src/components/mini-calendar.tsx +554 -0
  330. package/src/components/modal.tsx +287 -0
  331. package/src/components/month-picker.tsx +549 -0
  332. package/src/components/multi-entity-combobox.tsx +226 -0
  333. package/src/components/multi-select.tsx +303 -0
  334. package/src/components/number-field.tsx +225 -0
  335. package/src/components/number-input.tsx +211 -0
  336. package/src/components/numpad-sheet.tsx +264 -0
  337. package/src/components/page-contents.tsx +289 -0
  338. package/src/components/password-strength.tsx +258 -0
  339. package/src/components/picker-sheet.tsx +251 -0
  340. package/src/components/popover.tsx +195 -0
  341. package/src/components/search-field.tsx +146 -0
  342. package/src/components/series-chart-labels.ts +26 -0
  343. package/src/components/series-chart.tsx +481 -0
  344. package/src/components/settings-fields.tsx +68 -0
  345. package/src/components/signature-pad.tsx +630 -0
  346. package/src/components/slider.tsx +313 -0
  347. package/src/components/sparkline.tsx +329 -0
  348. package/src/components/stat-tile.tsx +448 -0
  349. package/src/components/swipeable-row.tsx +271 -0
  350. package/src/components/switch.tsx +170 -0
  351. package/src/components/time-input.tsx +107 -0
  352. package/src/components/toggle-group.tsx +153 -0
  353. package/src/components/toggle-legend.tsx +229 -0
  354. package/src/components/tooltip.tsx +477 -0
  355. package/src/components/treemap.tsx +390 -0
  356. package/src/components/ui.tsx +1284 -0
  357. package/src/components/use-mobile-reveal.ts +54 -0
  358. package/src/components/use-table-state.ts +295 -0
  359. package/src/components/user-avatar.tsx +61 -0
  360. package/src/components/wizard-stepper.tsx +37 -0
  361. package/src/data-table.ts +19 -0
  362. package/src/feedback/feedback-attachment.tsx +246 -0
  363. package/src/feedback/feedback-dialog.tsx +209 -0
  364. package/src/feedback/feedback-inbox.tsx +615 -0
  365. package/src/feedback.ts +8 -0
  366. package/src/hooks/use-anchored-panel.ts +169 -0
  367. package/src/hooks/use-anchored-rect.ts +78 -0
  368. package/src/hooks/use-announce.ts +85 -0
  369. package/src/hooks/use-body-scroll-lock.ts +94 -0
  370. package/src/hooks/use-close-transition.ts +99 -0
  371. package/src/hooks/use-dismiss.ts +77 -0
  372. package/src/hooks/use-focus-trap.ts +160 -0
  373. package/src/hooks/use-media-query.ts +23 -0
  374. package/src/hooks/use-overlay-history.ts +345 -0
  375. package/src/hooks/use-row-swipe.ts +212 -0
  376. package/src/i18n/defaults.ts +68 -0
  377. package/src/i18n/kit-labels.tsx +447 -0
  378. package/src/index.ts +169 -0
  379. package/src/lib/calc.ts +309 -0
  380. package/src/lib/cn.ts +5 -0
  381. package/src/lib/dates.ts +187 -0
  382. package/src/lib/logger.ts +73 -0
  383. package/src/lib/safe-storage.ts +37 -0
  384. package/src/search/command-palette.tsx +342 -0
  385. package/src/search.ts +3 -0
  386. package/src/shell/app-shell.tsx +772 -0
  387. package/src/shell/option-switcher-menu.tsx +72 -0
  388. package/src/shell/role-switcher.tsx +46 -0
  389. package/src/shell/top-bar.tsx +39 -0
  390. package/src/shell/topbar-action-menu.tsx +91 -0
  391. package/src/shell/topbar-controls.tsx +229 -0
  392. package/src/shell.ts +10 -0
  393. package/src/theme/chart-palette.ts +145 -0
  394. package/src/theme/color.ts +266 -0
  395. package/src/theme/palette-derive.ts +681 -0
  396. package/src/theme/palette-presets.ts +760 -0
  397. package/src/theme/palette-store.ts +94 -0
  398. package/src/theme/theme-store.ts +145 -0
  399. package/src/tour/tour.tsx +586 -0
  400. package/src/tour.ts +3 -0
  401. package/src/vite-env.d.ts +26 -0
  402. package/src/wizard/stepper-nav.tsx +221 -0
  403. package/src/wizard/types.ts +178 -0
  404. package/src/wizard/use-wizard.ts +345 -0
  405. package/src/wizard/validation.ts +37 -0
  406. package/src/wizard/wizard-context.tsx +29 -0
  407. package/src/wizard/wizard-step.tsx +14 -0
  408. package/src/wizard/wizard-summary.tsx +69 -0
  409. package/src/wizard.ts +14 -0
  410. package/tokens.css +222 -20
@@ -0,0 +1,477 @@
1
+ import {
2
+ cloneElement,
3
+ isValidElement,
4
+ useId,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ type ComponentPropsWithoutRef,
9
+ type ReactElement,
10
+ type ReactNode,
11
+ } from "react";
12
+ import { createPortal } from "react-dom";
13
+ import { cn } from "../lib/cn";
14
+ import { useEscapeKey } from "../hooks/use-dismiss";
15
+ import { useAnchoredRect, type AnchorRect } from "../hooks/use-anchored-rect";
16
+
17
+ type TooltipSide = "top" | "bottom" | "left" | "right";
18
+
19
+ /** The floating bubble itself. Uses the shared surface/border/text tokens so it
20
+ * reads as part of the app's chrome (like the top bar and cards) rather than the
21
+ * cold slate pill it used to be.
22
+ *
23
+ * `w-max` keeps a short label on one line — the old `whitespace-nowrap` did that
24
+ * too, but it also let a sentence-length label grow without bound, and a bubble
25
+ * wider than the space beside its trigger gets clipped by whatever overflow
26
+ * container it sits in. So cap it and let long text wrap instead. The cap tracks
27
+ * the viewport as well, for narrow screens where 20rem is already most of it.
28
+ * `side` is a preference rather than an instruction for the PORTALLED variant,
29
+ * which measures the bubble and turns it round when it would not fit
30
+ * (Steering Design feedback #126). The CSS-only one never learns its own size,
31
+ * so there `side` is still the whole of the placement. */
32
+ const TOOLTIP_SURFACE =
33
+ "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";
34
+
35
+ const sidePositionClass: Record<TooltipSide, string> = {
36
+ top: "bottom-full left-1/2 -translate-x-1/2 mb-1",
37
+ bottom: "top-full left-1/2 -translate-x-1/2 mt-1",
38
+ left: "right-full top-1/2 -translate-y-1/2 mr-1",
39
+ right: "left-full top-1/2 -translate-y-1/2 ml-1",
40
+ };
41
+
42
+ /**
43
+ * `extends ComponentPropsWithoutRef<"span">` because the wrapper this renders IS a span,
44
+ * and a tooltip is the component a caller most often needs to reach past: it sits
45
+ * between the layout and the control, so a `data-tour` anchor, a test id or an
46
+ * `aria-label` aimed at the trigger used to be swallowed by it.
47
+ *
48
+ * ⚠️ On the empty-label branch there is no wrapper at all, and therefore nothing for
49
+ * those attributes to land on — see the note in the body.
50
+ */
51
+ export interface TooltipProps extends ComponentPropsWithoutRef<"span"> {
52
+ label: ReactNode;
53
+ side?: TooltipSide;
54
+ className?: string;
55
+ portal?: boolean;
56
+ /** Tag the bubble `data-private`, for a label that repeats the user's own data. */
57
+ redact?: boolean;
58
+ children: ReactNode;
59
+ }
60
+
61
+ /** What each variant below takes: the resolved `side`, and every span attribute the
62
+ * caller handed {@link Tooltip}, forwarded to that variant's own wrapper. */
63
+ type TooltipVariantProps = Omit<TooltipProps, "side" | "portal"> & { side: TooltipSide };
64
+
65
+ /**
66
+ * Hover/focus label for a control.
67
+ *
68
+ * Two implementations, and the choice matters more than it looks. The default is
69
+ * CSS-only: the bubble is always mounted next to the trigger and fades in on
70
+ * `:hover`, which costs no state and works in a plain render test. The `portal`
71
+ * variant mounts the bubble in `document.body` only while hovered, positioned by
72
+ * measurement.
73
+ *
74
+ * ⚠️ **A bubble that repeats a value has to be redactable.** The consuming app blurs
75
+ * `[data-private]` under a `demo-mode` class on `<html>` — and the portalled bubble is
76
+ * mounted on `document.body`, which is INSIDE that class, so the rule reaches it as
77
+ * long as the bubble is tagged. It is not tagged by default, because most labels are
78
+ * UI strings; pass `redact` on the ones that repeat the user's own data (a truncated
79
+ * payee, an account name, a memo). Getting this wrong is silent: the trigger blurs,
80
+ * the bubble spells the value out on hover.
81
+ *
82
+ * ⚠️ **An empty label renders nothing at all.** `title={payee ?? ""}` is an ordinary
83
+ * shape at a call site that reveals truncated text, and the native attribute answers
84
+ * it by showing no tooltip. A component that faithfully rendered an empty bubble
85
+ * would be a worse `title`, so the emptiness check is here rather than at every call
86
+ * site that could forget it.
87
+ *
88
+ * ⚠️ **The bubble describes its trigger, which means cloning it.** `role="tooltip"` is
89
+ * a name for a box, not a relationship — so for as long as nothing referenced the
90
+ * bubble, the label reached the pointer and nobody else. That is worst on the call
91
+ * sites that need it most: the kit's icon-only buttons, where the tooltip IS the
92
+ * label. `aria-describedby` has to sit on the focusable element, which is the
93
+ * caller's child and not this component's wrapper, so the child is CLONED to carry
94
+ * it. A description the caller already set is appended to, never replaced — a field's
95
+ * error text and its hint bubble both describe it. Children that cannot take props (a
96
+ * fragment, a bare string, several elements) are left exactly as they were.
97
+ *
98
+ * ⚠️ **Escape dismisses it (WCAG 1.4.13).** Anything that appears on hover or focus has
99
+ * to be dismissible without moving the pointer, and a bubble is opaque: it lands over
100
+ * the row, field or figure you were reading, and the only way out of it used to be to
101
+ * point somewhere else — which is precisely what you cannot do when what you need to
102
+ * read is underneath it. The listener is the document's rather than the wrapper's
103
+ * because the pointer opens this with the keyboard focus somewhere else entirely, and
104
+ * it is subscribed only while a bubble is actually up: the CSS variant is always
105
+ * mounted, and a table of forty tooltips must not mean forty keydown listeners.
106
+ *
107
+ * ⚠️ **Inside a scroll container, use `portal`.** An always-mounted bubble is
108
+ * absolutely positioned, but an absolutely positioned descendant still counts
109
+ * towards its scroll-container ancestor's scrollable overflow — so an invisible
110
+ * bubble on a control near the right edge makes the container scroll sideways
111
+ * with nothing to reveal. That is what Keksdose feedback dev#488 reported on the
112
+ * admin roster: 66px of horizontal scroll on a table that fit, 44px of it owed to
113
+ * tooltips nobody could see. The portalled bubble is `position: fixed` and absent
114
+ * until hovered, so it adds no width — and, being outside the container, it also
115
+ * cannot be clipped by it.
116
+ */
117
+ export function Tooltip({
118
+ label,
119
+ side = "top",
120
+ className,
121
+ portal = false,
122
+ redact = false,
123
+ children,
124
+ ...rest
125
+ }: TooltipProps) {
126
+ // No label, no bubble — and no wrapper either, so a conditional tooltip costs the
127
+ // layout nothing on the branch where it does not apply. `...rest` goes with the
128
+ // wrapper on this branch, which is the documented limit of the pass-through: there is
129
+ // no element left to put an attribute on.
130
+ if (isEmptyLabel(label)) return <>{children}</>;
131
+ if (portal) {
132
+ return (
133
+ <PortalTooltip
134
+ label={label}
135
+ side={side}
136
+ className={className}
137
+ redact={redact}
138
+ {...rest}
139
+ >
140
+ {children}
141
+ </PortalTooltip>
142
+ );
143
+ }
144
+ // Both variants are separate components so that `Tooltip` itself can keep calling NO
145
+ // hooks: the empty-label branch above returns before either of them, and a hook after
146
+ // a conditional return is a hooks-order bug rather than a style violation.
147
+ return (
148
+ <CssTooltip label={label} side={side} className={className} redact={redact} {...rest}>
149
+ {children}
150
+ </CssTooltip>
151
+ );
152
+ }
153
+
154
+ /** The always-mounted variant: the bubble sits next to the trigger and CSS fades it in.
155
+ *
156
+ * It holds the little state it does for the two things CSS cannot express — which
157
+ * element to point `aria-describedby` at, and Escape — and not for the fade, which is
158
+ * still `group-hover`/`group-focus-within` and still costs a render nothing. */
159
+ function CssTooltip({
160
+ label,
161
+ side,
162
+ className,
163
+ redact,
164
+ children,
165
+ ...rest
166
+ }: TooltipVariantProps) {
167
+ const id = useId();
168
+ const [hovered, setHovered] = useState(false);
169
+ const [focused, setFocused] = useState(false);
170
+ const [dismissed, setDismissed] = useState(false);
171
+ useEscapeKey(() => setDismissed(true), (hovered || focused) && !dismissed);
172
+ return (
173
+ <span
174
+ // `...rest` first: the four handlers below are what decides whether a bubble is
175
+ // up, and a caller passing an `onFocus` of its own must not replace them.
176
+ {...rest}
177
+ className={cn("relative inline-flex group/tooltip", className)}
178
+ // These four track WHETHER A BUBBLE IS UP. They activate nothing — the only thing
179
+ // here that can be activated is the caller's child, which keeps every handler it
180
+ // arrived with — so this wrapper needs no role and no key handling of its own.
181
+ // `jsx-a11y/no-static-element-interactions` warns about it all the same, as it
182
+ // already does about the portal variant's identical trigger below; both are left
183
+ // visible rather than silenced, because a rule this package ratchets should be
184
+ // argued with in the backlog and not in a disable comment.
185
+ //
186
+ // Re-armed by the next hover or focus rather than by an effect watching those
187
+ // flags: coming back to a trigger is a fresh request for its label, and an effect
188
+ // would also re-show the bubble under a pointer that never left.
189
+ onMouseEnter={() => {
190
+ setHovered(true);
191
+ setDismissed(false);
192
+ }}
193
+ onMouseLeave={() => setHovered(false)}
194
+ onFocus={() => {
195
+ setFocused(true);
196
+ setDismissed(false);
197
+ }}
198
+ onBlur={() => setFocused(false)}
199
+ >
200
+ {describedBy(children, dismissed ? undefined : id)}
201
+ <span
202
+ id={id}
203
+ role="tooltip"
204
+ // The `hidden` ATTRIBUTE, not an opacity class: dismissing has to take the
205
+ // bubble out of the accessibility tree as well as off the screen, or a screen
206
+ // reader still reads out the description of a bubble the user just closed.
207
+ hidden={dismissed || undefined}
208
+ data-private={redact ? "" : undefined}
209
+ className={cn(
210
+ TOOLTIP_SURFACE,
211
+ "pointer-events-none absolute z-50 opacity-0 group-hover/tooltip:opacity-100 group-focus-within/tooltip:opacity-100",
212
+ sidePositionClass[side],
213
+ )}
214
+ >
215
+ {label}
216
+ </span>
217
+ </span>
218
+ );
219
+ }
220
+
221
+ /** Hand `children` the bubble's id as an `aria-describedby`, if it is an element that
222
+ * can hold one. `id` is undefined while there is no bubble to point at — a dangling
223
+ * reference describes the trigger as nothing at all, which is worse than silence. */
224
+ function describedBy(children: ReactNode, id: string | undefined): ReactNode {
225
+ if (id === undefined || !isValidElement(children)) return children;
226
+ const child = children as ReactElement<{ "aria-describedby"?: string }>;
227
+ // Fragments, Suspense and friends are symbol-typed and take no DOM props; cloning one
228
+ // with an aria attribute warns in development and drops it in production.
229
+ if (typeof child.type === "symbol") return children;
230
+ const own = child.props["aria-describedby"];
231
+ return cloneElement(child, { "aria-describedby": own ? `${own} ${id}` : id });
232
+ }
233
+
234
+ /** "Would this bubble be blank." Only the values a call site actually produces when
235
+ * it has nothing to say — `""`, `null`, `undefined`, `false` from a `&&` guard. A
236
+ * numeric `0` is a real label and stays one. */
237
+ function isEmptyLabel(label: ReactNode): boolean {
238
+ return (
239
+ label == null ||
240
+ label === false ||
241
+ (typeof label === "string" && label.trim() === "")
242
+ );
243
+ }
244
+
245
+ const TOOLTIP_GAP = 4;
246
+
247
+ /** How close to the viewport edge a bubble may sit. Not zero: a label flush
248
+ * against the glass reads as clipped even when every character is on screen. */
249
+ const TOOLTIP_MARGIN = 4;
250
+
251
+ const portalTransformBySide: Record<TooltipSide, string> = {
252
+ right: "translate(0, -50%)",
253
+ left: "translate(-100%, -50%)",
254
+ top: "translate(-50%, -100%)",
255
+ bottom: "translate(-50%, 0)",
256
+ };
257
+
258
+ /** Anchor point (viewport px) for the tooltip on the given side of `r`. Paired
259
+ * with {@link portalTransformBySide}, which shifts the box onto that point. */
260
+ function tooltipAnchor(
261
+ r: AnchorRect,
262
+ side: TooltipSide,
263
+ ): { left: number; top: number } {
264
+ switch (side) {
265
+ case "right":
266
+ return { left: r.right + TOOLTIP_GAP, top: r.top + r.height / 2 };
267
+ case "left":
268
+ return { left: r.left - TOOLTIP_GAP, top: r.top + r.height / 2 };
269
+ case "top":
270
+ return { left: r.left + r.width / 2, top: r.top - TOOLTIP_GAP };
271
+ case "bottom":
272
+ return { left: r.left + r.width / 2, top: r.bottom + TOOLTIP_GAP };
273
+ }
274
+ }
275
+
276
+ export interface TooltipSize {
277
+ width: number;
278
+ height: number;
279
+ }
280
+
281
+ export interface TooltipViewport {
282
+ width: number;
283
+ height: number;
284
+ }
285
+
286
+ export interface TooltipPlacement {
287
+ left: number;
288
+ top: number;
289
+ /** Which side it ended up on, which need not be the one that was asked for. */
290
+ side: TooltipSide;
291
+ }
292
+
293
+ const opposite: Record<TooltipSide, TooltipSide> = {
294
+ left: "right",
295
+ right: "left",
296
+ top: "bottom",
297
+ bottom: "top",
298
+ };
299
+
300
+ /** Whether the bubble clears the viewport edge on `side` of the trigger. */
301
+ function roomOn(
302
+ r: AnchorRect,
303
+ side: TooltipSide,
304
+ size: TooltipSize,
305
+ viewport: TooltipViewport,
306
+ ): boolean {
307
+ switch (side) {
308
+ case "left":
309
+ return r.left - TOOLTIP_GAP - size.width >= TOOLTIP_MARGIN;
310
+ case "right":
311
+ return r.right + TOOLTIP_GAP + size.width <= viewport.width - TOOLTIP_MARGIN;
312
+ case "top":
313
+ return r.top - TOOLTIP_GAP - size.height >= TOOLTIP_MARGIN;
314
+ case "bottom":
315
+ return r.bottom + TOOLTIP_GAP + size.height <= viewport.height - TOOLTIP_MARGIN;
316
+ }
317
+ }
318
+
319
+ function sameRoom(
320
+ a: { size: TooltipSize; viewport: TooltipViewport },
321
+ b: { size: TooltipSize; viewport: TooltipViewport },
322
+ ): boolean {
323
+ return (
324
+ a.size.width === b.size.width &&
325
+ a.size.height === b.size.height &&
326
+ a.viewport.width === b.viewport.width &&
327
+ a.viewport.height === b.viewport.height
328
+ );
329
+ }
330
+
331
+ function clamp(value: number, low: number, high: number): number {
332
+ // `high` first, so a bubble taller or wider than the viewport is pinned to the
333
+ // top-left corner rather than to the bottom-right one — the start of a label
334
+ // is the half worth keeping.
335
+ return Math.max(low, Math.min(value, high));
336
+ }
337
+
338
+ /**
339
+ * Where the bubble actually goes, given how big it turned out to be.
340
+ *
341
+ * Two rules, and they are separate because they fix separate failures.
342
+ *
343
+ * **Turn round when the preferred side has no room.** `side` says which side of
344
+ * the trigger the label reads best on, and on a form near the left edge of the
345
+ * window that side is off the screen — the capped bubble can only wrap, not
346
+ * move, so what the reader gets is a sentence with its first half outside the
347
+ * glass. Flipped only when the *other* side is genuinely better: a trigger in a
348
+ * viewport too narrow for the bubble either way keeps the side it asked for, and
349
+ * the clamp below does what it can.
350
+ *
351
+ * **Then clamp both axes.** The cross axis is the one that needs it — a `top`
352
+ * bubble is centred on the trigger, so a trigger near the left edge pushes half
353
+ * the label off even though the side it is on is right — and clamping the main
354
+ * axis too costs nothing and covers the flip having nowhere to land.
355
+ *
356
+ * Pure, and measured in viewport pixels throughout, so it can be tested without
357
+ * a layout: the caller supplies the trigger's rect, the bubble's own size and
358
+ * the window.
359
+ */
360
+ export function placeTooltip(
361
+ r: AnchorRect,
362
+ side: TooltipSide,
363
+ size: TooltipSize,
364
+ viewport: TooltipViewport,
365
+ ): TooltipPlacement {
366
+ const chosen =
367
+ roomOn(r, side, size, viewport) || !roomOn(r, opposite[side], size, viewport)
368
+ ? side
369
+ : opposite[side];
370
+ const point = tooltipAnchor(r, chosen);
371
+ const box =
372
+ chosen === "left"
373
+ ? { left: point.left - size.width, top: point.top - size.height / 2 }
374
+ : chosen === "right"
375
+ ? { left: point.left, top: point.top - size.height / 2 }
376
+ : chosen === "top"
377
+ ? { left: point.left - size.width / 2, top: point.top - size.height }
378
+ : { left: point.left - size.width / 2, top: point.top };
379
+ return {
380
+ left: clamp(box.left, TOOLTIP_MARGIN, viewport.width - size.width - TOOLTIP_MARGIN),
381
+ top: clamp(box.top, TOOLTIP_MARGIN, viewport.height - size.height - TOOLTIP_MARGIN),
382
+ side: chosen,
383
+ };
384
+ }
385
+
386
+ function PortalTooltip({
387
+ label,
388
+ side,
389
+ className,
390
+ redact,
391
+ children,
392
+ ...rest
393
+ }: TooltipVariantProps) {
394
+ const triggerRef = useRef<HTMLSpanElement | null>(null);
395
+ const bubbleRef = useRef<HTMLSpanElement | null>(null);
396
+ const [visible, setVisible] = useState(false);
397
+ const id = useId();
398
+ // Escape closes it outright, since this variant's bubble only exists while it is
399
+ // shown. The next mouseenter/focus brings it back, which is the behaviour WCAG
400
+ // 1.4.13 asks for: dismissible now, still available when you ask again.
401
+ useEscapeKey(() => setVisible(false), visible);
402
+ // The measure + scroll/resize-tracking lifecycle is owned by useAnchoredRect;
403
+ // here we only map the rect to a side-specific anchor point.
404
+ const rect = useAnchoredRect(triggerRef, visible);
405
+ // The bubble's own size and the window it has to fit in — neither of which is
406
+ // knowable in render: the width is whatever the label wrapped to inside the
407
+ // cap, and reading `window` while rendering is not a pure thing to do. Both
408
+ // are taken in a LAYOUT effect, so the correction lands before the browser
409
+ // paints and there is no frame in which the label sits off the screen.
410
+ const [room, setRoom] = useState<{ size: TooltipSize; viewport: TooltipViewport } | null>(null);
411
+ useLayoutEffect(() => {
412
+ const measured = visible ? bubbleRef.current?.getBoundingClientRect() : undefined;
413
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- a measurement is the one thing a layout effect is for
414
+ setRoom((previous) => {
415
+ if (!measured) return null;
416
+ const next = {
417
+ size: { width: measured.width, height: measured.height },
418
+ viewport: { width: window.innerWidth, height: window.innerHeight },
419
+ };
420
+ // Only publish what actually CHANGED: every re-measure allocates a fresh
421
+ // object, and a new object on every scroll event would re-render the
422
+ // bubble forever.
423
+ return previous && sameRoom(previous, next) ? previous : next;
424
+ });
425
+ }, [visible, rect, label]);
426
+
427
+ const point = rect ? tooltipAnchor(rect, side) : null;
428
+ // Unmeasured on the very first pass, where the anchor point plus the side's
429
+ // own transform is exactly what this always did. One layout effect later the
430
+ // size is known and the placement is decided properly.
431
+ const placed = rect && room ? placeTooltip(rect, side, room.size, room.viewport) : null;
432
+
433
+ return (
434
+ <>
435
+ <span
436
+ // As in `CssTooltip`: the caller's attributes first, the four handlers that run
437
+ // this component after them. The BUBBLE is deliberately not given them — it is
438
+ // portalled to `<body>`, and an id or a tour anchor duplicated onto a node that
439
+ // only exists while hovered would match twice or match nothing.
440
+ {...rest}
441
+ ref={triggerRef}
442
+ className={cn("relative inline-flex", className)}
443
+ onMouseEnter={() => setVisible(true)}
444
+ onMouseLeave={() => setVisible(false)}
445
+ onFocus={() => setVisible(true)}
446
+ onBlur={() => setVisible(false)}
447
+ >
448
+ {describedBy(children, visible ? id : undefined)}
449
+ </span>
450
+ {visible &&
451
+ point &&
452
+ typeof document !== "undefined" &&
453
+ createPortal(
454
+ <span
455
+ ref={bubbleRef}
456
+ id={id}
457
+ role="tooltip"
458
+ data-private={redact ? "" : undefined}
459
+ style={
460
+ placed
461
+ ? { position: "fixed", left: placed.left, top: placed.top }
462
+ : {
463
+ position: "fixed",
464
+ left: point.left,
465
+ top: point.top,
466
+ transform: portalTransformBySide[side],
467
+ }
468
+ }
469
+ className={cn(TOOLTIP_SURFACE, "pointer-events-none z-50")}
470
+ >
471
+ {label}
472
+ </span>,
473
+ document.body,
474
+ )}
475
+ </>
476
+ );
477
+ }