@eifi1/ui-kit 0.10.0 → 0.12.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 (382) hide show
  1. package/README.md +66 -37
  2. package/dist/chart.d.ts +3 -1
  3. package/dist/chart.js +2 -0
  4. package/dist/chart.js.map +1 -1
  5. package/dist/components/account-settings-labels.d.ts +105 -0
  6. package/dist/components/account-settings-labels.js +81 -0
  7. package/dist/components/account-settings-labels.js.map +1 -0
  8. package/dist/components/account-settings.d.ts +36 -52
  9. package/dist/components/account-settings.js +44 -8
  10. package/dist/components/account-settings.js.map +1 -1
  11. package/dist/components/alert-banner.d.ts +26 -1
  12. package/dist/components/alert-banner.js +10 -7
  13. package/dist/components/alert-banner.js.map +1 -1
  14. package/dist/components/amount-input.d.ts +26 -4
  15. package/dist/components/amount-input.js +5 -2
  16. package/dist/components/amount-input.js.map +1 -1
  17. package/dist/components/authed-image.d.ts +68 -0
  18. package/dist/components/authed-image.js +117 -0
  19. package/dist/components/authed-image.js.map +1 -0
  20. package/dist/components/breadcrumbs.d.ts +2 -1
  21. package/dist/components/breadcrumbs.js +5 -2
  22. package/dist/components/breadcrumbs.js.map +1 -1
  23. package/dist/components/bulk-action-bar.d.ts +51 -5
  24. package/dist/components/bulk-action-bar.js +88 -41
  25. package/dist/components/bulk-action-bar.js.map +1 -1
  26. package/dist/components/button-group.d.ts +107 -3
  27. package/dist/components/button-group.js +60 -5
  28. package/dist/components/button-group.js.map +1 -1
  29. package/dist/components/calculator.d.ts +17 -4
  30. package/dist/components/calendar-heatmap.d.ts +162 -0
  31. package/dist/components/calendar-heatmap.js +336 -0
  32. package/dist/components/calendar-heatmap.js.map +1 -0
  33. package/dist/components/chip.d.ts +2 -1
  34. package/dist/components/chip.js +6 -3
  35. package/dist/components/chip.js.map +1 -1
  36. package/dist/components/choice-card.d.ts +66 -2
  37. package/dist/components/choice-card.js +108 -1
  38. package/dist/components/choice-card.js.map +1 -1
  39. package/dist/components/combobox-core.js +2 -0
  40. package/dist/components/combobox-core.js.map +1 -1
  41. package/dist/components/combobox.d.ts +2 -2
  42. package/dist/components/combobox.js +31 -9
  43. package/dist/components/combobox.js.map +1 -1
  44. package/dist/components/copy-button.d.ts +52 -70
  45. package/dist/components/copy-button.js +1 -1
  46. package/dist/components/copy-button.js.map +1 -1
  47. package/dist/components/data-table-cells.d.ts +63 -0
  48. package/dist/components/data-table-cells.js +64 -0
  49. package/dist/components/data-table-cells.js.map +1 -0
  50. package/dist/components/data-table-filter-popover.d.ts +1 -1
  51. package/dist/components/data-table-filters.d.ts +1 -1
  52. package/dist/components/data-table-filters.js +31 -1
  53. package/dist/components/data-table-filters.js.map +1 -1
  54. package/dist/components/data-table-labels.d.ts +13 -0
  55. package/dist/components/data-table-labels.js +8 -1
  56. package/dist/components/data-table-labels.js.map +1 -1
  57. package/dist/components/data-table.d.ts +1 -1
  58. package/dist/components/data-table.js +104 -12
  59. package/dist/components/data-table.js.map +1 -1
  60. package/dist/components/date-picker.d.ts +3 -1
  61. package/dist/components/date-picker.js +15 -2
  62. package/dist/components/date-picker.js.map +1 -1
  63. package/dist/components/description-list.d.ts +23 -3
  64. package/dist/components/description-list.js +23 -3
  65. package/dist/components/description-list.js.map +1 -1
  66. package/dist/components/dialog-frame.d.ts +1 -0
  67. package/dist/components/entity-combobox.d.ts +1 -1
  68. package/dist/components/entity-combobox.js +18 -3
  69. package/dist/components/entity-combobox.js.map +1 -1
  70. package/dist/components/error-boundary.d.ts +92 -0
  71. package/dist/components/error-boundary.js +111 -0
  72. package/dist/components/error-boundary.js.map +1 -0
  73. package/dist/components/field.d.ts +69 -0
  74. package/dist/components/field.js +75 -0
  75. package/dist/components/field.js.map +1 -0
  76. package/dist/components/file-button.d.ts +51 -208
  77. package/dist/components/file-dropzone.d.ts +50 -2
  78. package/dist/components/floating-panel.d.ts +156 -4
  79. package/dist/components/floating-panel.js +193 -29
  80. package/dist/components/floating-panel.js.map +1 -1
  81. package/dist/components/form-actions.d.ts +53 -0
  82. package/dist/components/form-actions.js +98 -0
  83. package/dist/components/form-actions.js.map +1 -0
  84. package/dist/components/image-grid.d.ts +67 -0
  85. package/dist/components/image-grid.js +112 -0
  86. package/dist/components/image-grid.js.map +1 -0
  87. package/dist/components/lightbox.d.ts +104 -0
  88. package/dist/components/lightbox.js +229 -0
  89. package/dist/components/lightbox.js.map +1 -0
  90. package/dist/components/line-items.d.ts +114 -0
  91. package/dist/components/line-items.js +238 -0
  92. package/dist/components/line-items.js.map +1 -0
  93. package/dist/components/list.d.ts +37 -3
  94. package/dist/components/list.js +39 -14
  95. package/dist/components/list.js.map +1 -1
  96. package/dist/components/loading-state.d.ts +24 -0
  97. package/dist/components/loading-state.js +32 -0
  98. package/dist/components/loading-state.js.map +1 -0
  99. package/dist/components/menu-item.d.ts +13 -2
  100. package/dist/components/menu-item.js +11 -2
  101. package/dist/components/menu-item.js.map +1 -1
  102. package/dist/components/mini-calendar.d.ts +82 -5
  103. package/dist/components/mini-calendar.js +212 -64
  104. package/dist/components/mini-calendar.js.map +1 -1
  105. package/dist/components/modal.d.ts +20 -1
  106. package/dist/components/modal.js +26 -2
  107. package/dist/components/modal.js.map +1 -1
  108. package/dist/components/month-picker.d.ts +21 -1
  109. package/dist/components/month-picker.js +139 -26
  110. package/dist/components/month-picker.js.map +1 -1
  111. package/dist/components/multi-entity-combobox.d.ts +1 -1
  112. package/dist/components/multi-entity-combobox.js +19 -3
  113. package/dist/components/multi-entity-combobox.js.map +1 -1
  114. package/dist/components/nav-pills.d.ts +67 -0
  115. package/dist/components/nav-pills.js +69 -0
  116. package/dist/components/nav-pills.js.map +1 -0
  117. package/dist/components/number-field.d.ts +26 -5
  118. package/dist/components/number-field.js +5 -7
  119. package/dist/components/number-field.js.map +1 -1
  120. package/dist/components/number-input.d.ts +27 -5
  121. package/dist/components/number-input.js +7 -1
  122. package/dist/components/number-input.js.map +1 -1
  123. package/dist/components/numpad-sheet.d.ts +17 -4
  124. package/dist/components/passkeys-setting.d.ts +67 -0
  125. package/dist/components/passkeys-setting.js +214 -0
  126. package/dist/components/passkeys-setting.js.map +1 -0
  127. package/dist/components/picker-sheet.js +2 -1
  128. package/dist/components/picker-sheet.js.map +1 -1
  129. package/dist/components/pie-chart-labels.d.ts +20 -0
  130. package/dist/components/pie-chart-labels.js +12 -0
  131. package/dist/components/pie-chart-labels.js.map +1 -0
  132. package/dist/components/pie-chart.d.ts +101 -0
  133. package/dist/components/pie-chart.js +283 -0
  134. package/dist/components/pie-chart.js.map +1 -0
  135. package/dist/components/progress-bar.d.ts +73 -3
  136. package/dist/components/progress-bar.js +110 -8
  137. package/dist/components/progress-bar.js.map +1 -1
  138. package/dist/components/qr-code.d.ts +34 -0
  139. package/dist/components/qr-code.js +57 -0
  140. package/dist/components/qr-code.js.map +1 -0
  141. package/dist/components/search-field.d.ts +15 -3
  142. package/dist/components/search-field.js +5 -1
  143. package/dist/components/search-field.js.map +1 -1
  144. package/dist/components/series-chart.d.ts +16 -0
  145. package/dist/components/series-chart.js +4 -1
  146. package/dist/components/series-chart.js.map +1 -1
  147. package/dist/components/settings-fields.d.ts +50 -1
  148. package/dist/components/signed-amount.d.ts +121 -0
  149. package/dist/components/signed-amount.js +152 -0
  150. package/dist/components/signed-amount.js.map +1 -0
  151. package/dist/components/skeleton.d.ts +10 -3
  152. package/dist/components/skeleton.js +12 -2
  153. package/dist/components/skeleton.js.map +1 -1
  154. package/dist/components/stat-tile.d.ts +2 -1
  155. package/dist/components/stat-tile.js +5 -2
  156. package/dist/components/stat-tile.js.map +1 -1
  157. package/dist/components/status-dot.d.ts +6 -2
  158. package/dist/components/status-dot.js +6 -1
  159. package/dist/components/status-dot.js.map +1 -1
  160. package/dist/components/table.d.ts +77 -11
  161. package/dist/components/table.js +85 -17
  162. package/dist/components/table.js.map +1 -1
  163. package/dist/components/text-link.d.ts +176 -0
  164. package/dist/components/text-link.js +109 -0
  165. package/dist/components/text-link.js.map +1 -0
  166. package/dist/components/text.d.ts +8 -1
  167. package/dist/components/text.js +1 -0
  168. package/dist/components/text.js.map +1 -1
  169. package/dist/components/time-input.d.ts +50 -1
  170. package/dist/components/toggle-group.d.ts +28 -0
  171. package/dist/components/toggle-group.js +58 -9
  172. package/dist/components/toggle-group.js.map +1 -1
  173. package/dist/components/toggle-legend.d.ts +36 -3
  174. package/dist/components/toggle-legend.js +49 -0
  175. package/dist/components/toggle-legend.js.map +1 -1
  176. package/dist/components/tooltip.d.ts +11 -3
  177. package/dist/components/tooltip.js +2 -0
  178. package/dist/components/tooltip.js.map +1 -1
  179. package/dist/components/ui.d.ts +52 -729
  180. package/dist/components/ui.js +296 -27
  181. package/dist/components/ui.js.map +1 -1
  182. package/dist/components/use-table-state.d.ts +3 -52
  183. package/dist/components/use-table-state.js +128 -30
  184. package/dist/components/use-table-state.js.map +1 -1
  185. package/dist/components/user-avatar.d.ts +25 -3
  186. package/dist/components/user-avatar.js +32 -3
  187. package/dist/components/user-avatar.js.map +1 -1
  188. package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
  189. package/dist/data-table.d.ts +2 -1
  190. package/dist/data-table.js +5 -1
  191. package/dist/data-table.js.map +1 -1
  192. package/dist/feedback/feedback-dialog.d.ts +26 -3
  193. package/dist/feedback/feedback-dialog.js +16 -2
  194. package/dist/feedback/feedback-dialog.js.map +1 -1
  195. package/dist/feedback/feedback-thread.d.ts +133 -0
  196. package/dist/feedback/feedback-thread.js +237 -0
  197. package/dist/feedback/feedback-thread.js.map +1 -0
  198. package/dist/feedback.d.ts +2 -1
  199. package/dist/feedback.js +1 -0
  200. package/dist/feedback.js.map +1 -1
  201. package/dist/hooks/use-authed-src.d.ts +71 -0
  202. package/dist/hooks/use-authed-src.js +63 -0
  203. package/dist/hooks/use-authed-src.js.map +1 -0
  204. package/dist/hooks/use-file-drop.d.ts +50 -2
  205. package/dist/hooks/use-hotkey.d.ts +59 -0
  206. package/dist/hooks/use-hotkey.js +105 -0
  207. package/dist/hooks/use-hotkey.js.map +1 -0
  208. package/dist/hooks/use-search-param-state.d.ts +83 -0
  209. package/dist/hooks/use-search-param-state.js +94 -0
  210. package/dist/hooks/use-search-param-state.js.map +1 -0
  211. package/dist/i18n/defaults.d.ts +17 -4
  212. package/dist/i18n/defaults.js +33 -1
  213. package/dist/i18n/defaults.js.map +1 -1
  214. package/dist/i18n/kit-labels.d.ts +47 -310
  215. package/dist/i18n/kit-labels.js +20 -5
  216. package/dist/i18n/kit-labels.js.map +1 -1
  217. package/dist/i18n/locales/de-CH-informal.d.ts +17 -4
  218. package/dist/i18n/locales/de-CH.d.ts +17 -4
  219. package/dist/i18n/locales/de-informal.d.ts +17 -4
  220. package/dist/i18n/locales/de.d.ts +17 -4
  221. package/dist/i18n/locales/de.js +165 -4
  222. package/dist/i18n/locales/de.js.map +1 -1
  223. package/dist/i18n/locales/es.d.ts +17 -4
  224. package/dist/i18n/locales/es.js +162 -4
  225. package/dist/i18n/locales/es.js.map +1 -1
  226. package/dist/i18n/locales/fr.d.ts +17 -4
  227. package/dist/i18n/locales/fr.js +162 -4
  228. package/dist/i18n/locales/fr.js.map +1 -1
  229. package/dist/i18n/locales/hu.d.ts +17 -4
  230. package/dist/i18n/locales/hu.js +162 -4
  231. package/dist/i18n/locales/hu.js.map +1 -1
  232. package/dist/i18n/locales/it.d.ts +17 -4
  233. package/dist/i18n/locales/it.js +162 -4
  234. package/dist/i18n/locales/it.js.map +1 -1
  235. package/dist/i18n/locales/zh.d.ts +17 -4
  236. package/dist/i18n/locales/zh.js +162 -4
  237. package/dist/i18n/locales/zh.js.map +1 -1
  238. package/dist/index.d.ts +45 -21
  239. package/dist/index.js +43 -1
  240. package/dist/index.js.map +1 -1
  241. package/dist/kit-labels-v3biUF1L.d.ts +1645 -0
  242. package/dist/lib/clipping.d.ts +21 -5
  243. package/dist/lib/clipping.js +3 -0
  244. package/dist/lib/clipping.js.map +1 -1
  245. package/dist/lib/dates.d.ts +17 -1
  246. package/dist/lib/dates.js +19 -0
  247. package/dist/lib/dates.js.map +1 -1
  248. package/dist/lib/format.d.ts +144 -0
  249. package/dist/lib/format.js +149 -0
  250. package/dist/lib/format.js.map +1 -0
  251. package/dist/lib/qr-encode.d.ts +44 -0
  252. package/dist/lib/qr-encode.js +338 -0
  253. package/dist/lib/qr-encode.js.map +1 -0
  254. package/dist/rhf/fields.d.ts +241 -0
  255. package/dist/rhf/fields.js +601 -0
  256. package/dist/rhf/fields.js.map +1 -0
  257. package/dist/rhf/form.d.ts +50 -1
  258. package/dist/rhf/line-items.d.ts +31 -0
  259. package/dist/rhf/line-items.js +59 -0
  260. package/dist/rhf/line-items.js.map +1 -0
  261. package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
  262. package/dist/rhf/use-rhf-wizard-step.js +38 -0
  263. package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
  264. package/dist/rhf.d.ts +63 -1
  265. package/dist/rhf.js +3 -0
  266. package/dist/rhf.js.map +1 -1
  267. package/dist/shell/app-shell.d.ts +99 -2
  268. package/dist/shell/app-shell.js +58 -3
  269. package/dist/shell/app-shell.js.map +1 -1
  270. package/dist/shell/auth-layout.d.ts +74 -0
  271. package/dist/shell/auth-layout.js +97 -0
  272. package/dist/shell/auth-layout.js.map +1 -0
  273. package/dist/shell/top-bar-brand.d.ts +81 -0
  274. package/dist/shell/top-bar-brand.js +47 -0
  275. package/dist/shell/top-bar-brand.js.map +1 -0
  276. package/dist/shell/topbar-action-menu.d.ts +84 -3
  277. package/dist/shell/topbar-action-menu.js +115 -27
  278. package/dist/shell/topbar-action-menu.js.map +1 -1
  279. package/dist/shell.d.ts +58 -2
  280. package/dist/shell.js +2 -0
  281. package/dist/shell.js.map +1 -1
  282. package/dist/wizard/stepper-nav.d.ts +49 -1
  283. package/dist/wizard/wizard-context.d.ts +30 -1
  284. package/dist/wizard/wizard-context.js +23 -2
  285. package/dist/wizard/wizard-context.js.map +1 -1
  286. package/dist/wizard/wizard-step.d.ts +20 -5
  287. package/dist/wizard/wizard-step.js +16 -3
  288. package/dist/wizard/wizard-step.js.map +1 -1
  289. package/dist/wizard.d.ts +51 -3
  290. package/package.json +1 -1
  291. package/src/chart.ts +4 -0
  292. package/src/components/account-settings-labels.ts +190 -0
  293. package/src/components/account-settings.tsx +110 -63
  294. package/src/components/alert-banner.tsx +36 -7
  295. package/src/components/amount-input.tsx +13 -1
  296. package/src/components/authed-image.tsx +190 -0
  297. package/src/components/breadcrumbs.tsx +8 -2
  298. package/src/components/bulk-action-bar.tsx +163 -57
  299. package/src/components/button-group.tsx +129 -4
  300. package/src/components/calendar-heatmap.tsx +599 -0
  301. package/src/components/chip.tsx +8 -4
  302. package/src/components/choice-card.tsx +208 -1
  303. package/src/components/combobox-core.tsx +5 -0
  304. package/src/components/combobox.tsx +31 -9
  305. package/src/components/copy-button.tsx +3 -2
  306. package/src/components/data-table-cells.tsx +119 -0
  307. package/src/components/data-table-filters.ts +77 -0
  308. package/src/components/data-table-labels.ts +23 -0
  309. package/src/components/data-table.tsx +231 -13
  310. package/src/components/date-picker.tsx +19 -3
  311. package/src/components/description-list.tsx +52 -3
  312. package/src/components/entity-combobox.tsx +18 -3
  313. package/src/components/error-boundary.tsx +216 -0
  314. package/src/components/field.tsx +188 -0
  315. package/src/components/floating-panel.tsx +380 -16
  316. package/src/components/form-actions.tsx +191 -0
  317. package/src/components/image-grid.tsx +181 -0
  318. package/src/components/lightbox.tsx +374 -0
  319. package/src/components/line-items.tsx +385 -0
  320. package/src/components/list.tsx +93 -18
  321. package/src/components/loading-state.tsx +47 -0
  322. package/src/components/menu-item.tsx +29 -3
  323. package/src/components/mini-calendar.tsx +317 -57
  324. package/src/components/modal.tsx +61 -3
  325. package/src/components/month-picker.tsx +181 -26
  326. package/src/components/multi-entity-combobox.tsx +19 -3
  327. package/src/components/nav-pills.tsx +152 -0
  328. package/src/components/number-field.tsx +15 -10
  329. package/src/components/number-input.tsx +16 -1
  330. package/src/components/passkeys-setting.tsx +331 -0
  331. package/src/components/picker-sheet.tsx +7 -1
  332. package/src/components/pie-chart-labels.ts +32 -0
  333. package/src/components/pie-chart.tsx +479 -0
  334. package/src/components/progress-bar.tsx +241 -13
  335. package/src/components/qr-code.tsx +83 -0
  336. package/src/components/search-field.tsx +20 -4
  337. package/src/components/series-chart.tsx +21 -0
  338. package/src/components/signed-amount.tsx +286 -0
  339. package/src/components/skeleton.tsx +21 -3
  340. package/src/components/stat-tile.tsx +8 -2
  341. package/src/components/status-dot.tsx +10 -2
  342. package/src/components/table.tsx +186 -23
  343. package/src/components/text-link.tsx +248 -0
  344. package/src/components/text.tsx +9 -1
  345. package/src/components/toggle-group.tsx +106 -13
  346. package/src/components/toggle-legend.tsx +87 -2
  347. package/src/components/tooltip.tsx +17 -3
  348. package/src/components/ui.tsx +572 -44
  349. package/src/components/use-table-state.ts +265 -33
  350. package/src/components/user-avatar.tsx +57 -3
  351. package/src/data-table.ts +10 -0
  352. package/src/feedback/feedback-dialog.tsx +42 -3
  353. package/src/feedback/feedback-thread.tsx +434 -0
  354. package/src/feedback.ts +1 -0
  355. package/src/hooks/use-authed-src.ts +161 -0
  356. package/src/hooks/use-hotkey.ts +172 -0
  357. package/src/hooks/use-search-param-state.ts +187 -0
  358. package/src/i18n/defaults.ts +32 -0
  359. package/src/i18n/kit-labels.tsx +104 -8
  360. package/src/i18n/locales/de.ts +162 -0
  361. package/src/i18n/locales/es.ts +159 -0
  362. package/src/i18n/locales/fr.ts +162 -0
  363. package/src/i18n/locales/hu.ts +160 -0
  364. package/src/i18n/locales/it.ts +160 -0
  365. package/src/i18n/locales/zh.ts +159 -0
  366. package/src/index.ts +64 -0
  367. package/src/lib/clipping.ts +22 -4
  368. package/src/lib/dates.ts +37 -0
  369. package/src/lib/format.ts +325 -0
  370. package/src/lib/qr-encode.ts +420 -0
  371. package/src/rhf/fields.tsx +1054 -0
  372. package/src/rhf/line-items.tsx +130 -0
  373. package/src/rhf/use-rhf-wizard-step.ts +113 -0
  374. package/src/rhf.ts +8 -0
  375. package/src/shell/app-shell.tsx +147 -2
  376. package/src/shell/auth-layout.tsx +190 -0
  377. package/src/shell/top-bar-brand.tsx +76 -0
  378. package/src/shell/topbar-action-menu.tsx +260 -49
  379. package/src/shell.ts +2 -0
  380. package/src/wizard/wizard-context.tsx +50 -1
  381. package/src/wizard/wizard-step.tsx +43 -6
  382. package/tokens.css +12 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/toggle-group.tsx"],"sourcesContent":["import { useId } from \"react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactElement, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { horizontalStep } from \"../lib/direction\";\nimport { FIELD_INVALID, FloatingField } from \"./ui\";\n\nexport interface ToggleOption<T extends string> {\n value: T;\n label: string;\n className?: string;\n}\n\n/**\n * `onChange` is the group's own — the chosen VALUE, not a DOM event — so the div's\n * `onChange` is omitted rather than shadowed: leaving both in scope would give the\n * prop two incompatible meanings depending on which overload TypeScript picked.\n *\n * `children` is omitted too: this renders its `options` and nothing else, so a `children` the type\n * accepted and the component ignored would be a prop that silently does nothing —\n * worse than one that does not compile.\n */\nexport interface ToggleGroupBaseProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\"> {\n options: ToggleOption<T>[];\n className?: string;\n /** Applied to every option button (e.g. to tune height/rounding to match\n * adjacent fields). Per-option `className` still wins over this. */\n optionClassName?: string;\n /**\n * @deprecated Pass `aria-label` instead — the DOM spelling, which every other\n * control in this kit now answers to. Kept working because three applications ship\n * this one today; it names the group only when `aria-label` is absent.\n */\n ariaLabel?: string;\n /**\n * Show, refuse the change (Keksdose live #288: a payment dated in the future has no\n * state to set).\n *\n * Whatever `value` says stays pressed and keeps its own fill rather than going grey\n * with the rest — a reader who cannot see WHICH option is chosen has been told less\n * than before it was disabled. A caller with nothing to show passes no value, and\n * the group renders dimmed with nothing pressed, which is the shape Keksdose's\n * status picker uses for a row whose status does not exist yet.\n *\n * On the whole GROUP, not per option: a segmented control where some segments are\n * live and others are not is a menu with holes in it, and no caller here wants one.\n */\n disabled?: boolean;\n /**\n * `sm`: 12px options with `px-2 py-1` — the compact group keksdose's rule editor\n * (rule-editor:204) writes as `optionClassName=\"px-2 py-1 text-xs\"` beside a small\n * caption. `md` (default) is the size every other group has.\n */\n size?: \"sm\" | \"md\";\n /**\n * Stand in a form row as a FIELD: with a label the group wears the field's chrome —\n * border, surface, the top strip with a small static label in it, a labelled\n * {@link Select}'s height — so beside an Input or a Select it reads as one of them\n * rather than as a control with a caption over it. lenkbank builds exactly this by\n * hand as `ToggleField` (features/gear/common.tsx:97, feedback #69), and its notes\n * are why the chrome STRETCHES to its row as well as matching the select's padding:\n * a native select's height is the browser's, so a toggle a few pixels short of it\n * is levelled up by the row rather than by arithmetic.\n *\n * The label names the group (`aria-labelledby`), so `aria-label` is not needed. With\n * a label, `className` styles the field's wrapper — as on {@link Select} — and the\n * group's own box is dropped: two nested borders read as a control in a control.\n */\n label?: ReactNode;\n /** A {@link FieldHint} on the label line, as on a labelled {@link Select}. Only with\n * `label`. */\n hint?: ReactNode;\n /** The message under the field when it is wrong: paints the field's border with\n * `--danger`, marks the group `aria-invalid` and describes it with the message, as\n * {@link Select}'s `error` does. Only with `label`. */\n error?: ReactNode;\n}\n\n/** The group as it has always been: one option is always the answer. */\nexport interface ToggleGroupRequiredProps<T extends string> extends ToggleGroupBaseProps<T> {\n allowEmpty?: false;\n value: T;\n onChange: (value: T) => void;\n}\n\n/**\n * A group that can be emptied: clicking the active option clears it, and `onChange`\n * receives `null` (Keksdose's support-panel filters, where \"no filter\" is reached by\n * clicking the filter that is on).\n *\n * The options become TOGGLE BUTTONS (`aria-pressed`, in a `role=\"group\"`) rather than\n * radios. A radio cannot be unchecked by activating it — no screen reader user expects\n * a second press on \"Open, radio, checked\" to leave nothing checked, and nothing would\n * tell them it had. \"Open, toggle button, pressed\" says exactly what a press will do.\n */\nexport interface ToggleGroupClearableProps<T extends string> extends ToggleGroupBaseProps<T> {\n allowEmpty: true;\n value: T | null;\n onChange: (value: T | null) => void;\n}\n\n/** `allowEmpty` picks the shape, so `onChange` is typed `(T) => void` unless the group\n * can actually emit `null` — no existing caller has a `null` to handle. */\nexport type ToggleGroupProps<T extends string> =\n | ToggleGroupRequiredProps<T>\n | ToggleGroupClearableProps<T>;\n\n/**\n * Overloaded rather than typed by the union alone (keksdose, \"Gaps found adopting\n * 0.6.0\" #7). Inferring `T` through a union of prop shapes let TypeScript settle on\n * `string`, so `<ToggleGroup allowEmpty value={filter} onChange={setFilter} />` over a\n * `useState<Status | null>` did not compile unless the caller spelled\n * `<ToggleGroup<Status>>`. One signature per mode lets each infer `T` from its own\n * `value`/`onChange`/`options`; the third keeps a caller that forwards a\n * {@link ToggleGroupProps} union (a wrapper component) compiling.\n */\nexport function ToggleGroup<T extends string>(props: ToggleGroupClearableProps<T>): ReactElement;\nexport function ToggleGroup<T extends string>(props: ToggleGroupRequiredProps<T>): ReactElement;\nexport function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): ReactElement;\nexport function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): ReactElement {\n const {\n value,\n options,\n className,\n optionClassName,\n ariaLabel,\n disabled = false,\n size = \"md\",\n label,\n hint,\n error,\n \"aria-label\": ariaLabelAttr,\n ...restWithMode\n } = props;\n const labelId = useId();\n const errorId = useId();\n const field = label !== undefined && label !== null && label !== false && label !== \"\";\n const hasError = field && error !== undefined && error !== null && error !== false && error !== \"\";\n // Taken off the rest so neither reaches the DOM; `props` keeps them paired, which is\n // what lets the `onChange` below be called with `null` only in the mode that allows it.\n const { allowEmpty: _allowEmpty, onChange: _onChange, ...rest } = restWithMode;\n const choose = (next: T) => {\n if (props.allowEmpty) props.onChange(next === value ? null : next);\n else props.onChange(next);\n };\n const clearable = props.allowEmpty === true;\n // A radio group is ONE tab stop (the checked radio, else the first) and arrows move\n // the choice — the pattern `role=\"radiogroup\"` promises a screen-reader user. Before\n // 0.7.0 each segment was its own tab stop with no arrow keys. The clearable mode is a\n // row of toggle buttons, where separate tab stops are the pattern.\n const tabStop = options.some((o) => o.value === value) ? value : options[0]?.value;\n const onRadioKey = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {\n const last = options.length - 1;\n let next: number | null = null;\n const step = horizontalStep(e.key, e.currentTarget);\n if (step !== 0) next = index + step;\n else if (e.key === \"ArrowDown\") next = index + 1;\n else if (e.key === \"ArrowUp\") next = index - 1;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = last;\n if (next === null) return;\n e.preventDefault();\n next = next < 0 ? last : next > last ? 0 : next;\n const buttons = e.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>(\":scope > button\");\n buttons?.[next]?.focus();\n choose(options[next].value);\n };\n const group = (\n <div\n // The audit's named example of a closed prop list (§\"Public API design\"): the\n // tour locates a step by CSS SELECTOR, so a component that drops every attribute\n // it was not expecting cannot be spotlighted at all — and Keksdose's rule editor\n // carries a comment explaining that it wraps this group in a bare <div> for\n // exactly that reason.\n //\n // `...rest` first, then the attributes the group cannot do without: a caller\n // hanging an anchor or a test id on the group must not be able to overwrite the\n // radiogroup role or the disabled state by accident. `className` is destructured\n // out entirely and merged through `cn`, so it is never in here.\n {...rest}\n role={clearable ? \"group\" : \"radiogroup\"}\n // The DOM spelling wins; `ariaLabel` is the fallback for the call sites that\n // have not moved yet.\n aria-label={ariaLabelAttr ?? ariaLabel}\n aria-labelledby={field && ariaLabelAttr === undefined && ariaLabel === undefined ? labelId : rest[\"aria-labelledby\"]}\n aria-invalid={hasError || rest[\"aria-invalid\"] || undefined}\n aria-describedby={\n hasError ? (rest[\"aria-describedby\"] ? `${rest[\"aria-describedby\"]} ${errorId}` : errorId) : rest[\"aria-describedby\"]\n }\n // `aria-disabled` on the group as well as `disabled` on each button: a radio\n // group is what the user is being refused, and a screen reader announcing\n // three separately-disabled radios does not say that.\n aria-disabled={disabled || undefined}\n className={cn(\n // `gap-0.5` — the same 2px as the container's own padding, so EVERY segment\n // sits in a uniform 2px moat and no two fills ever touch. Flush segments were\n // Keksdose live #268's rework: the pressed segment wears a saturated fill and\n // an unpressed neighbour wears a pale hover fill, and with a shared edge the\n // two rectangles read as one smeared shape — *\"the boundary of the selected\n // option and hovering next to it overlays the boundary of the selected\n // button\"*. A gap is what makes each segment its own chip; it cannot be\n // undone by a caller's per-option colour, which a hover-only fix could.\n //\n // (The hover fill is the DESKTOP half of that report: Tailwind v4 wraps every\n // `hover:` in `@media (hover: hover)`, so a phone never paints it. The half a\n // phone does see is the focus ring — see the segment's own note below.)\n \"inline-flex w-full gap-0.5 rounded-md border border-[var(--border-strong)] bg-[var(--bg-surface)] p-0.5 shadow-sm\",\n // The whole group fades, the way every other disabled control in this\n // package does; `cursor-not-allowed` is on the buttons, which is what a\n // pointer is actually over.\n disabled && \"opacity-60\",\n // Inside the field's chrome the group is only a row of segments: no border, no\n // surface, no padding of its own, and the field (not the group) is what dims.\n field && \"border-0 bg-transparent p-0 shadow-none opacity-100\",\n !field && className,\n )}\n >\n {options.map((opt, index) => {\n const active = opt.value === value;\n return (\n <button\n key={opt.value}\n type=\"button\"\n role={clearable ? undefined : \"radio\"}\n aria-checked={clearable ? undefined : active}\n aria-pressed={clearable ? active : undefined}\n disabled={disabled}\n tabIndex={clearable ? undefined : opt.value === tabStop ? 0 : -1}\n onKeyDown={clearable ? undefined : (e) => onRadioKey(e, index)}\n onClick={() => choose(opt.value)}\n className={cn(\n // `truncate` (which carries whitespace-nowrap) rather than letting a\n // label wrap: a segmented control sizes its whole row to the tallest\n // option, so one two-word option — Keksdose feedback #147's \"Where I\n // am\" — silently doubles the height of every segment beside it.\n //\n // `basis-auto` is what keeps that ellipsis a LAST resort rather than\n // the normal state (Keksdose dev#475). With flex-1's `basis-0`, a\n // shrink-to-fit group (`w-auto`) still resolves to the sum of the\n // labels' widths — and then splits it EQUALLY, so the short option\n // got 66px it did not need and \"Where I am\" got 66 of the 81 it did:\n // truncated at 1778px of free screen. Basing each segment on its own\n // content and sharing only the LEFTOVER space keeps a full-width\n // group's segments near-equal and an auto-width group's exact.\n //\n // `focus-visible` + `ring-inset`, not `focus` + an outset ring. A ring\n // is a box-shadow that spreads OUTWARD, so on a flush group it painted\n // 2px of ring over both neighbours and over the container's own border\n // — and on a phone it appeared on every TAP, because a tap focuses the\n // button. That is the other half of what live #268's rework saw\n // overlaying the selected segment's boundary. Inset keeps the ring\n // inside the segment it belongs to; focus-visible keeps it for the\n // keyboard, which is the only input that needs it.\n \"min-w-0 flex-1 basis-auto truncate rounded px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--border-strong)]\",\n size === \"sm\" && \"px-2 py-1 text-xs\",\n // In a field: no vertical padding and a 20px line — a `text-sm` line, the\n // same line a labelled Select holds under its label strip — so the field's\n // own `pt-4 pb-1` decides the height, as it does for the select.\n field && \"py-0 leading-5\",\n active\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n // No hover fill on a group that cannot be changed — a segment that\n // lights up under the pointer is an offer, and there is none here.\n disabled && \"cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent\",\n optionClassName,\n opt.className,\n )}\n >\n {opt.label}\n </button>\n );\n })}\n </div>\n );\n if (!field) return group;\n return (\n // `h-full` + `flex-1`: the chrome fills a grid or stretched flex row, which is what\n // levels it with a select beside it whatever the browser makes of the select.\n <div className={cn(\"flex h-full flex-col\", className)}>\n <FloatingField\n className=\"flex flex-1 flex-col\"\n label={<span id={labelId}>{label}</span>}\n staticLabel\n hint={hint}\n >\n <div\n className={cn(\n \"flex flex-1 flex-col justify-center rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-1 pt-4 pb-1 shadow-sm\",\n disabled && \"bg-[var(--bg-surface-2)] opacity-60\",\n hasError && FIELD_INVALID,\n )}\n >\n {group}\n </div>\n </FloatingField>\n {hasError && (\n <p id={errorId} className=\"mt-1 text-[11px] leading-tight text-[var(--danger)]\">\n {error}\n </p>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/toggle-group.tsx"],"sourcesContent":["import { useId } from \"react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactElement, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { horizontalStep } from \"../lib/direction\";\nimport { FIELD_INVALID, FloatingField, Label } from \"./ui\";\n\nexport interface ToggleOption<T extends string> {\n value: T;\n label: string;\n className?: string;\n}\n\n/**\n * `onChange` is the group's own — the chosen VALUE, not a DOM event — so the div's\n * `onChange` is omitted rather than shadowed: leaving both in scope would give the\n * prop two incompatible meanings depending on which overload TypeScript picked.\n *\n * `children` is omitted too: this renders its `options` and nothing else, so a `children` the type\n * accepted and the component ignored would be a prop that silently does nothing —\n * worse than one that does not compile.\n */\nexport interface ToggleGroupBaseProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\"> {\n options: ToggleOption<T>[];\n className?: string;\n /** Applied to every option button (e.g. to tune height/rounding to match\n * adjacent fields). Per-option `className` still wins over this. */\n optionClassName?: string;\n /**\n * @deprecated Pass `aria-label` instead — the DOM spelling, which every other\n * control in this kit now answers to. Kept working because three applications ship\n * this one today; it names the group only when `aria-label` is absent.\n */\n ariaLabel?: string;\n /**\n * Show, refuse the change (Keksdose live #288: a payment dated in the future has no\n * state to set).\n *\n * Whatever `value` says stays pressed and keeps its own fill rather than going grey\n * with the rest — a reader who cannot see WHICH option is chosen has been told less\n * than before it was disabled. A caller with nothing to show passes no value, and\n * the group renders dimmed with nothing pressed, which is the shape Keksdose's\n * status picker uses for a row whose status does not exist yet.\n *\n * On the whole GROUP, not per option: a segmented control where some segments are\n * live and others are not is a menu with holes in it, and no caller here wants one.\n */\n disabled?: boolean;\n /**\n * `sm`: 12px options with `px-2 py-1` — the compact group keksdose's rule editor\n * (rule-editor:204) writes as `optionClassName=\"px-2 py-1 text-xs\"` beside a small\n * caption. `md` (default) is the size every other group has.\n */\n size?: \"sm\" | \"md\";\n /**\n * Stand in a form row as a FIELD: with a label the group wears the field's chrome —\n * border, surface, the top strip with a small static label in it, a labelled\n * {@link Select}'s height — so beside an Input or a Select it reads as one of them\n * rather than as a control with a caption over it. lenkbank builds exactly this by\n * hand as `ToggleField` (features/gear/common.tsx:97, feedback #69), and its notes\n * are why the chrome STRETCHES to its row as well as matching the select's padding:\n * a native select's height is the browser's, so a toggle a few pixels short of it\n * is levelled up by the row rather than by arithmetic.\n *\n * The label names the group (`aria-labelledby`), so `aria-label` is not needed. With\n * a label, `className` styles the field's wrapper — as on {@link Select} — and the\n * group's own box is dropped: two nested borders read as a control in a control.\n */\n label?: ReactNode;\n /**\n * Where `label` goes. `\"field\"` (default): the field chrome described under `label`.\n * `\"above\"`: the kit's {@link Label} over the bare group — the shape of a {@link Field}\n * — with `hint` beside the label and `error` under the group, for a form that sets\n * its labels above its fields (kastlan's international-rent-calculator.tsx, whose DE\n * cap pair sits in a `Field` column between two labelled-above inputs, where the\n * chrome's inner label would be the only one of its kind).\n *\n * Inside a `Field`, pass no `label` at all and spread the render-prop instead —\n * `{(ids, { labelId }) => <ToggleGroup {...ids} aria-labelledby={labelId} … />}` —\n * so the Field's label names the group and its hint and error describe it.\n */\n labelPlacement?: \"field\" | \"above\";\n /** A {@link FieldHint} on the label line, as on a labelled {@link Select}. Only with\n * `label`. */\n hint?: ReactNode;\n /** The message under the field when it is wrong: paints the field's border with\n * `--danger`, marks the group `aria-invalid` and describes it with the message, as\n * {@link Select}'s `error` does. Only with `label`. */\n error?: ReactNode;\n}\n\n/**\n * A line under the group saying what the CHOSEN option means — lenkbank's ToggleField\n * `hint` (features/gear/common.tsx:97): not help behind a \"?\" but a caption, and one\n * that changes as the choice does. Pass a function of the value for that; it is\n * attached with `aria-describedby`, and a function caption is also a polite live\n * region, because a description is read when the group is entered and not again when\n * an arrow key changes the choice underneath it.\n *\n * In muted 11px text under the field (or the bare group), above an `error`.\n */\ntype ToggleGroupCaption<V> = ReactNode | ((value: V) => ReactNode);\n\n/** The group as it has always been: one option is always the answer. */\nexport interface ToggleGroupRequiredProps<T extends string> extends ToggleGroupBaseProps<T> {\n allowEmpty?: false;\n value: T;\n onChange: (value: T) => void;\n /** See {@link ToggleGroupCaption}. */\n caption?: ToggleGroupCaption<T>;\n}\n\n/**\n * A group that can be emptied: clicking the active option clears it, and `onChange`\n * receives `null` (Keksdose's support-panel filters, where \"no filter\" is reached by\n * clicking the filter that is on).\n *\n * The options become TOGGLE BUTTONS (`aria-pressed`, in a `role=\"group\"`) rather than\n * radios. A radio cannot be unchecked by activating it — no screen reader user expects\n * a second press on \"Open, radio, checked\" to leave nothing checked, and nothing would\n * tell them it had. \"Open, toggle button, pressed\" says exactly what a press will do.\n */\nexport interface ToggleGroupClearableProps<T extends string> extends ToggleGroupBaseProps<T> {\n allowEmpty: true;\n value: T | null;\n onChange: (value: T | null) => void;\n /** See {@link ToggleGroupCaption}. `null` while nothing is chosen. */\n caption?: ToggleGroupCaption<T | null>;\n}\n\n/** `allowEmpty` picks the shape, so `onChange` is typed `(T) => void` unless the group\n * can actually emit `null` — no existing caller has a `null` to handle. */\nexport type ToggleGroupProps<T extends string> =\n | ToggleGroupRequiredProps<T>\n | ToggleGroupClearableProps<T>;\n\n/**\n * Overloaded rather than typed by the union alone (keksdose, \"Gaps found adopting\n * 0.6.0\" #7). Inferring `T` through a union of prop shapes let TypeScript settle on\n * `string`, so `<ToggleGroup allowEmpty value={filter} onChange={setFilter} />` over a\n * `useState<Status | null>` did not compile unless the caller spelled\n * `<ToggleGroup<Status>>`. One signature per mode lets each infer `T` from its own\n * `value`/`onChange`/`options`; the third keeps a caller that forwards a\n * {@link ToggleGroupProps} union (a wrapper component) compiling.\n */\nexport function ToggleGroup<T extends string>(props: ToggleGroupClearableProps<T>): ReactElement;\nexport function ToggleGroup<T extends string>(props: ToggleGroupRequiredProps<T>): ReactElement;\nexport function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): ReactElement;\nexport function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): ReactElement {\n const {\n value,\n options,\n className,\n optionClassName,\n ariaLabel,\n disabled = false,\n size = \"md\",\n label,\n labelPlacement = \"field\",\n hint,\n error,\n \"aria-label\": ariaLabelAttr,\n ...restWithMode\n } = props;\n const labelId = useId();\n const errorId = useId();\n const labelled = label !== undefined && label !== null && label !== false && label !== \"\";\n // `field` is the chrome; a label placed above keeps the bare group's own box.\n const field = labelled && labelPlacement === \"field\";\n const above = labelled && labelPlacement === \"above\";\n const hasError = labelled && error !== undefined && error !== null && error !== false && error !== \"\";\n // Taken off the rest so neither reaches the DOM; `props` keeps them paired, which is\n // what lets the `onChange` below be called with `null` only in the mode that allows it.\n const { allowEmpty: _allowEmpty, onChange: _onChange, caption, ...rest } = restWithMode;\n // Invalid from outside too: a `Field` hands the bare group `aria-invalid`, and the\n // border has to say what the attribute says.\n const outsideInvalid = rest[\"aria-invalid\"] === true || rest[\"aria-invalid\"] === \"true\";\n const captionId = useId();\n const captionIsLive = typeof caption === \"function\";\n const captionNode = captionIsLive ? (caption as (v: T | null) => ReactNode)(value) : caption;\n const hasCaption = captionNode !== undefined && captionNode !== null && captionNode !== false && captionNode !== \"\";\n const choose = (next: T) => {\n if (props.allowEmpty) props.onChange(next === value ? null : next);\n else props.onChange(next);\n };\n const clearable = props.allowEmpty === true;\n // A radio group is ONE tab stop (the checked radio, else the first) and arrows move\n // the choice — the pattern `role=\"radiogroup\"` promises a screen-reader user. Before\n // 0.7.0 each segment was its own tab stop with no arrow keys. The clearable mode is a\n // row of toggle buttons, where separate tab stops are the pattern.\n const tabStop = options.some((o) => o.value === value) ? value : options[0]?.value;\n const onRadioKey = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {\n const last = options.length - 1;\n let next: number | null = null;\n const step = horizontalStep(e.key, e.currentTarget);\n if (step !== 0) next = index + step;\n else if (e.key === \"ArrowDown\") next = index + 1;\n else if (e.key === \"ArrowUp\") next = index - 1;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = last;\n if (next === null) return;\n e.preventDefault();\n next = next < 0 ? last : next > last ? 0 : next;\n const buttons = e.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>(\":scope > button\");\n buttons?.[next]?.focus();\n choose(options[next].value);\n };\n const group = (\n <div\n // The audit's named example of a closed prop list (§\"Public API design\"): the\n // tour locates a step by CSS SELECTOR, so a component that drops every attribute\n // it was not expecting cannot be spotlighted at all — and Keksdose's rule editor\n // carries a comment explaining that it wraps this group in a bare <div> for\n // exactly that reason.\n //\n // `...rest` first, then the attributes the group cannot do without: a caller\n // hanging an anchor or a test id on the group must not be able to overwrite the\n // radiogroup role or the disabled state by accident. `className` is destructured\n // out entirely and merged through `cn`, so it is never in here.\n {...rest}\n role={clearable ? \"group\" : \"radiogroup\"}\n // The DOM spelling wins; `ariaLabel` is the fallback for the call sites that\n // have not moved yet.\n aria-label={ariaLabelAttr ?? ariaLabel}\n aria-labelledby={labelled && ariaLabelAttr === undefined && ariaLabel === undefined ? labelId : rest[\"aria-labelledby\"]}\n aria-invalid={hasError || rest[\"aria-invalid\"] || undefined}\n aria-describedby={\n [rest[\"aria-describedby\"], hasCaption && captionId, hasError && errorId].filter(Boolean).join(\" \") || undefined\n }\n // `aria-disabled` on the group as well as `disabled` on each button: a radio\n // group is what the user is being refused, and a screen reader announcing\n // three separately-disabled radios does not say that.\n aria-disabled={disabled || undefined}\n className={cn(\n // `gap-0.5` — the same 2px as the container's own padding, so EVERY segment\n // sits in a uniform 2px moat and no two fills ever touch. Flush segments were\n // Keksdose live #268's rework: the pressed segment wears a saturated fill and\n // an unpressed neighbour wears a pale hover fill, and with a shared edge the\n // two rectangles read as one smeared shape — *\"the boundary of the selected\n // option and hovering next to it overlays the boundary of the selected\n // button\"*. A gap is what makes each segment its own chip; it cannot be\n // undone by a caller's per-option colour, which a hover-only fix could.\n //\n // (The hover fill is the DESKTOP half of that report: Tailwind v4 wraps every\n // `hover:` in `@media (hover: hover)`, so a phone never paints it. The half a\n // phone does see is the focus ring — see the segment's own note below.)\n \"inline-flex w-full gap-0.5 rounded-md border border-[var(--border-strong)] bg-[var(--bg-surface)] p-0.5 shadow-sm\",\n // The whole group fades, the way every other disabled control in this\n // package does; `cursor-not-allowed` is on the buttons, which is what a\n // pointer is actually over.\n disabled && \"opacity-60\",\n // The bare group (no chrome to paint) wears the invalid border itself.\n !field && (outsideInvalid || (above && hasError)) && FIELD_INVALID,\n // Inside the field's chrome the group is only a row of segments: no border, no\n // surface, no padding of its own, and the field (not the group) is what dims.\n field && \"border-0 bg-transparent p-0 shadow-none opacity-100\",\n !labelled && className,\n )}\n >\n {options.map((opt, index) => {\n const active = opt.value === value;\n return (\n <button\n key={opt.value}\n type=\"button\"\n role={clearable ? undefined : \"radio\"}\n aria-checked={clearable ? undefined : active}\n aria-pressed={clearable ? active : undefined}\n disabled={disabled}\n tabIndex={clearable ? undefined : opt.value === tabStop ? 0 : -1}\n onKeyDown={clearable ? undefined : (e) => onRadioKey(e, index)}\n onClick={() => choose(opt.value)}\n className={cn(\n // `truncate` (which carries whitespace-nowrap) rather than letting a\n // label wrap: a segmented control sizes its whole row to the tallest\n // option, so one two-word option — Keksdose feedback #147's \"Where I\n // am\" — silently doubles the height of every segment beside it.\n //\n // `basis-auto` is what keeps that ellipsis a LAST resort rather than\n // the normal state (Keksdose dev#475). With flex-1's `basis-0`, a\n // shrink-to-fit group (`w-auto`) still resolves to the sum of the\n // labels' widths — and then splits it EQUALLY, so the short option\n // got 66px it did not need and \"Where I am\" got 66 of the 81 it did:\n // truncated at 1778px of free screen. Basing each segment on its own\n // content and sharing only the LEFTOVER space keeps a full-width\n // group's segments near-equal and an auto-width group's exact.\n //\n // `focus-visible` + `ring-inset`, not `focus` + an outset ring. A ring\n // is a box-shadow that spreads OUTWARD, so on a flush group it painted\n // 2px of ring over both neighbours and over the container's own border\n // — and on a phone it appeared on every TAP, because a tap focuses the\n // button. That is the other half of what live #268's rework saw\n // overlaying the selected segment's boundary. Inset keeps the ring\n // inside the segment it belongs to; focus-visible keeps it for the\n // keyboard, which is the only input that needs it.\n \"min-w-0 flex-1 basis-auto truncate rounded px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--border-strong)]\",\n size === \"sm\" && \"px-2 py-1 text-xs\",\n // In a field: no vertical padding and a 20px line — a `text-sm` line, the\n // same line a labelled Select holds under its label strip — so the field's\n // own `pt-4 pb-1` decides the height, as it does for the select.\n field && \"py-0 leading-5\",\n active\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n // No hover fill on a group that cannot be changed — a segment that\n // lights up under the pointer is an offer, and there is none here.\n disabled && \"cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent\",\n optionClassName,\n opt.className,\n )}\n >\n {opt.label}\n </button>\n );\n })}\n </div>\n );\n // Rendered whenever the caption is — `aria-live` must be on the element BEFORE its\n // text changes, or the change is not announced — but empty (no height, no margin)\n // when there is nothing to say, so a function caption that returns null for some\n // options leaves no gap. Empty rather than `hidden`: some readers do not announce\n // text that appears inside an element coming back from `display: none`.\n const captionEl =\n hasCaption || captionIsLive ? (\n <p\n id={captionId}\n aria-live={captionIsLive ? \"polite\" : undefined}\n className={cn(\"text-[11px] leading-snug text-[var(--text-muted)]\", hasCaption && \"mt-1\")}\n >\n {hasCaption ? captionNode : null}\n </p>\n ) : null;\n const errorEl = hasError ? (\n <p id={errorId} className=\"mt-1 text-[11px] leading-tight text-[var(--danger)]\">\n {error}\n </p>\n ) : null;\n if (above) {\n // `relative` so a caller's `sr-only` label cannot escape (sr-only-containment).\n return (\n <div className={cn(\"relative grid min-w-0 gap-1.5\", className)}>\n <div className=\"flex items-center gap-1\">\n {/* A `<label>` with no `htmlFor`: a group is not labelable, so it is named\n by `aria-labelledby` on the group; the element keeps the Field look. */}\n <Label\n id={labelId}\n disabled={disabled}\n data-error={hasError || undefined}\n className=\"data-[error=true]:text-[var(--danger)]\"\n >\n {label}\n </Label>\n {hint}\n </div>\n <div className=\"min-w-0\">\n {group}\n {captionEl}\n {errorEl}\n </div>\n </div>\n );\n }\n if (!field) {\n if (!captionEl) return group;\n // The group keeps its `className`, as without a caption; the wrapper only stacks.\n return (\n <div className=\"min-w-0\">\n {group}\n {captionEl}\n </div>\n );\n }\n return (\n // `h-full` + `flex-1`: the chrome fills a grid or stretched flex row, which is what\n // levels it with a select beside it whatever the browser makes of the select.\n <div className={cn(\"flex h-full flex-col\", className)}>\n <FloatingField\n className=\"flex flex-1 flex-col\"\n label={<span id={labelId}>{label}</span>}\n staticLabel\n hint={hint}\n >\n <div\n className={cn(\n \"flex flex-1 flex-col justify-center rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-1 pt-4 pb-1 shadow-sm\",\n disabled && \"bg-[var(--bg-surface-2)] opacity-60\",\n hasError && FIELD_INVALID,\n )}\n >\n {group}\n </div>\n </FloatingField>\n {captionEl}\n {errorEl}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -44,14 +44,21 @@ interface LegendEntry {
44
44
  /**
45
45
  * What the entry's mark looks like. `swatch` — a filled square, the default — says
46
46
  * WHICH MEASUREMENT. `stroke` says WHICH QUANTITY, for the charts that carry both at
47
- * once and tell them apart by colour and by dash.
47
+ * once and tell them apart by colour and by dash. `dot` is a round mark, for a
48
+ * MARKER on the chart (a point, an event) rather than a series.
48
49
  */
49
- marker?: "swatch" | "stroke";
50
+ marker?: "swatch" | "stroke" | "dot";
50
51
  /** Which of {@link STROKE_PATTERNS}, when the marker is a stroke — the same index
51
52
  * `SeriesChartSeries.dash` takes, so a legend cannot promise a dot-dash the plot
52
53
  * draws dashed. Or the same custom `stroke-dasharray` string the series draws with
53
54
  * (keksdose's `"4 3"`). */
54
55
  dash?: number | string;
56
+ /**
57
+ * A mark of the caller's own, drawn in place of {@link marker} — for a key whose
58
+ * mark is not a colour at all (a fading bar for "open-ended", a hairline for
59
+ * "today"). Decorative: it is wrapped `aria-hidden`, and the label is the name.
60
+ */
61
+ icon?: ReactNode;
55
62
  }
56
63
  interface ToggleLegendProps {
57
64
  entries: LegendEntry[];
@@ -90,6 +97,32 @@ interface ToggleLegendProps {
90
97
  * so Tab reaches every switch and Space/Enter flips it.
91
98
  */
92
99
  declare function ToggleLegend({ entries, hidden, onToggle, orientation, showSingle, className, labels: labelsProp, }: ToggleLegendProps): react.JSX.Element | null;
100
+ interface StaticLegendProps {
101
+ entries: LegendEntry[];
102
+ /** See {@link ToggleLegendProps.orientation}. */
103
+ orientation?: "horizontal" | "vertical";
104
+ /** The list's accessible name. Default: the `seriesChart.legend` label. */
105
+ "aria-label"?: string;
106
+ className?: string;
107
+ /** Per-instance strings over `<UiKitProvider labels={{ seriesChart }}>`. */
108
+ labels?: Partial<SeriesChartLabels>;
109
+ }
110
+ /**
111
+ * A legend that is a KEY, not a control: the same entries and marks as
112
+ * {@link ToggleLegend} — swatch, stroke (with the chart's own dash), dot, or a mark of
113
+ * the caller's own — but nothing to press.
114
+ *
115
+ * A separate component rather than `ToggleLegend` without `onToggle`, because the two
116
+ * are different things to a screen reader: a group of switches announces a pressed
117
+ * state on every entry, a key is a LIST, read as "list, 4 items" and walked with the
118
+ * list keys, and costs no tab stops. The apps drew this by hand under the charts that
119
+ * say what a colour or a dash means without letting you switch it (kastlan's lease
120
+ * timeline, keksdose's cash buffer).
121
+ *
122
+ * Unlike `ToggleLegend` it draws a single entry: a key with one line still says what
123
+ * the one mark on the chart means.
124
+ */
125
+ declare function StaticLegend({ entries, orientation, "aria-label": ariaLabel, className, labels: labelsProp, }: StaticLegendProps): react.JSX.Element | null;
93
126
  /**
94
127
  * The column a vertical legend stands in: beside the charts, on their horizontal
95
128
  * centre line. `h-full` + `justify-center`, so it centres in a grid cell or a flex row
@@ -113,4 +146,4 @@ declare function LegendGroup({ title, children }: {
113
146
  children: ReactNode;
114
147
  }): react.JSX.Element;
115
148
 
116
- export { LegendColumn, type LegendEntry, LegendGroup, STEP_DASH, STROKE_PATTERNS, ToggleLegend, type ToggleLegendProps, strokeDash, toggleHidden };
149
+ export { LegendColumn, type LegendEntry, LegendGroup, STEP_DASH, STROKE_PATTERNS, StaticLegend, type StaticLegendProps, ToggleLegend, type ToggleLegendProps, strokeDash, toggleHidden };
@@ -65,6 +65,38 @@ function ToggleLegend({
65
65
  }
66
66
  );
67
67
  }
68
+ function StaticLegend({
69
+ entries,
70
+ orientation = "horizontal",
71
+ "aria-label": ariaLabel,
72
+ className,
73
+ labels: labelsProp
74
+ }) {
75
+ const labels = useKitLabels("seriesChart", DEFAULT_SERIES_CHART_LABELS, labelsProp);
76
+ if (entries.length === 0) return null;
77
+ return /* @__PURE__ */ jsx(
78
+ "ul",
79
+ {
80
+ "aria-label": ariaLabel ?? labels.legend,
81
+ className: cn(
82
+ "m-0 flex list-none gap-x-3 gap-y-1 p-0",
83
+ orientation === "vertical" ? "flex-col items-start justify-center" : "mt-2 flex-wrap items-center",
84
+ className
85
+ ),
86
+ children: entries.map((entry) => /* @__PURE__ */ jsxs(
87
+ "li",
88
+ {
89
+ className: "flex items-center gap-1.5 text-[11px] text-[var(--text-secondary)]",
90
+ children: [
91
+ /* @__PURE__ */ jsx(LegendMark, { entry, off: false }),
92
+ entry.label
93
+ ]
94
+ },
95
+ entry.key
96
+ ))
97
+ }
98
+ );
99
+ }
68
100
  function LegendColumn({ children, className }) {
69
101
  return /* @__PURE__ */ jsx("div", { className: cn("flex h-full flex-col justify-center gap-3", className), children });
70
102
  }
@@ -75,6 +107,22 @@ function LegendGroup({ title, children }) {
75
107
  ] });
76
108
  }
77
109
  function LegendMark({ entry, off }) {
110
+ if (entry.icon != null) {
111
+ return /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "inline-flex shrink-0 items-center", children: entry.icon });
112
+ }
113
+ if (entry.marker === "dot") {
114
+ return /* @__PURE__ */ jsx(
115
+ "span",
116
+ {
117
+ "aria-hidden": true,
118
+ className: "size-2 shrink-0 rounded-full",
119
+ style: {
120
+ backgroundColor: off ? "transparent" : entry.color,
121
+ boxShadow: `inset 0 0 0 1.5px ${entry.color}`
122
+ }
123
+ }
124
+ );
125
+ }
78
126
  if (entry.marker === "stroke") {
79
127
  return /* @__PURE__ */ jsx("svg", { "aria-hidden": true, viewBox: "0 0 20 2", className: "h-0.5 w-5 shrink-0 overflow-visible", children: /* @__PURE__ */ jsx(
80
128
  "line",
@@ -106,6 +154,7 @@ export {
106
154
  LegendGroup,
107
155
  STEP_DASH,
108
156
  STROKE_PATTERNS,
157
+ StaticLegend,
109
158
  ToggleLegend,
110
159
  strokeDash,
111
160
  toggleHidden
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/toggle-legend.tsx"],"sourcesContent":["// A legend whose entries are SWITCHES — and the stroke table it shares with\n// `SeriesChart`. Lifted out of lenkbank's measurement plots, where ten legends over\n// thirteen charts took channels on and off the picture.\n//\n// No recharts in this module: a legend of switches is plain buttons, and it is as\n// useful under a chart the consumer drew by hand as under `SeriesChart`.\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { DEFAULT_SERIES_CHART_LABELS, type SeriesChartLabels } from \"./series-chart-labels\";\n\n/**\n * The strokes a reader can tell apart, in the order they are handed out.\n *\n * For the charts where the colour is already spoken for: a comparison draws each\n * measurement in its own colour, so a chart carrying several CHANNELS of each has\n * nothing left but the stroke — and \"solid versus dashed\" only gets you two.\n *\n * **Five, and the fifth is the last one.** They are the draughtsman's line types, in\n * the draughtsman's order — continuous, dashed, dotted, dash-dot, dash-dot-dot — the\n * one family of five a century of drawings has already proved legible at a hair's\n * width. A sixth is not distinguishable from one of these at two pixels wide, so a\n * chart needing one is a chart that should be split.\n *\n * Kept here, beside the legend's own mark, and read by `SeriesChart` from here — so\n * the key under a chart and the lines on it cannot disagree.\n */\nexport const STROKE_PATTERNS: readonly (string | undefined)[] = [\n undefined,\n \"5 4\",\n \"1 3\",\n \"9 3 2 3\",\n \"9 3 2 3 2 3\",\n];\n\n/** The dash array for the nth pattern, wrapping past the fifth. */\nexport function strokeDash(order: number): string | undefined {\n const n = STROKE_PATTERNS.length;\n return STROKE_PATTERNS[((Math.trunc(order) % n) + n) % n];\n}\n\n/** Which of the patterns a `step` series is drawn in.\n *\n * A whole-number channel that jumps rather than travels is drawn dashed, and it has\n * to be one of {@link STROKE_PATTERNS} rather than a dash array written out at the\n * call site: a legend entry says its pattern by index, so a step line drawn in\n * anything else would be a legend promising a solid line for a dashed one. */\nexport const STEP_DASH = 1;\n\n/**\n * One key switched, as a new hidden set.\n *\n * Every caller writes `onToggle={(key) => setHidden(toggleHidden(hidden, key))}`. A\n * new set rather than a mutated one, because the state is read during render and a\n * mutation of something a component already holds is invisible to React.\n */\nexport function toggleHidden(hidden: ReadonlySet<string>, key: string): ReadonlySet<string> {\n const next = new Set(hidden);\n if (!next.delete(key)) next.add(key);\n return next;\n}\n\nexport interface LegendEntry {\n key: string;\n label: ReactNode;\n /** Any CSS colour — `paletteFor(i)` for the kit's own ramp. */\n color: string;\n /**\n * What the entry's mark looks like. `swatch` — a filled square, the default — says\n * WHICH MEASUREMENT. `stroke` says WHICH QUANTITY, for the charts that carry both at\n * once and tell them apart by colour and by dash.\n */\n marker?: \"swatch\" | \"stroke\";\n /** Which of {@link STROKE_PATTERNS}, when the marker is a stroke — the same index\n * `SeriesChartSeries.dash` takes, so a legend cannot promise a dot-dash the plot\n * draws dashed. Or the same custom `stroke-dasharray` string the series draws with\n * (keksdose's `\"4 3\"`). */\n dash?: number | string;\n}\n\nexport interface ToggleLegendProps {\n entries: LegendEntry[];\n /** The keys currently off the chart. */\n hidden: ReadonlySet<string>;\n onToggle: (key: string) => void;\n /** `vertical` is for a legend standing BESIDE the charts rather than under them —\n * what a row of charts sharing one legend wants, since under two charts there is\n * no \"under\", and putting it under one says it belongs to that one. */\n orientation?: \"horizontal\" | \"vertical\";\n /**\n * Draw a legend of ONE entry rather than nothing.\n *\n * A single line's legend is usually noise. It is not where the legend is the only\n * SWITCH that line has — a series that starts hidden and has no entry to bring it\n * back is gone for good.\n */\n showSingle?: boolean;\n className?: string;\n /** Per-instance strings over `<UiKitProvider labels={{ seriesChart }}>`. */\n labels?: Partial<SeriesChartLabels>;\n}\n\n/**\n * A legend whose entries are switches.\n *\n * `ChartLegendContent` with `onItemClick`/`activeKey` is the other kind: clicking an\n * entry HIGHLIGHTS it and dims the rest, which is right when every series belongs on\n * the chart and one of them is momentarily interesting. This is for the opposite —\n * three coordinates on one axis where y runs to 800 and z barely moves, and the only\n * way to see z is to take y off the chart entirely. Any number may be off at once.\n *\n * A hidden entry keeps its mark and its place. Removing it would reflow the legend on\n * every click and lose the one thing it is for: saying what COULD be shown.\n *\n * Each entry is a real `<button>` with `aria-pressed` — pressed means \"on the chart\" —\n * so Tab reaches every switch and Space/Enter flips it.\n */\nexport function ToggleLegend({\n entries,\n hidden,\n onToggle,\n orientation = \"horizontal\",\n showSingle = false,\n className,\n labels: labelsProp,\n}: ToggleLegendProps) {\n const labels = useKitLabels(\"seriesChart\", DEFAULT_SERIES_CHART_LABELS, labelsProp);\n if (entries.length < (showSingle ? 1 : 2)) return null;\n return (\n <div\n role=\"group\"\n aria-label={labels.legend}\n className={cn(\n \"flex gap-x-3 gap-y-1\",\n orientation === \"vertical\"\n ? \"flex-col items-start justify-center\"\n : \"mt-2 flex-wrap items-center\",\n className,\n )}\n >\n {entries.map((entry) => {\n const off = hidden.has(entry.key);\n return (\n <button\n key={entry.key}\n type=\"button\"\n // Pressed rather than a checkbox: it is a control over what the chart\n // draws, and \"not pressed\" is the same sentence the dimmed mark says.\n aria-pressed={!off}\n onClick={() => onToggle(entry.key)}\n className={cn(\n \"flex items-center gap-1.5 rounded text-start text-[11px] text-[var(--text-secondary)] transition-opacity hover:opacity-80\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]\",\n off && \"opacity-35\",\n )}\n >\n <LegendMark entry={entry} off={off} />\n {entry.label}\n </button>\n );\n })}\n </div>\n );\n}\n\n/**\n * The column a vertical legend stands in: beside the charts, on their horizontal\n * centre line. `h-full` + `justify-center`, so it centres in a grid cell or a flex row\n * that stretches it — and stacks whatever it is given, which is what a panel showing\n * two legends at once needs. The width is the caller's: how wide a legend column is\n * is a fact about that screen's layout.\n */\nexport function LegendColumn({ children, className }: { children: ReactNode; className?: string }) {\n return (\n <div className={cn(\"flex h-full flex-col justify-center gap-3\", className)}>{children}</div>\n );\n}\n\n/**\n * A legend and what it is a legend OF, as a headed column.\n *\n * For legends that are several vocabularies at once — which measurement and which\n * channel — so the entries are read down a group and the groups across. Wrapped rows\n * would put the second half of one group on a line under the first half of the next.\n */\nexport function LegendGroup({ title, children }: { title: ReactNode; children: ReactNode }) {\n return (\n <div className=\"flex min-w-0 flex-col items-start gap-1\">\n <span className=\"text-[10px] font-semibold uppercase tracking-wide text-[var(--text-muted)]\">\n {title}\n </span>\n <div className=\"flex flex-col items-start gap-y-0.5\">{children}</div>\n </div>\n );\n}\n\n/** The entry's mark: a square for a measurement, a stroke for a quantity. A hidden one\n * keeps its outline — a mark that vanished would leave a line of text with nothing in\n * front of it.\n *\n * A stroke is an SVG line with the chart's own `strokeDasharray`, not a CSS gradient\n * approximating it: a gradient can say \"solid or dashed\" and nothing more.\n *\n * A hidden stroke is NOT faded here: the button around it already is (`opacity-35`),\n * and the line used to take its own 0.35 on top — 0.35 × 0.35, about 12 %, a mark\n * gone rather than dimmed, while the square beside it read at the full 35 %. */\nfunction LegendMark({ entry, off }: { entry: LegendEntry; off: boolean }) {\n if (entry.marker === \"stroke\") {\n return (\n <svg aria-hidden viewBox=\"0 0 20 2\" className=\"h-0.5 w-5 shrink-0 overflow-visible\">\n <line\n x1={0}\n y1={1}\n x2={20}\n y2={1}\n stroke={entry.color}\n strokeWidth={2}\n strokeDasharray={typeof entry.dash === \"string\" ? entry.dash : strokeDash(entry.dash ?? 0)}\n />\n </svg>\n );\n }\n return (\n <span\n aria-hidden\n className=\"h-2.5 w-2.5 shrink-0 rounded-[3px]\"\n style={{\n backgroundColor: off ? \"transparent\" : entry.color,\n boxShadow: `inset 0 0 0 1.5px ${entry.color}`,\n }}\n />\n );\n}\n"],"mappings":";AA+IU,SAaE,KAbF;AAxIV,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAC7B,SAAS,mCAA2D;AAkB7D,MAAM,kBAAmD;AAAA,EAC9D;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,SAAS,WAAW,OAAmC;AAC5D,QAAM,IAAI,gBAAgB;AAC1B,SAAO,iBAAkB,KAAK,MAAM,KAAK,IAAI,IAAK,KAAK,CAAC;AAC1D;AAQO,MAAM,YAAY;AASlB,SAAS,aAAa,QAA6B,KAAkC;AAC1F,QAAM,OAAO,IAAI,IAAI,MAAM;AAC3B,MAAI,CAAC,KAAK,OAAO,GAAG,EAAG,MAAK,IAAI,GAAG;AACnC,SAAO;AACT;AAyDO,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,aAAa;AAAA,EACb;AAAA,EACA,QAAQ;AACV,GAAsB;AACpB,QAAM,SAAS,aAAa,eAAe,6BAA6B,UAAU;AAClF,MAAI,QAAQ,UAAU,aAAa,IAAI,GAAI,QAAO;AAClD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,cAAY,OAAO;AAAA,MACnB,WAAW;AAAA,QACT;AAAA,QACA,gBAAgB,aACZ,wCACA;AAAA,QACJ;AAAA,MACF;AAAA,MAEC,kBAAQ,IAAI,CAAC,UAAU;AACtB,cAAM,MAAM,OAAO,IAAI,MAAM,GAAG;AAChC,eACE;AAAA,UAAC;AAAA;AAAA,YAEC,MAAK;AAAA,YAGL,gBAAc,CAAC;AAAA,YACf,SAAS,MAAM,SAAS,MAAM,GAAG;AAAA,YACjC,WAAW;AAAA,cACT;AAAA,cACA;AAAA,cACA,OAAO;AAAA,YACT;AAAA,YAEA;AAAA,kCAAC,cAAW,OAAc,KAAU;AAAA,cACnC,MAAM;AAAA;AAAA;AAAA,UAbF,MAAM;AAAA,QAcb;AAAA,MAEJ,CAAC;AAAA;AAAA,EACH;AAEJ;AASO,SAAS,aAAa,EAAE,UAAU,UAAU,GAAgD;AACjG,SACE,oBAAC,SAAI,WAAW,GAAG,6CAA6C,SAAS,GAAI,UAAS;AAE1F;AASO,SAAS,YAAY,EAAE,OAAO,SAAS,GAA8C;AAC1F,SACE,qBAAC,SAAI,WAAU,2CACb;AAAA,wBAAC,UAAK,WAAU,8EACb,iBACH;AAAA,IACA,oBAAC,SAAI,WAAU,uCAAuC,UAAS;AAAA,KACjE;AAEJ;AAYA,SAAS,WAAW,EAAE,OAAO,IAAI,GAAyC;AACxE,MAAI,MAAM,WAAW,UAAU;AAC7B,WACE,oBAAC,SAAI,eAAW,MAAC,SAAQ,YAAW,WAAU,uCAC5C;AAAA,MAAC;AAAA;AAAA,QACC,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,QAAQ,MAAM;AAAA,QACd,aAAa;AAAA,QACb,iBAAiB,OAAO,MAAM,SAAS,WAAW,MAAM,OAAO,WAAW,MAAM,QAAQ,CAAC;AAAA;AAAA,IAC3F,GACF;AAAA,EAEJ;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,eAAW;AAAA,MACX,WAAU;AAAA,MACV,OAAO;AAAA,QACL,iBAAiB,MAAM,gBAAgB,MAAM;AAAA,QAC7C,WAAW,qBAAqB,MAAM,KAAK;AAAA,MAC7C;AAAA;AAAA,EACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/toggle-legend.tsx"],"sourcesContent":["// A legend whose entries are SWITCHES — and the stroke table it shares with\n// `SeriesChart`. Lifted out of lenkbank's measurement plots, where ten legends over\n// thirteen charts took channels on and off the picture.\n//\n// No recharts in this module: a legend of switches is plain buttons, and it is as\n// useful under a chart the consumer drew by hand as under `SeriesChart`.\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { DEFAULT_SERIES_CHART_LABELS, type SeriesChartLabels } from \"./series-chart-labels\";\n\n/**\n * The strokes a reader can tell apart, in the order they are handed out.\n *\n * For the charts where the colour is already spoken for: a comparison draws each\n * measurement in its own colour, so a chart carrying several CHANNELS of each has\n * nothing left but the stroke — and \"solid versus dashed\" only gets you two.\n *\n * **Five, and the fifth is the last one.** They are the draughtsman's line types, in\n * the draughtsman's order — continuous, dashed, dotted, dash-dot, dash-dot-dot — the\n * one family of five a century of drawings has already proved legible at a hair's\n * width. A sixth is not distinguishable from one of these at two pixels wide, so a\n * chart needing one is a chart that should be split.\n *\n * Kept here, beside the legend's own mark, and read by `SeriesChart` from here — so\n * the key under a chart and the lines on it cannot disagree.\n */\nexport const STROKE_PATTERNS: readonly (string | undefined)[] = [\n undefined,\n \"5 4\",\n \"1 3\",\n \"9 3 2 3\",\n \"9 3 2 3 2 3\",\n];\n\n/** The dash array for the nth pattern, wrapping past the fifth. */\nexport function strokeDash(order: number): string | undefined {\n const n = STROKE_PATTERNS.length;\n return STROKE_PATTERNS[((Math.trunc(order) % n) + n) % n];\n}\n\n/** Which of the patterns a `step` series is drawn in.\n *\n * A whole-number channel that jumps rather than travels is drawn dashed, and it has\n * to be one of {@link STROKE_PATTERNS} rather than a dash array written out at the\n * call site: a legend entry says its pattern by index, so a step line drawn in\n * anything else would be a legend promising a solid line for a dashed one. */\nexport const STEP_DASH = 1;\n\n/**\n * One key switched, as a new hidden set.\n *\n * Every caller writes `onToggle={(key) => setHidden(toggleHidden(hidden, key))}`. A\n * new set rather than a mutated one, because the state is read during render and a\n * mutation of something a component already holds is invisible to React.\n */\nexport function toggleHidden(hidden: ReadonlySet<string>, key: string): ReadonlySet<string> {\n const next = new Set(hidden);\n if (!next.delete(key)) next.add(key);\n return next;\n}\n\nexport interface LegendEntry {\n key: string;\n label: ReactNode;\n /** Any CSS colour — `paletteFor(i)` for the kit's own ramp. */\n color: string;\n /**\n * What the entry's mark looks like. `swatch` — a filled square, the default — says\n * WHICH MEASUREMENT. `stroke` says WHICH QUANTITY, for the charts that carry both at\n * once and tell them apart by colour and by dash. `dot` is a round mark, for a\n * MARKER on the chart (a point, an event) rather than a series.\n */\n marker?: \"swatch\" | \"stroke\" | \"dot\";\n /** Which of {@link STROKE_PATTERNS}, when the marker is a stroke — the same index\n * `SeriesChartSeries.dash` takes, so a legend cannot promise a dot-dash the plot\n * draws dashed. Or the same custom `stroke-dasharray` string the series draws with\n * (keksdose's `\"4 3\"`). */\n dash?: number | string;\n /**\n * A mark of the caller's own, drawn in place of {@link marker} — for a key whose\n * mark is not a colour at all (a fading bar for \"open-ended\", a hairline for\n * \"today\"). Decorative: it is wrapped `aria-hidden`, and the label is the name.\n */\n icon?: ReactNode;\n}\n\nexport interface ToggleLegendProps {\n entries: LegendEntry[];\n /** The keys currently off the chart. */\n hidden: ReadonlySet<string>;\n onToggle: (key: string) => void;\n /** `vertical` is for a legend standing BESIDE the charts rather than under them —\n * what a row of charts sharing one legend wants, since under two charts there is\n * no \"under\", and putting it under one says it belongs to that one. */\n orientation?: \"horizontal\" | \"vertical\";\n /**\n * Draw a legend of ONE entry rather than nothing.\n *\n * A single line's legend is usually noise. It is not where the legend is the only\n * SWITCH that line has — a series that starts hidden and has no entry to bring it\n * back is gone for good.\n */\n showSingle?: boolean;\n className?: string;\n /** Per-instance strings over `<UiKitProvider labels={{ seriesChart }}>`. */\n labels?: Partial<SeriesChartLabels>;\n}\n\n/**\n * A legend whose entries are switches.\n *\n * `ChartLegendContent` with `onItemClick`/`activeKey` is the other kind: clicking an\n * entry HIGHLIGHTS it and dims the rest, which is right when every series belongs on\n * the chart and one of them is momentarily interesting. This is for the opposite —\n * three coordinates on one axis where y runs to 800 and z barely moves, and the only\n * way to see z is to take y off the chart entirely. Any number may be off at once.\n *\n * A hidden entry keeps its mark and its place. Removing it would reflow the legend on\n * every click and lose the one thing it is for: saying what COULD be shown.\n *\n * Each entry is a real `<button>` with `aria-pressed` — pressed means \"on the chart\" —\n * so Tab reaches every switch and Space/Enter flips it.\n */\nexport function ToggleLegend({\n entries,\n hidden,\n onToggle,\n orientation = \"horizontal\",\n showSingle = false,\n className,\n labels: labelsProp,\n}: ToggleLegendProps) {\n const labels = useKitLabels(\"seriesChart\", DEFAULT_SERIES_CHART_LABELS, labelsProp);\n if (entries.length < (showSingle ? 1 : 2)) return null;\n return (\n <div\n role=\"group\"\n aria-label={labels.legend}\n className={cn(\n \"flex gap-x-3 gap-y-1\",\n orientation === \"vertical\"\n ? \"flex-col items-start justify-center\"\n : \"mt-2 flex-wrap items-center\",\n className,\n )}\n >\n {entries.map((entry) => {\n const off = hidden.has(entry.key);\n return (\n <button\n key={entry.key}\n type=\"button\"\n // Pressed rather than a checkbox: it is a control over what the chart\n // draws, and \"not pressed\" is the same sentence the dimmed mark says.\n aria-pressed={!off}\n onClick={() => onToggle(entry.key)}\n className={cn(\n \"flex items-center gap-1.5 rounded text-start text-[11px] text-[var(--text-secondary)] transition-opacity hover:opacity-80\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]\",\n off && \"opacity-35\",\n )}\n >\n <LegendMark entry={entry} off={off} />\n {entry.label}\n </button>\n );\n })}\n </div>\n );\n}\n\nexport interface StaticLegendProps {\n entries: LegendEntry[];\n /** See {@link ToggleLegendProps.orientation}. */\n orientation?: \"horizontal\" | \"vertical\";\n /** The list's accessible name. Default: the `seriesChart.legend` label. */\n \"aria-label\"?: string;\n className?: string;\n /** Per-instance strings over `<UiKitProvider labels={{ seriesChart }}>`. */\n labels?: Partial<SeriesChartLabels>;\n}\n\n/**\n * A legend that is a KEY, not a control: the same entries and marks as\n * {@link ToggleLegend} — swatch, stroke (with the chart's own dash), dot, or a mark of\n * the caller's own — but nothing to press.\n *\n * A separate component rather than `ToggleLegend` without `onToggle`, because the two\n * are different things to a screen reader: a group of switches announces a pressed\n * state on every entry, a key is a LIST, read as \"list, 4 items\" and walked with the\n * list keys, and costs no tab stops. The apps drew this by hand under the charts that\n * say what a colour or a dash means without letting you switch it (kastlan's lease\n * timeline, keksdose's cash buffer).\n *\n * Unlike `ToggleLegend` it draws a single entry: a key with one line still says what\n * the one mark on the chart means.\n */\nexport function StaticLegend({\n entries,\n orientation = \"horizontal\",\n \"aria-label\": ariaLabel,\n className,\n labels: labelsProp,\n}: StaticLegendProps) {\n const labels = useKitLabels(\"seriesChart\", DEFAULT_SERIES_CHART_LABELS, labelsProp);\n if (entries.length === 0) return null;\n return (\n <ul\n aria-label={ariaLabel ?? labels.legend}\n className={cn(\n \"m-0 flex list-none gap-x-3 gap-y-1 p-0\",\n orientation === \"vertical\"\n ? \"flex-col items-start justify-center\"\n : \"mt-2 flex-wrap items-center\",\n className,\n )}\n >\n {entries.map((entry) => (\n <li\n key={entry.key}\n className=\"flex items-center gap-1.5 text-[11px] text-[var(--text-secondary)]\"\n >\n <LegendMark entry={entry} off={false} />\n {entry.label}\n </li>\n ))}\n </ul>\n );\n}\n\n/**\n * The column a vertical legend stands in: beside the charts, on their horizontal\n * centre line. `h-full` + `justify-center`, so it centres in a grid cell or a flex row\n * that stretches it — and stacks whatever it is given, which is what a panel showing\n * two legends at once needs. The width is the caller's: how wide a legend column is\n * is a fact about that screen's layout.\n */\nexport function LegendColumn({ children, className }: { children: ReactNode; className?: string }) {\n return (\n <div className={cn(\"flex h-full flex-col justify-center gap-3\", className)}>{children}</div>\n );\n}\n\n/**\n * A legend and what it is a legend OF, as a headed column.\n *\n * For legends that are several vocabularies at once — which measurement and which\n * channel — so the entries are read down a group and the groups across. Wrapped rows\n * would put the second half of one group on a line under the first half of the next.\n */\nexport function LegendGroup({ title, children }: { title: ReactNode; children: ReactNode }) {\n return (\n <div className=\"flex min-w-0 flex-col items-start gap-1\">\n <span className=\"text-[10px] font-semibold uppercase tracking-wide text-[var(--text-muted)]\">\n {title}\n </span>\n <div className=\"flex flex-col items-start gap-y-0.5\">{children}</div>\n </div>\n );\n}\n\n/** The entry's mark: a square for a measurement, a stroke for a quantity. A hidden one\n * keeps its outline — a mark that vanished would leave a line of text with nothing in\n * front of it.\n *\n * A stroke is an SVG line with the chart's own `strokeDasharray`, not a CSS gradient\n * approximating it: a gradient can say \"solid or dashed\" and nothing more.\n *\n * A hidden stroke is NOT faded here: the button around it already is (`opacity-35`),\n * and the line used to take its own 0.35 on top — 0.35 × 0.35, about 12 %, a mark\n * gone rather than dimmed, while the square beside it read at the full 35 %. */\nfunction LegendMark({ entry, off }: { entry: LegendEntry; off: boolean }) {\n if (entry.icon != null) {\n return (\n <span aria-hidden className=\"inline-flex shrink-0 items-center\">\n {entry.icon}\n </span>\n );\n }\n if (entry.marker === \"dot\") {\n return (\n <span\n aria-hidden\n className=\"size-2 shrink-0 rounded-full\"\n style={{\n backgroundColor: off ? \"transparent\" : entry.color,\n boxShadow: `inset 0 0 0 1.5px ${entry.color}`,\n }}\n />\n );\n }\n if (entry.marker === \"stroke\") {\n return (\n <svg aria-hidden viewBox=\"0 0 20 2\" className=\"h-0.5 w-5 shrink-0 overflow-visible\">\n <line\n x1={0}\n y1={1}\n x2={20}\n y2={1}\n stroke={entry.color}\n strokeWidth={2}\n strokeDasharray={typeof entry.dash === \"string\" ? entry.dash : strokeDash(entry.dash ?? 0)}\n />\n </svg>\n );\n }\n return (\n <span\n aria-hidden\n className=\"h-2.5 w-2.5 shrink-0 rounded-[3px]\"\n style={{\n backgroundColor: off ? \"transparent\" : entry.color,\n boxShadow: `inset 0 0 0 1.5px ${entry.color}`,\n }}\n />\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { ComponentPropsWithoutRef, ReactNode } from 'react';
3
3
  import { AnchorRect } from '../hooks/use-anchored-rect.js';
4
+ export { CLIPS_ATTRIBUTE } from '../lib/clipping.js';
4
5
 
5
6
  /** Where the bubble sits. `start` / `end` follow the reading direction — `end` is the
6
7
  * right in LTR and the left in RTL — and are what a layout that mirrors should ask
@@ -25,8 +26,8 @@ interface TooltipProps extends ComponentPropsWithoutRef<"span"> {
25
26
  /**
26
27
  * Where the bubble lives. Left out (the default since 0.10.0), the tooltip decides for
27
28
  * itself: the bubble stays next to the trigger unless an ancestor clips or scrolls
28
- * (`overflow` other than `visible`), in which case it is portalled — see "Inside a
29
- * scroll container" below. `true` always portals, `false` never does; both are exactly
29
+ * (`overflow` other than `visible`, or the {@link CLIPS_ATTRIBUTE} marker), in which
30
+ * case it is portalled — see "Inside a scroll container" below. `true` always portals, `false` never does; both are exactly
30
31
  * what they were before the default existed.
31
32
  */
32
33
  portal?: boolean;
@@ -89,7 +90,14 @@ interface TooltipProps extends ComponentPropsWithoutRef<"span"> {
89
90
  * being one: every tooltip in a table, a drawer or a scrolling card had to remember it,
90
91
  * and the ones that forgot were only found by someone scrolling sideways. So with
91
92
  * `portal` left out, the tooltip looks for a clipping ancestor itself — any element
92
- * between it and `<body>` whose computed `overflow-x` / `overflow-y` is not `visible`.
93
+ * between it and `<body>` whose computed `overflow-x` / `overflow-y` is not `visible`,
94
+ * or that carries {@link CLIPS_ATTRIBUTE} (`data-clips`). The marker is what makes the
95
+ * answer the same under test: jsdom computes no Tailwind, so there every scroller
96
+ * reads `visible` and a table-cell tooltip used to stay in place — its always-mounted
97
+ * bubble then repeated the label in the cell's accessible name and `textContent`
98
+ * ("CheckingChecking"), and apps pinned `portal` to stop it. The kit's own scrollers
99
+ * are marked, so a tooltip in a DataTable or Table cell portals in jsdom exactly as it
100
+ * does in the browser.
93
101
  * It looks at MOUNT, not only on open: dev#488's phantom scroll is caused by a bubble
94
102
  * nobody opened, so a check that waited for the hover would find the damage already
95
103
  * done. It looks again on every open, for a container that started scrolling after
@@ -14,6 +14,7 @@ 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
16
  import { hasClippingAncestor } from "../lib/clipping.js";
17
+ import { CLIPS_ATTRIBUTE } from "../lib/clipping.js";
17
18
  function physicalSide(side, dir) {
18
19
  if (side === "start") return dir === "rtl" ? "right" : "left";
19
20
  if (side === "end") return dir === "rtl" ? "left" : "right";
@@ -277,6 +278,7 @@ function PortalBubble({
277
278
  );
278
279
  }
279
280
  export {
281
+ CLIPS_ATTRIBUTE,
280
282
  Tooltip,
281
283
  placeTooltip
282
284
  };
@@ -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 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":"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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/** The attribute that marks an element as a clipping container for {@link Tooltip}'s\n * auto-portal, whatever its computed `overflow` — `<div data-clips>` or\n * `<div {...{ [CLIPS_ATTRIBUTE]: \"\" }}>`. Put it on an app's own scroller so a test\n * environment without stylesheets (jsdom) portals the same tooltips the browser\n * does. DataTable's body and Table's wrapper already carry it. */\nexport { CLIPS_ATTRIBUTE } 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`, or the {@link CLIPS_ATTRIBUTE} marker), in which\n * case it is portalled — see \"Inside a 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 * or that carries {@link CLIPS_ATTRIBUTE} (`data-clips`). The marker is what makes the\n * answer the same under test: jsdom computes no Tailwind, so there every scroller\n * reads `visible` and a table-cell tooltip used to stay in place — its always-mounted\n * bubble then repeated the label in the cell's accessible name and `textContent`\n * (\"CheckingChecking\"), and apps pinned `portal` to stop it. The kit's own scrollers\n * are marked, so a tooltip in a DataTable or Table cell portals in jsdom exactly as it\n * does in the browser.\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":"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","names":[]}