@eifi1/ui-kit 0.48.1 → 0.49.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 (318) hide show
  1. package/README.md +28 -13
  2. package/dist/admin/admin-action-confirm.js +1 -1
  3. package/dist/admin/admin-action-confirm.js.map +1 -1
  4. package/dist/admin/invitations-panel.js +5 -1
  5. package/dist/admin/invitations-panel.js.map +1 -1
  6. package/dist/admin/user-roster.d.ts +11 -5
  7. package/dist/admin/user-roster.js +15 -4
  8. package/dist/admin/user-roster.js.map +1 -1
  9. package/dist/auth/accept-invitation.js +2 -2
  10. package/dist/auth/accept-invitation.js.map +1 -1
  11. package/dist/auth/not-found-page.js +1 -1
  12. package/dist/auth/not-found-page.js.map +1 -1
  13. package/dist/auth/reset-password-form.js +2 -2
  14. package/dist/auth/reset-password-form.js.map +1 -1
  15. package/dist/auth/verify-email.js +2 -2
  16. package/dist/auth/verify-email.js.map +1 -1
  17. package/dist/billing/plan-card.js +2 -2
  18. package/dist/billing/plan-card.js.map +1 -1
  19. package/dist/components/breadcrumbs.d.ts +19 -1
  20. package/dist/components/breadcrumbs.js +11 -4
  21. package/dist/components/breadcrumbs.js.map +1 -1
  22. package/dist/components/button-look.d.ts +37 -7
  23. package/dist/components/button-look.js +33 -6
  24. package/dist/components/button-look.js.map +1 -1
  25. package/dist/components/button.d.ts +4 -1
  26. package/dist/components/button.js +2 -2
  27. package/dist/components/button.js.map +1 -1
  28. package/dist/components/calendar-heatmap.js +2 -1
  29. package/dist/components/calendar-heatmap.js.map +1 -1
  30. package/dist/components/chart-edge.d.ts +10 -0
  31. package/dist/components/chart-edge.js +57 -0
  32. package/dist/components/chart-edge.js.map +1 -0
  33. package/dist/components/chart.js +2 -1
  34. package/dist/components/chart.js.map +1 -1
  35. package/dist/components/chip.d.ts +8 -1
  36. package/dist/components/chip.js +4 -1
  37. package/dist/components/chip.js.map +1 -1
  38. package/dist/components/confirm-context.d.ts +5 -0
  39. package/dist/components/confirm-context.js +7 -0
  40. package/dist/components/confirm-context.js.map +1 -0
  41. package/dist/components/confirm-dialog.js +3 -3
  42. package/dist/components/confirm-dialog.js.map +1 -1
  43. package/dist/components/danger-confirm.d.ts +2 -2
  44. package/dist/components/danger-confirm.js +1 -1
  45. package/dist/components/danger-confirm.js.map +1 -1
  46. package/dist/components/data-table-fit.d.ts +8 -1
  47. package/dist/components/data-table-fit.js +28 -4
  48. package/dist/components/data-table-fit.js.map +1 -1
  49. package/dist/components/data-table-groups.d.ts +3 -1
  50. package/dist/components/data-table-groups.js +11 -5
  51. package/dist/components/data-table-groups.js.map +1 -1
  52. package/dist/components/data-table-head.d.ts +8 -1
  53. package/dist/components/data-table-head.js +11 -3
  54. package/dist/components/data-table-head.js.map +1 -1
  55. package/dist/components/data-table-leading-row.d.ts +3 -1
  56. package/dist/components/data-table-leading-row.js +2 -1
  57. package/dist/components/data-table-leading-row.js.map +1 -1
  58. package/dist/components/data-table-mobile.d.ts +48 -2
  59. package/dist/components/data-table-mobile.js +87 -10
  60. package/dist/components/data-table-mobile.js.map +1 -1
  61. package/dist/components/data-table-row-actions.d.ts +2 -1
  62. package/dist/components/data-table-row-actions.js +9 -2
  63. package/dist/components/data-table-row-actions.js.map +1 -1
  64. package/dist/components/data-table-row.d.ts +84 -0
  65. package/dist/components/data-table-row.js +261 -0
  66. package/dist/components/data-table-row.js.map +1 -0
  67. package/dist/components/data-table-selection.d.ts +3 -0
  68. package/dist/components/data-table-selection.js +12 -1
  69. package/dist/components/data-table-selection.js.map +1 -1
  70. package/dist/components/data-table-virtual.d.ts +98 -0
  71. package/dist/components/data-table-virtual.js +369 -0
  72. package/dist/components/data-table-virtual.js.map +1 -0
  73. package/dist/components/data-table.d.ts +74 -2
  74. package/dist/components/data-table.js +241 -221
  75. package/dist/components/data-table.js.map +1 -1
  76. package/dist/components/error-boundary.js +2 -2
  77. package/dist/components/error-boundary.js.map +1 -1
  78. package/dist/components/floating-panel.d.ts +36 -13
  79. package/dist/components/floating-panel.js +27 -7
  80. package/dist/components/floating-panel.js.map +1 -1
  81. package/dist/components/form-actions.d.ts +1 -1
  82. package/dist/components/form-actions.js +1 -1
  83. package/dist/components/form-actions.js.map +1 -1
  84. package/dist/components/icon-button.d.ts +11 -0
  85. package/dist/components/icon-button.js +7 -5
  86. package/dist/components/icon-button.js.map +1 -1
  87. package/dist/components/nav-pills.d.ts +8 -1
  88. package/dist/components/nav-pills.js +61 -38
  89. package/dist/components/nav-pills.js.map +1 -1
  90. package/dist/components/page-header-context.d.ts +8 -0
  91. package/dist/components/page-header-context.js +7 -0
  92. package/dist/components/page-header-context.js.map +1 -0
  93. package/dist/components/page-header.d.ts +14 -2
  94. package/dist/components/page-header.js +10 -1
  95. package/dist/components/page-header.js.map +1 -1
  96. package/dist/components/pie-chart.js +2 -1
  97. package/dist/components/pie-chart.js.map +1 -1
  98. package/dist/components/progress-bar.js +5 -4
  99. package/dist/components/progress-bar.js.map +1 -1
  100. package/dist/components/reauth-dialog.js +1 -1
  101. package/dist/components/reauth-dialog.js.map +1 -1
  102. package/dist/components/row-actions-labels.js +3 -1
  103. package/dist/components/row-actions-labels.js.map +1 -1
  104. package/dist/components/row-actions.d.ts +43 -9
  105. package/dist/components/row-actions.js +78 -15
  106. package/dist/components/row-actions.js.map +1 -1
  107. package/dist/components/section-nav.d.ts +37 -0
  108. package/dist/components/section-nav.js +33 -0
  109. package/dist/components/section-nav.js.map +1 -0
  110. package/dist/components/series-chart.js +8 -3
  111. package/dist/components/series-chart.js.map +1 -1
  112. package/dist/components/share-card.js +1 -1
  113. package/dist/components/share-card.js.map +1 -1
  114. package/dist/components/signature-pad.js +1 -1
  115. package/dist/components/signature-pad.js.map +1 -1
  116. package/dist/components/sparkline.js +2 -1
  117. package/dist/components/sparkline.js.map +1 -1
  118. package/dist/components/stat-tile.js +14 -7
  119. package/dist/components/stat-tile.js.map +1 -1
  120. package/dist/components/status-dot.d.ts +5 -1
  121. package/dist/components/status-dot.js +4 -1
  122. package/dist/components/status-dot.js.map +1 -1
  123. package/dist/components/tooltip.js +1 -1
  124. package/dist/components/tooltip.js.map +1 -1
  125. package/dist/components/translation-review-editor.js +2 -2
  126. package/dist/components/translation-review-editor.js.map +1 -1
  127. package/dist/components/translation-review.js +2 -2
  128. package/dist/components/translation-review.js.map +1 -1
  129. package/dist/components/treemap.d.ts +5 -2
  130. package/dist/components/treemap.js +26 -6
  131. package/dist/components/treemap.js.map +1 -1
  132. package/dist/components/use-table-state.js +37 -6
  133. package/dist/components/use-table-state.js.map +1 -1
  134. package/dist/data-table.d.ts +1 -0
  135. package/dist/data-table.js.map +1 -1
  136. package/dist/demo/demo-banner.js +1 -1
  137. package/dist/demo/demo-banner.js.map +1 -1
  138. package/dist/demo/demo-ended.js +1 -1
  139. package/dist/demo/demo-ended.js.map +1 -1
  140. package/dist/demo/demo-start.js +1 -1
  141. package/dist/demo/demo-start.js.map +1 -1
  142. package/dist/eslint/index.js +1 -1
  143. package/dist/feedback/feedback-inbox.js +1 -1
  144. package/dist/feedback/feedback-inbox.js.map +1 -1
  145. package/dist/hooks/use-anchored-panel.js +2 -1
  146. package/dist/hooks/use-anchored-panel.js.map +1 -1
  147. package/dist/hooks/use-dismiss.d.ts +9 -1
  148. package/dist/hooks/use-dismiss.js +25 -5
  149. package/dist/hooks/use-dismiss.js.map +1 -1
  150. package/dist/hooks/use-document-title.d.ts +14 -0
  151. package/dist/hooks/use-document-title.js +22 -0
  152. package/dist/hooks/use-document-title.js.map +1 -0
  153. package/dist/i18n/german.js +6 -2
  154. package/dist/i18n/german.js.map +1 -1
  155. package/dist/i18n/kit-labels.d.ts +31 -1
  156. package/dist/i18n/kit-labels.js +43 -2
  157. package/dist/i18n/kit-labels.js.map +1 -1
  158. package/dist/i18n/locales/en.js +6 -2
  159. package/dist/i18n/locales/en.js.map +2 -2
  160. package/dist/i18n/locales/es.js +6 -2
  161. package/dist/i18n/locales/es.js.map +1 -1
  162. package/dist/i18n/locales/fr.js +6 -2
  163. package/dist/i18n/locales/fr.js.map +1 -1
  164. package/dist/i18n/locales/hu.js +6 -2
  165. package/dist/i18n/locales/hu.js.map +1 -1
  166. package/dist/i18n/locales/it.js +6 -2
  167. package/dist/i18n/locales/it.js.map +1 -1
  168. package/dist/i18n/locales/zh.js +6 -2
  169. package/dist/i18n/locales/zh.js.map +1 -1
  170. package/dist/i18n/review.js +2 -0
  171. package/dist/i18n/review.js.map +1 -1
  172. package/dist/i18n/shared-labels.js +1 -0
  173. package/dist/i18n/shared-labels.js.map +1 -1
  174. package/dist/index.d.ts +8 -1
  175. package/dist/index.js +11 -0
  176. package/dist/index.js.map +1 -1
  177. package/dist/landing/landing-actions.js +2 -2
  178. package/dist/landing/landing-actions.js.map +1 -1
  179. package/dist/landing/public-header.js +2 -2
  180. package/dist/landing/public-header.js.map +1 -1
  181. package/dist/lib/clipboard-minus.d.ts +27 -0
  182. package/dist/lib/clipboard-minus.js +63 -0
  183. package/dist/lib/clipboard-minus.js.map +1 -0
  184. package/dist/lib/document-title.d.ts +17 -0
  185. package/dist/lib/document-title.js +29 -0
  186. package/dist/lib/document-title.js.map +1 -0
  187. package/dist/lib/float-clearance.js +8 -2
  188. package/dist/lib/float-clearance.js.map +1 -1
  189. package/dist/lib/format.d.ts +35 -10
  190. package/dist/lib/format.js +55 -11
  191. package/dist/lib/format.js.map +1 -1
  192. package/dist/lib/minus.d.ts +22 -0
  193. package/dist/lib/minus.js +14 -0
  194. package/dist/lib/minus.js.map +1 -0
  195. package/dist/lib/status-tone.d.ts +30 -0
  196. package/dist/lib/status-tone.js +17 -0
  197. package/dist/lib/status-tone.js.map +1 -0
  198. package/dist/settings/settings-layout.js +3 -2
  199. package/dist/settings/settings-layout.js.map +1 -1
  200. package/dist/shell/account-menu-language.d.ts +1 -1
  201. package/dist/shell/account-menu-language.js +9 -1
  202. package/dist/shell/account-menu-language.js.map +1 -1
  203. package/dist/shell/app-shell-labels.js +2 -1
  204. package/dist/shell/app-shell-labels.js.map +1 -1
  205. package/dist/shell/app-shell.d.ts +58 -16
  206. package/dist/shell/app-shell.js +158 -44
  207. package/dist/shell/app-shell.js.map +1 -1
  208. package/dist/shell/language-code.d.ts +3 -0
  209. package/dist/shell/language-code.js +8 -0
  210. package/dist/shell/language-code.js.map +1 -0
  211. package/dist/shell/topbar-controls.d.ts +14 -6
  212. package/dist/shell/topbar-controls.js +14 -17
  213. package/dist/shell/topbar-controls.js.map +1 -1
  214. package/dist/testing/css-variables.generated.d.ts +1 -1
  215. package/dist/testing/css-variables.generated.js +1 -1
  216. package/dist/testing/css-variables.generated.js.map +1 -1
  217. package/dist/theme/chart-palette.d.ts +82 -15
  218. package/dist/theme/chart-palette.js +51 -24
  219. package/dist/theme/chart-palette.js.map +1 -1
  220. package/dist/theme/palette-presets.d.ts +9 -0
  221. package/dist/theme/palette-presets.js +77 -62
  222. package/dist/theme/palette-presets.js.map +1 -1
  223. package/dist/tour/tour.js +1 -1
  224. package/dist/tour/tour.js.map +1 -1
  225. package/dist/wizard/stepper-nav.d.ts +1 -1
  226. package/dist/wizard/stepper-nav.js +3 -3
  227. package/dist/wizard/stepper-nav.js.map +1 -1
  228. package/package.json +1 -1
  229. package/src/admin/admin-action-confirm.tsx +1 -1
  230. package/src/admin/invitations-panel.tsx +7 -3
  231. package/src/admin/user-roster.tsx +23 -6
  232. package/src/auth/accept-invitation.tsx +3 -3
  233. package/src/auth/not-found-page.tsx +1 -1
  234. package/src/auth/reset-password-form.tsx +3 -3
  235. package/src/auth/verify-email.tsx +3 -3
  236. package/src/billing/plan-card.tsx +2 -2
  237. package/src/components/breadcrumbs.tsx +32 -4
  238. package/src/components/button-look.ts +86 -15
  239. package/src/components/button.tsx +6 -3
  240. package/src/components/calendar-heatmap.tsx +2 -1
  241. package/src/components/chart-edge.ts +73 -0
  242. package/src/components/chart.tsx +3 -1
  243. package/src/components/chip.tsx +10 -1
  244. package/src/components/confirm-context.ts +7 -0
  245. package/src/components/confirm-dialog.tsx +3 -3
  246. package/src/components/danger-confirm.tsx +3 -3
  247. package/src/components/data-table-fit.ts +58 -8
  248. package/src/components/data-table-groups.tsx +15 -4
  249. package/src/components/data-table-head.tsx +25 -2
  250. package/src/components/data-table-leading-row.tsx +4 -1
  251. package/src/components/data-table-mobile.tsx +162 -11
  252. package/src/components/data-table-row-actions.tsx +12 -3
  253. package/src/components/data-table-row.tsx +485 -0
  254. package/src/components/data-table-selection.ts +17 -1
  255. package/src/components/data-table-virtual.ts +547 -0
  256. package/src/components/data-table.tsx +367 -326
  257. package/src/components/error-boundary.tsx +2 -2
  258. package/src/components/floating-panel.tsx +67 -16
  259. package/src/components/form-actions.tsx +2 -2
  260. package/src/components/icon-button.tsx +21 -8
  261. package/src/components/nav-pills.tsx +29 -2
  262. package/src/components/page-header-context.ts +8 -0
  263. package/src/components/page-header.tsx +34 -2
  264. package/src/components/pie-chart.tsx +2 -1
  265. package/src/components/progress-bar.tsx +6 -3
  266. package/src/components/reauth-dialog.tsx +1 -1
  267. package/src/components/row-actions-labels.ts +2 -0
  268. package/src/components/row-actions.tsx +154 -33
  269. package/src/components/section-nav.tsx +65 -0
  270. package/src/components/series-chart.tsx +12 -3
  271. package/src/components/share-card.tsx +1 -1
  272. package/src/components/signature-pad.tsx +1 -1
  273. package/src/components/sparkline.tsx +2 -1
  274. package/src/components/stat-tile.tsx +19 -7
  275. package/src/components/status-dot.tsx +7 -1
  276. package/src/components/tooltip.tsx +3 -1
  277. package/src/components/translation-review-editor.tsx +2 -2
  278. package/src/components/translation-review.tsx +2 -2
  279. package/src/components/treemap.tsx +34 -8
  280. package/src/components/use-table-state.ts +63 -8
  281. package/src/data-table.ts +5 -3
  282. package/src/demo/demo-banner.tsx +1 -1
  283. package/src/demo/demo-ended.tsx +1 -1
  284. package/src/demo/demo-start.tsx +1 -1
  285. package/src/feedback/feedback-inbox.tsx +1 -1
  286. package/src/hooks/use-anchored-panel.ts +9 -1
  287. package/src/hooks/use-dismiss.ts +43 -4
  288. package/src/hooks/use-document-title.ts +31 -0
  289. package/src/i18n/german.ts +4 -0
  290. package/src/i18n/kit-labels.tsx +76 -1
  291. package/src/i18n/locales/es.ts +4 -0
  292. package/src/i18n/locales/fr.ts +4 -0
  293. package/src/i18n/locales/hu.ts +4 -0
  294. package/src/i18n/locales/it.ts +4 -0
  295. package/src/i18n/locales/zh.ts +4 -0
  296. package/src/i18n/review.ts +2 -0
  297. package/src/i18n/shared-labels.ts +1 -0
  298. package/src/index.ts +13 -0
  299. package/src/landing/landing-actions.tsx +2 -2
  300. package/src/landing/public-header.tsx +2 -2
  301. package/src/lib/clipboard-minus.ts +94 -0
  302. package/src/lib/document-title.ts +41 -0
  303. package/src/lib/float-clearance.ts +19 -3
  304. package/src/lib/format.ts +115 -30
  305. package/src/lib/minus.ts +40 -0
  306. package/src/lib/status-tone.ts +43 -0
  307. package/src/settings/settings-layout.tsx +3 -2
  308. package/src/shell/account-menu-language.tsx +14 -2
  309. package/src/shell/app-shell-labels.ts +1 -0
  310. package/src/shell/app-shell.tsx +298 -69
  311. package/src/shell/language-code.ts +5 -0
  312. package/src/shell/topbar-controls.tsx +35 -15
  313. package/src/testing/css-variables.generated.ts +6 -6
  314. package/src/theme/chart-palette.ts +102 -20
  315. package/src/theme/palette-presets.ts +121 -84
  316. package/src/tour/tour.tsx +1 -1
  317. package/src/wizard/stepper-nav.tsx +4 -4
  318. package/tokens.css +28 -18
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/page-header.tsx"],"sourcesContent":["import { useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithRef, ElementType, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { SECTION_LABEL_CLASS } from \"./text\";\n\n/**\n * `md` (default) is kastlan's `text-2xl` page title from `sm` up, a step smaller on a\n * phone; `sm` is lenkbank's `text-lg` title on a dense tool page (projects-page.tsx).\n *\n * `compact` (0.13) is keksdose's page title (keksdose F4): `text-xl font-semibold` at\n * EVERY width — budgets, rules, accounts, transactions and budget pages all write\n * exactly that `<h1>` by hand. It is `md`'s phone size without `md`'s step up to\n * `text-2xl` on a wider screen, and without its `tracking-tight`, which keksdose's\n * titles never had. A word and not a letter for the reason `ProgressBar`'s `slim` is\n * one: the letters are taken in order, and there is none between `sm` and `md` — an\n * `lg` or `xl` at `text-xl` would read as LARGER than `md`, which from `sm` up it is\n * not.\n */\nexport type PageHeaderSize = \"sm\" | \"compact\" | \"md\";\n\nconst TITLE: Record<PageHeaderSize, string> = {\n sm: \"text-lg font-semibold\",\n compact: \"text-xl font-semibold\",\n md: \"text-xl font-semibold tracking-tight sm:text-2xl\",\n};\n\n/**\n * How the title block and the actions sit below the `sm` breakpoint. From `sm` up both\n * layouts are one row: title block at the start, actions at the end.\n */\nexport type PageHeaderMobileLayout = \"stacked\" | \"inline\";\n\n/**\n * `stacked` at Large and Extra large on a phone (0.32.2, kastlan's 0.32.1 report;\n * docs/text-size-harmonization.md §4, as FormActions does): the actions take the full\n * width, one under the other. Side by side in 240 px of layout, two buttons each wrapped\n * their label onto three lines. `large:max-sm:`, the scaled phone width, so Normal and\n * every wider screen keep their row; `inline` is for actions known to fit, and stays.\n */\nconst STACK_ACTIONS_AT_LARGE =\n \"large:max-sm:flex-col large:max-sm:items-stretch large:max-sm:[&>*]:w-full\";\n\nconst ROW: Record<PageHeaderMobileLayout, string> = {\n stacked: \"flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4\",\n inline: \"flex-row items-center justify-between gap-2 sm:gap-4\",\n};\n\n/**\n * Where the actions sit on the title block's height, in the one row the header is from\n * `sm` up (and on a phone too when {@link PageHeaderMobileLayout} is `inline`, the only\n * layout that has a row there). Unset keeps each layout's own: `start` for `stacked`,\n * `center` for `inline`.\n */\nexport type PageHeaderActionsAlign = \"start\" | \"center\" | \"end\";\n\n/** Applied to the ROW, so the title block and the actions are aligned against each\n * other on one cross axis. 0.14.0 put `self-*` on the actions box alone, which moved\n * nothing when the actions were the taller item — they already filled the row, and\n * the title stayed at the top (keksdose H2, reports-page's two-line labelled\n * CurrencySelect). Per layout, because `stacked` is a column on a phone, where\n * `items-center` would centre everything HORIZONTALLY — so it aligns from `sm` up. */\nconst ROW_ALIGN: Record<PageHeaderMobileLayout, Record<PageHeaderActionsAlign, string>> = {\n stacked: { start: \"sm:items-start\", center: \"sm:items-center\", end: \"sm:items-end\" },\n inline: { start: \"items-start\", center: \"items-center\", end: \"items-end\" },\n};\n\nexport interface PageHeaderProps extends Omit<ComponentPropsWithRef<\"div\">, \"title\"> {\n title: ReactNode;\n /** A sentence under the title. */\n description?: ReactNode;\n /** Buttons for the page — \"New\", \"Export\". At the title's end from `sm` up; under it,\n * wrapping, on a phone — unless {@link mobileLayout} is `inline`. */\n actions?: ReactNode;\n /** A `Breadcrumbs` trail, drawn above everything else. */\n breadcrumbs?: ReactNode;\n /** A small uppercase line above the title — the section or the kind of record\n * (\"Building\", \"Settings\"). The section-label type. */\n eyebrow?: ReactNode;\n /** The title's element. Default `h1`: a page has one, and this is it. `h2` for a\n * header inside a page that already has its `h1` (a tab's own header). */\n as?: \"h1\" | \"h2\" | \"h3\";\n size?: PageHeaderSize;\n /**\n * `stacked` (default): on a phone the actions drop under the title and wrap, so a\n * row of three buttons never pushes the title off the screen.\n *\n * `inline`: the actions stay beside the title on a phone too, vertically centred on\n * it — keksdose's headers (transactions-page.tsx:515, `flex items-center\n * justify-between gap-2`), which carry one or two icon-sized controls and were tuned\n * for the phone in live #263. Adopting `PageHeader` must not undo that tuning, so the\n * row stays a row (keksdose F4). The actions do NOT wrap or shrink here: `inline` is\n * for a header whose actions are known to fit beside a phone-width title (an icon\n * button, a month picker); a long title wraps to make room for them instead.\n */\n mobileLayout?: PageHeaderMobileLayout;\n /**\n * A second group of actions that gets its OWN full-width row under the header on a\n * phone, and joins the actions' row from `sm` up, between the title and\n * {@link actions}.\n *\n * keksdose's budget page (budget-page.tsx, feedback #60) is the case: title and month\n * navigation on row 1, the fold/unfold toggles on row 2 on a phone — \"two tidy rows\n * instead of the old ragged justify-between overflow\" — and one row, title | toggles |\n * month nav, on a wider screen. It builds that by hand with a wrapping flex row and\n * swapped order utilities; adopting `PageHeader` (keksdose G6b) must not lose it.\n * There: `actions` is the month navigation, this is the toggles.\n *\n * The DOM order is title, `actions`, `secondaryActions` — the phone's visual order,\n * and the one a screen reader and the Tab key follow at every width. From `sm` up the\n * two groups swap places visually only, which keeps the page's primary control (the\n * month) at the row's end, where keksdose had it, and first in reading order. The\n * breakpoint is `sm`, the one {@link mobileLayout} switches at, not keksdose's `md`:\n * one header, one breakpoint.\n */\n secondaryActions?: ReactNode;\n /**\n * The actions' vertical alignment against the title block — see\n * {@link PageHeaderActionsAlign}. Unset keeps the layout's own.\n *\n * keksdose's reports page (reports-page.tsx, keksdose G8) carries a LABELLED\n * `CurrencySelect` as its action: a field with its label on top is taller than the\n * title, and `stacked`'s default top alignment hangs it from the title's cap height\n * with its control well below the title's line. `center` sets it on the title's\n * middle, as that page's hand-written `items-center` row did. It aligns the whole row\n * (title block and actions against each other), since 0.14.1: 0.14.0 moved only the\n * actions box, which did nothing when the actions were the taller item (keksdose H2).\n */\n actionsAlign?: PageHeaderActionsAlign;\n /**\n * One line, cut with an ellipsis, instead of the default breaking of a long word onto\n * as many lines as it needs.\n *\n * keksdose's payees page (payees-page.tsx, live #263, keksdose G6a) wraps its title in\n * its own truncating span so a long name gives way to the `inline` header's actions\n * instead of pushing them. This is that span's behaviour on the heading itself; the\n * title block is already allowed to shrink below its content, which is what lets the\n * cut happen inside a flex row.\n *\n * The full title stays in the DOM as the heading's text, so a screen reader and the\n * document outline read all of it. There is deliberately no native `title` tooltip:\n * the kit does not use them (not on touch, not on keyboard focus, not styled).\n *\n * At Normal only: from Large up the title wraps (`truncate-until-large`, text size\n * §10.17; round 0.34 T1), as nothing is cut at Large — the actions then share the row\n * with a taller title block.\n */\n truncateTitle?: boolean;\n}\n\n/**\n * A page's head: optional breadcrumbs, an eyebrow, the title, a description, and the\n * page's actions.\n *\n * kastlan's (shared/components/display/page-header.tsx) is the model — title and\n * description on one side, actions on the other, stacked on a phone — and this adds\n * what its pages kept writing around it: a trail above, an eyebrow, a `ReactNode`\n * title (a status chip beside the name), and actions that WRAP when there are more\n * than fit a phone's width, instead of running off it. No outer margin: the page's\n * own rhythm (`space-y-*`, a grid gap) places it.\n */\nexport function PageHeader({\n title,\n description,\n actions,\n breadcrumbs,\n eyebrow,\n as = \"h1\",\n size = \"md\",\n mobileLayout = \"stacked\",\n secondaryActions,\n actionsAlign,\n truncateTitle = false,\n className,\n ...rest\n}: PageHeaderProps) {\n const Heading = as as ElementType;\n const hasSecondary = secondaryActions != null;\n const wrapsSecondary = hasSecondary && mobileLayout === \"inline\";\n const align = actionsAlign != null ? ROW_ALIGN[mobileLayout][actionsAlign] : undefined;\n // keksdose H1: in `inline` the title block could shrink below its longest word, and\n // `break-words` then split \"Monatsbudget\" into \"Monatsbudg\" / \"et\" beside the actions.\n // Unless it truncates, the title keeps its longest word (min-content) and the ROW\n // wraps instead: actions that do not fit go to a line of their own, as the app's old\n // `flex-wrap` header did.\n const keepsWords = mobileLayout === \"inline\" && !truncateTitle;\n // keksdose, after H1: once the actions have wrapped below the title, the phone's\n // header was THREE rows — title / actions / secondary — where the app's old header\n // shared the second row between the month nav and the toggles (~48px of phone\n // height). So the secondary group's full-width basis (G6b: a row of its own) is\n // dropped exactly when the actions already sit below the title; then it joins their\n // row if it fits, and wraps under them if not. While the actions share the title's\n // row, nothing changes: the secondary group never climbs onto the title's row.\n //\n // CSS cannot see \"the previous item wrapped\", so it is measured — by a\n // ResizeObserver, whose first callback runs on `observe`. It is stable: the\n // secondary group comes after the actions, so its basis cannot move them. No\n // ResizeObserver (jsdom) or no layout keeps the basis, the 0.14.0 layout.\n const measuresWrap = keepsWords && hasSecondary && actions != null;\n const titleRef = useRef<HTMLDivElement>(null);\n const actionsRef = useRef<HTMLDivElement>(null);\n const [actionsWrapped, setActionsWrapped] = useState(false);\n useLayoutEffect(() => {\n const titleBox = titleRef.current;\n const actionsBox = actionsRef.current;\n if (!measuresWrap || !titleBox || !actionsBox || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(() => {\n const t = titleBox.getBoundingClientRect();\n const a = actionsBox.getBoundingClientRect();\n // Unmeasured (display: none, no layout): keep the full-width basis.\n setActionsWrapped(t.height > 0 && a.height > 0 && a.top >= t.bottom - 1);\n });\n observer.observe(titleBox);\n observer.observe(actionsBox);\n if (titleBox.parentElement) observer.observe(titleBox.parentElement);\n return () => observer.disconnect();\n }, [measuresWrap]);\n return (\n <div {...rest} className={cn(\"flex min-w-0 flex-col gap-2\", className)}>\n {breadcrumbs}\n {/* With a second group, `inline` wraps so that group's full-width basis puts it\n on a row of its own on a phone (`stacked` is a column there already, and a\n basis would size a column item's HEIGHT); from `sm` up the row stops wrapping\n and the order utilities seat it between the title and the actions. Without\n one, nothing changes. */}\n <div\n className={cn(\n \"flex min-w-0\",\n ROW[mobileLayout],\n keepsWords ? \"flex-wrap\" : wrapsSecondary && \"flex-wrap sm:flex-nowrap\",\n align,\n )}\n >\n <div ref={titleRef} className={cn(\"flex-1\", keepsWords ? \"min-w-min\" : \"min-w-0\")}>\n {eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, \"mb-1\")}>{eyebrow}</p>}\n <Heading\n className={cn(\n truncateTitle ? \"truncate-until-large\" : \"break-words\",\n \"text-primary\",\n TITLE[size],\n )}\n >\n {title}\n </Heading>\n {description != null && <p className=\"mt-1 text-sm text-muted\">{description}</p>}\n </div>\n {actions != null && (\n <div\n ref={actionsRef}\n className={cn(\n \"flex shrink-0 flex-wrap items-center gap-2\",\n hasSecondary && \"sm:order-2\",\n mobileLayout === \"stacked\" && STACK_ACTIONS_AT_LARGE,\n )}\n >\n {actions}\n </div>\n )}\n {hasSecondary && (\n <div\n className={cn(\n \"flex flex-wrap items-center gap-2 sm:order-1 sm:shrink-0\",\n wrapsSecondary && (measuresWrap && actionsWrapped ? \"sm:basis-auto\" : \"basis-full sm:basis-auto\"),\n )}\n >\n {secondaryActions}\n </div>\n )}\n </div>\n </div>\n );\n}\n"],"mappings":";AAwOQ,SACsB,KADtB;AAxOR,SAAS,iBAAiB,QAAQ,gBAAgB;AAElD,SAAS,UAAU;AACnB,SAAS,2BAA2B;AAiBpC,MAAM,QAAwC;AAAA,EAC5C,IAAI;AAAA,EACJ,SAAS;AAAA,EACT,IAAI;AACN;AAeA,MAAM,yBACJ;AAEF,MAAM,MAA8C;AAAA,EAClD,SAAS;AAAA,EACT,QAAQ;AACV;AAgBA,MAAM,YAAoF;AAAA,EACxF,SAAS,EAAE,OAAO,kBAAkB,QAAQ,mBAAmB,KAAK,eAAe;AAAA,EACnF,QAAQ,EAAE,OAAO,eAAe,QAAQ,gBAAgB,KAAK,YAAY;AAC3E;AAgGO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,KAAK;AAAA,EACL,OAAO;AAAA,EACP,eAAe;AAAA,EACf;AAAA,EACA;AAAA,EACA,gBAAgB;AAAA,EAChB;AAAA,EACA,GAAG;AACL,GAAoB;AAClB,QAAM,UAAU;AAChB,QAAM,eAAe,oBAAoB;AACzC,QAAM,iBAAiB,gBAAgB,iBAAiB;AACxD,QAAM,QAAQ,gBAAgB,OAAO,UAAU,YAAY,EAAE,YAAY,IAAI;AAM7E,QAAM,aAAa,iBAAiB,YAAY,CAAC;AAajD,QAAM,eAAe,cAAc,gBAAgB,WAAW;AAC9D,QAAM,WAAW,OAAuB,IAAI;AAC5C,QAAM,aAAa,OAAuB,IAAI;AAC9C,QAAM,CAAC,gBAAgB,iBAAiB,IAAI,SAAS,KAAK;AAC1D,kBAAgB,MAAM;AACpB,UAAM,WAAW,SAAS;AAC1B,UAAM,aAAa,WAAW;AAC9B,QAAI,CAAC,gBAAgB,CAAC,YAAY,CAAC,cAAc,OAAO,mBAAmB,YAAa;AACxF,UAAM,WAAW,IAAI,eAAe,MAAM;AACxC,YAAM,IAAI,SAAS,sBAAsB;AACzC,YAAM,IAAI,WAAW,sBAAsB;AAE3C,wBAAkB,EAAE,SAAS,KAAK,EAAE,SAAS,KAAK,EAAE,OAAO,EAAE,SAAS,CAAC;AAAA,IACzE,CAAC;AACD,aAAS,QAAQ,QAAQ;AACzB,aAAS,QAAQ,UAAU;AAC3B,QAAI,SAAS,cAAe,UAAS,QAAQ,SAAS,aAAa;AACnE,WAAO,MAAM,SAAS,WAAW;AAAA,EACnC,GAAG,CAAC,YAAY,CAAC;AACjB,SACE,qBAAC,SAAK,GAAG,MAAM,WAAW,GAAG,+BAA+B,SAAS,GAClE;AAAA;AAAA,IAMD;AAAA,MAAC;AAAA;AAAA,QACC,WAAW;AAAA,UACT;AAAA,UACA,IAAI,YAAY;AAAA,UAChB,aAAa,cAAc,kBAAkB;AAAA,UAC7C;AAAA,QACF;AAAA,QAEA;AAAA,+BAAC,SAAI,KAAK,UAAU,WAAW,GAAG,UAAU,aAAa,cAAc,SAAS,GAC7E;AAAA,uBAAW,QAAQ,oBAAC,OAAE,WAAW,GAAG,oBAAoB,IAAI,MAAM,GAAI,mBAAQ;AAAA,YAC/E;AAAA,cAAC;AAAA;AAAA,gBACC,WAAW;AAAA,kBACT,gBAAgB,yBAAyB;AAAA,kBACzC;AAAA,kBACA,MAAM,IAAI;AAAA,gBACZ;AAAA,gBAEC;AAAA;AAAA,YACH;AAAA,YACC,eAAe,QAAQ,oBAAC,OAAE,WAAU,2BAA2B,uBAAY;AAAA,aAC9E;AAAA,UACC,WAAW,QACV;AAAA,YAAC;AAAA;AAAA,cACC,KAAK;AAAA,cACL,WAAW;AAAA,gBACT;AAAA,gBACA,gBAAgB;AAAA,gBAChB,iBAAiB,aAAa;AAAA,cAChC;AAAA,cAEC;AAAA;AAAA,UACH;AAAA,UAED,gBACC;AAAA,YAAC;AAAA;AAAA,cACC,WAAW;AAAA,gBACT;AAAA,gBACA,mBAAmB,gBAAgB,iBAAiB,kBAAkB;AAAA,cACxE;AAAA,cAEC;AAAA;AAAA,UACH;AAAA;AAAA;AAAA,IAEJ;AAAA,KACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/page-header.tsx"],"sourcesContent":["import { useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithRef, ElementType, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { SECTION_LABEL_CLASS } from \"./text\";\nimport { useDocumentTitle } from \"../hooks/use-document-title\";\nimport { PageHeaderTrailContext } from \"./page-header-context\";\n\n/**\n * `md` (default) is kastlan's `text-2xl` page title from `sm` up, a step smaller on a\n * phone; `sm` is lenkbank's `text-lg` title on a dense tool page (projects-page.tsx).\n *\n * `compact` (0.13) is keksdose's page title (keksdose F4): `text-xl font-semibold` at\n * EVERY width — budgets, rules, accounts, transactions and budget pages all write\n * exactly that `<h1>` by hand. It is `md`'s phone size without `md`'s step up to\n * `text-2xl` on a wider screen, and without its `tracking-tight`, which keksdose's\n * titles never had. A word and not a letter for the reason `ProgressBar`'s `slim` is\n * one: the letters are taken in order, and there is none between `sm` and `md` — an\n * `lg` or `xl` at `text-xl` would read as LARGER than `md`, which from `sm` up it is\n * not.\n */\nexport type PageHeaderSize = \"sm\" | \"compact\" | \"md\";\n\nconst TITLE: Record<PageHeaderSize, string> = {\n sm: \"text-lg font-semibold\",\n compact: \"text-xl font-semibold\",\n md: \"text-xl font-semibold tracking-tight sm:text-2xl\",\n};\n\n/**\n * How the title block and the actions sit below the `sm` breakpoint. From `sm` up both\n * layouts are one row: title block at the start, actions at the end.\n */\nexport type PageHeaderMobileLayout = \"stacked\" | \"inline\";\n\n/**\n * `stacked` at Large and Extra large on a phone (0.32.2, kastlan's 0.32.1 report;\n * docs/text-size-harmonization.md §4, as FormActions does): the actions take the full\n * width, one under the other. Side by side in 240 px of layout, two buttons each wrapped\n * their label onto three lines. `large:max-sm:`, the scaled phone width, so Normal and\n * every wider screen keep their row; `inline` is for actions known to fit, and stays.\n */\nconst STACK_ACTIONS_AT_LARGE =\n \"large:max-sm:flex-col large:max-sm:items-stretch large:max-sm:[&>*]:w-full\";\n\nconst ROW: Record<PageHeaderMobileLayout, string> = {\n stacked: \"flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4\",\n inline: \"flex-row items-center justify-between gap-2 sm:gap-4\",\n};\n\n/**\n * Where the actions sit on the title block's height, in the one row the header is from\n * `sm` up (and on a phone too when {@link PageHeaderMobileLayout} is `inline`, the only\n * layout that has a row there). Unset keeps each layout's own: `start` for `stacked`,\n * `center` for `inline`.\n */\nexport type PageHeaderActionsAlign = \"start\" | \"center\" | \"end\";\n\n/** Applied to the ROW, so the title block and the actions are aligned against each\n * other on one cross axis. 0.14.0 put `self-*` on the actions box alone, which moved\n * nothing when the actions were the taller item — they already filled the row, and\n * the title stayed at the top (keksdose H2, reports-page's two-line labelled\n * CurrencySelect). Per layout, because `stacked` is a column on a phone, where\n * `items-center` would centre everything HORIZONTALLY — so it aligns from `sm` up. */\nconst ROW_ALIGN: Record<PageHeaderMobileLayout, Record<PageHeaderActionsAlign, string>> = {\n stacked: { start: \"sm:items-start\", center: \"sm:items-center\", end: \"sm:items-end\" },\n inline: { start: \"items-start\", center: \"items-center\", end: \"items-end\" },\n};\n\nexport interface PageHeaderProps extends Omit<ComponentPropsWithRef<\"div\">, \"title\"> {\n title: ReactNode;\n /** A sentence under the title. */\n description?: ReactNode;\n /** Buttons for the page — \"New\", \"Export\". At the title's end from `sm` up; under it,\n * wrapping, on a phone — unless {@link mobileLayout} is `inline`. */\n actions?: ReactNode;\n /** A `Breadcrumbs` trail, drawn above everything else. Inside a header with a title\n * it hides its current entry (the title names it) and is drawn only when an entry\n * other than home is left — `Breadcrumbs.hideCurrent` (0.49 KIT-9). */\n breadcrumbs?: ReactNode;\n /**\n * The window's title (`document.title`) while the header is mounted (0.49 KIT-9),\n * through `useDocumentTitle` and the provider's format (\"{page} · {section} · {app}\",\n * `appName` as the app). A string is the page; an array is `[page, section]` (or\n * `[page, section, app]`); `false` leaves the window's title alone.\n *\n * Default: the `title` when it is a string, and only for the page's own header\n * (`as=\"h1\"`) — a tab's `h2` header does not rename the window.\n */\n documentTitle?: string | ReadonlyArray<string | null | undefined> | false;\n /** A small uppercase line above the title — the section or the kind of record\n * (\"Building\", \"Settings\"). The section-label type. */\n eyebrow?: ReactNode;\n /** The title's element. Default `h1`: a page has one, and this is it. `h2` for a\n * header inside a page that already has its `h1` (a tab's own header). */\n as?: \"h1\" | \"h2\" | \"h3\";\n size?: PageHeaderSize;\n /**\n * `stacked` (default): on a phone the actions drop under the title and wrap, so a\n * row of three buttons never pushes the title off the screen.\n *\n * `inline`: the actions stay beside the title on a phone too, vertically centred on\n * it — keksdose's headers (transactions-page.tsx:515, `flex items-center\n * justify-between gap-2`), which carry one or two icon-sized controls and were tuned\n * for the phone in live #263. Adopting `PageHeader` must not undo that tuning, so the\n * row stays a row (keksdose F4). The actions do NOT wrap or shrink here: `inline` is\n * for a header whose actions are known to fit beside a phone-width title (an icon\n * button, a month picker); a long title wraps to make room for them instead.\n */\n mobileLayout?: PageHeaderMobileLayout;\n /**\n * A second group of actions that gets its OWN full-width row under the header on a\n * phone, and joins the actions' row from `sm` up, between the title and\n * {@link actions}.\n *\n * keksdose's budget page (budget-page.tsx, feedback #60) is the case: title and month\n * navigation on row 1, the fold/unfold toggles on row 2 on a phone — \"two tidy rows\n * instead of the old ragged justify-between overflow\" — and one row, title | toggles |\n * month nav, on a wider screen. It builds that by hand with a wrapping flex row and\n * swapped order utilities; adopting `PageHeader` (keksdose G6b) must not lose it.\n * There: `actions` is the month navigation, this is the toggles.\n *\n * The DOM order is title, `actions`, `secondaryActions` — the phone's visual order,\n * and the one a screen reader and the Tab key follow at every width. From `sm` up the\n * two groups swap places visually only, which keeps the page's primary control (the\n * month) at the row's end, where keksdose had it, and first in reading order. The\n * breakpoint is `sm`, the one {@link mobileLayout} switches at, not keksdose's `md`:\n * one header, one breakpoint.\n */\n secondaryActions?: ReactNode;\n /**\n * The actions' vertical alignment against the title block — see\n * {@link PageHeaderActionsAlign}. Unset keeps the layout's own.\n *\n * keksdose's reports page (reports-page.tsx, keksdose G8) carries a LABELLED\n * `CurrencySelect` as its action: a field with its label on top is taller than the\n * title, and `stacked`'s default top alignment hangs it from the title's cap height\n * with its control well below the title's line. `center` sets it on the title's\n * middle, as that page's hand-written `items-center` row did. It aligns the whole row\n * (title block and actions against each other), since 0.14.1: 0.14.0 moved only the\n * actions box, which did nothing when the actions were the taller item (keksdose H2).\n */\n actionsAlign?: PageHeaderActionsAlign;\n /**\n * One line, cut with an ellipsis, instead of the default breaking of a long word onto\n * as many lines as it needs.\n *\n * keksdose's payees page (payees-page.tsx, live #263, keksdose G6a) wraps its title in\n * its own truncating span so a long name gives way to the `inline` header's actions\n * instead of pushing them. This is that span's behaviour on the heading itself; the\n * title block is already allowed to shrink below its content, which is what lets the\n * cut happen inside a flex row.\n *\n * The full title stays in the DOM as the heading's text, so a screen reader and the\n * document outline read all of it. There is deliberately no native `title` tooltip:\n * the kit does not use them (not on touch, not on keyboard focus, not styled).\n *\n * At Normal only: from Large up the title wraps (`truncate-until-large`, text size\n * §10.17; round 0.34 T1), as nothing is cut at Large — the actions then share the row\n * with a taller title block.\n */\n truncateTitle?: boolean;\n}\n\nconst TITLED = { titled: true };\nconst UNTITLED = { titled: false };\n\n/**\n * A page's head: optional breadcrumbs, an eyebrow, the title, a description, and the\n * page's actions.\n *\n * kastlan's (shared/components/display/page-header.tsx) is the model — title and\n * description on one side, actions on the other, stacked on a phone — and this adds\n * what its pages kept writing around it: a trail above, an eyebrow, a `ReactNode`\n * title (a status chip beside the name), and actions that WRAP when there are more\n * than fit a phone's width, instead of running off it. No outer margin: the page's\n * own rhythm (`space-y-*`, a grid gap) places it.\n */\nexport function PageHeader({\n title,\n description,\n actions,\n breadcrumbs,\n eyebrow,\n as = \"h1\",\n size = \"md\",\n mobileLayout = \"stacked\",\n secondaryActions,\n actionsAlign,\n truncateTitle = false,\n documentTitle,\n className,\n ...rest\n}: PageHeaderProps) {\n const Heading = as as ElementType;\n useDocumentTitle(\n documentTitle === false\n ? []\n : documentTitle === undefined\n ? as === \"h1\" && typeof title === \"string\"\n ? [title]\n : []\n : typeof documentTitle === \"string\"\n ? [documentTitle]\n : documentTitle,\n );\n const titled = title != null && title !== false && title !== \"\";\n const hasSecondary = secondaryActions != null;\n const wrapsSecondary = hasSecondary && mobileLayout === \"inline\";\n const align = actionsAlign != null ? ROW_ALIGN[mobileLayout][actionsAlign] : undefined;\n // keksdose H1: in `inline` the title block could shrink below its longest word, and\n // `break-words` then split \"Monatsbudget\" into \"Monatsbudg\" / \"et\" beside the actions.\n // Unless it truncates, the title keeps its longest word (min-content) and the ROW\n // wraps instead: actions that do not fit go to a line of their own, as the app's old\n // `flex-wrap` header did.\n const keepsWords = mobileLayout === \"inline\" && !truncateTitle;\n // keksdose, after H1: once the actions have wrapped below the title, the phone's\n // header was THREE rows — title / actions / secondary — where the app's old header\n // shared the second row between the month nav and the toggles (~48px of phone\n // height). So the secondary group's full-width basis (G6b: a row of its own) is\n // dropped exactly when the actions already sit below the title; then it joins their\n // row if it fits, and wraps under them if not. While the actions share the title's\n // row, nothing changes: the secondary group never climbs onto the title's row.\n //\n // CSS cannot see \"the previous item wrapped\", so it is measured — by a\n // ResizeObserver, whose first callback runs on `observe`. It is stable: the\n // secondary group comes after the actions, so its basis cannot move them. No\n // ResizeObserver (jsdom) or no layout keeps the basis, the 0.14.0 layout.\n const measuresWrap = keepsWords && hasSecondary && actions != null;\n const titleRef = useRef<HTMLDivElement>(null);\n const actionsRef = useRef<HTMLDivElement>(null);\n const [actionsWrapped, setActionsWrapped] = useState(false);\n useLayoutEffect(() => {\n const titleBox = titleRef.current;\n const actionsBox = actionsRef.current;\n if (!measuresWrap || !titleBox || !actionsBox || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(() => {\n const t = titleBox.getBoundingClientRect();\n const a = actionsBox.getBoundingClientRect();\n // Unmeasured (display: none, no layout): keep the full-width basis.\n setActionsWrapped(t.height > 0 && a.height > 0 && a.top >= t.bottom - 1);\n });\n observer.observe(titleBox);\n observer.observe(actionsBox);\n if (titleBox.parentElement) observer.observe(titleBox.parentElement);\n return () => observer.disconnect();\n }, [measuresWrap]);\n return (\n <div {...rest} className={cn(\"flex min-w-0 flex-col gap-2\", className)}>\n {breadcrumbs != null && (\n <PageHeaderTrailContext.Provider value={titled ? TITLED : UNTITLED}>{breadcrumbs}</PageHeaderTrailContext.Provider>\n )}\n {/* With a second group, `inline` wraps so that group's full-width basis puts it\n on a row of its own on a phone (`stacked` is a column there already, and a\n basis would size a column item's HEIGHT); from `sm` up the row stops wrapping\n and the order utilities seat it between the title and the actions. Without\n one, nothing changes. */}\n <div\n className={cn(\n \"flex min-w-0\",\n ROW[mobileLayout],\n keepsWords ? \"flex-wrap\" : wrapsSecondary && \"flex-wrap sm:flex-nowrap\",\n align,\n )}\n >\n <div ref={titleRef} className={cn(\"flex-1\", keepsWords ? \"min-w-min\" : \"min-w-0\")}>\n {eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, \"mb-1\")}>{eyebrow}</p>}\n <Heading\n className={cn(\n truncateTitle ? \"truncate-until-large\" : \"break-words\",\n \"text-primary\",\n TITLE[size],\n )}\n >\n {title}\n </Heading>\n {description != null && <p className=\"mt-1 text-sm text-muted\">{description}</p>}\n </div>\n {actions != null && (\n <div\n ref={actionsRef}\n className={cn(\n \"flex shrink-0 flex-wrap items-center gap-2\",\n hasSecondary && \"sm:order-2\",\n mobileLayout === \"stacked\" && STACK_ACTIONS_AT_LARGE,\n )}\n >\n {actions}\n </div>\n )}\n {hasSecondary && (\n <div\n className={cn(\n \"flex flex-wrap items-center gap-2 sm:order-1 sm:shrink-0\",\n wrapsSecondary && (measuresWrap && actionsWrapped ? \"sm:basis-auto\" : \"basis-full sm:basis-auto\"),\n )}\n >\n {secondaryActions}\n </div>\n )}\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -11,6 +11,7 @@ import { DEFAULT_PIE_CHART_LABELS } from "./pie-chart-labels.js";
11
11
  import { StaticLegend, ToggleLegend, toggleHidden } from "./toggle-legend.js";
12
12
  import { useTextSize } from "../theme/text-size.js";
13
13
  import { chartHeightProps } from "./chart-height.js";
14
+ import { formatWithMinus } from "../lib/minus.js";
14
15
  const PIE_CONFIG = {};
15
16
  const RADIAN = Math.PI / 180;
16
17
  function RedactedTooltipContent(props) {
@@ -51,7 +52,7 @@ function PieChart({
51
52
  [locale]
52
53
  );
53
54
  const formatValue = useCallback(
54
- (v) => formatValueProp ? formatValueProp(v) : numberFormat.format(v),
55
+ (v) => formatValueProp ? formatValueProp(v) : formatWithMinus(numberFormat, v),
55
56
  [formatValueProp, numberFormat]
56
57
  );
57
58
  const formatShare = useCallback(
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/pie-chart.tsx"],"sourcesContent":["// A part-to-whole chart: a donut (or a full pie), one slice per category, its ANGLE\n// the category's share of what is drawn. Lifted out of kastlan, where the dashboard's\n// occupancy card and the reports' maintenance card each drew a recharts `<Pie>` by\n// hand inside the kit's `ChartContainer` — with a legend that could not switch\n// anything, slices a keyboard could not reach, and colours from a local hex list.\n//\n// It uses the kit's own chart shell (`ChartContainer`, `ChartTooltipContent`) and\n// ramp (`paletteFor`), so it re-skins with the palette like every other chart, and the\n// kit's own legends (`ToggleLegend`, `StaticLegend`).\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { ComponentProps, ComponentPropsWithRef, KeyboardEvent, ReactNode } from \"react\";\nimport { Pie, PieChart as RechartsPieChart, Sector } from \"recharts\";\nimport type { PieLabelRenderProps, PieSectorShapeProps } from \"recharts\";\nimport { cn } from \"../lib/cn\";\nimport { horizontalStep } from \"../lib/direction\";\nimport { useKitLabels, useKitLocale, type LabelOverride } from \"../i18n/kit-labels\";\nimport { paletteFor } from \"../theme/chart-palette\";\nimport { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from \"./chart\";\nimport { DEFAULT_PIE_CHART_LABELS, type PieChartLabels } from \"./pie-chart-labels\";\nimport { StaticLegend, ToggleLegend, toggleHidden, type LegendEntry } from \"./toggle-legend\";\nimport { useTextSize } from \"../theme/text-size\";\nimport { chartHeightProps, type ChartHeight } from \"./chart-height\";\n\n/** One slice. */\nexport interface PieChartSlice {\n /** Stable key, handed back by {@link PieChartProps.onSliceClick} and used by the\n * legend's hidden set. Labels can collide; keys must not. */\n key: string;\n /** What the slice is called — in the legend, the tooltip and its accessible name. */\n label: string;\n /**\n * The slice's size. A value that is not a positive finite number — `null` for a\n * figure that is missing, a zero, a negative — is NOT drawn and not in the legend:\n * an angle cannot be negative, and a zero-degree slice is one a pointer and a\n * keyboard would both stop on to find nothing.\n */\n value: number | null | undefined;\n /** Overrides the by-index palette colour (`paletteFor(i)`, `i` the slice's index in\n * `data` AS GIVEN) — a status that has a colour of its own. Any CSS colour. */\n color?: string;\n}\n\n/**\n * Everything a `<div>` takes reaches the chart's root, so a test id or a `data-tour`\n * anchor can find it. `children` is the chart's own.\n */\nexport interface PieChartProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\"> {\n data: readonly PieChartSlice[];\n /** `\"donut\"` (default) leaves a hole with the total in it; `\"pie\"` is the full disc. */\n variant?: \"donut\" | \"pie\";\n /**\n * Height of the plot (the legend is under it): a CSS length (`\"20rem\"`,\n * `\"min(20rem, 50dvh)\"`), a Tailwind class, or px. Default `\"18.75rem\"` — 300 px at\n * Normal, growing with the text size so the slice labels keep their room (0.32,\n * docs/text-size-harmonization.md §10.10). A number does not follow the text size.\n */\n height?: ChartHeight;\n /** How a value is written in the tooltip, the slice's name, a `\"value\"` slice label\n * and the donut's total. Default: `Intl.NumberFormat` in `locale`. */\n formatValue?: (value: number) => string;\n /**\n * What is written beside each slice: its share (`\"percent\"`, the default), its\n * value, or nothing. Only on slices of at least {@link minLabelShare} — a label on a\n * sliver sits on its neighbour's. The figure is in the tooltip and the slice's name\n * either way.\n */\n sliceLabels?: \"percent\" | \"value\" | \"none\";\n /** The smallest share (0–1) that gets a slice label. Default 0.04. */\n minLabelShare?: number;\n /** Draw the donut's centre: {@link centerLabel} over {@link centerValue}. Default\n * `true`; ignored for `variant=\"pie\"`, which has no centre to draw in. */\n center?: boolean;\n /** The caption in the donut's centre. Default: `labels.total`. */\n centerLabel?: ReactNode;\n /** The figure in the donut's centre. Default: the drawn total through `formatValue`. */\n centerValue?: ReactNode;\n /**\n * The legend under the chart. `\"toggle\"` (default): each entry is a switch that\n * takes its slice off the chart, and the percentages are then of what is left —\n * the shares always read \"of this picture\". `\"static\"`: a key with nothing to press.\n */\n legend?: \"toggle\" | \"static\" | \"none\";\n /** The slices switched off, for a caller that owns the set (a URL, a saved view).\n * Left out, the chart keeps its own. */\n hiddenSlices?: ReadonlySet<string>;\n /** Called with the new hidden set whenever the legend switches a slice. */\n onHiddenSlicesChange?: (hidden: ReadonlySet<string>) => void;\n /** A slice was activated — by click, or Enter/Space on a focused slice. Makes each\n * slice a `role=\"button\"`. */\n onSliceClick?: (key: string, slice: PieChartSlice) => void;\n /**\n * Tag every figure ON SCREEN `data-private` — the tooltip bubble, the centre and\n * `\"value\"` slice labels — so the host's demo-mode rule blurs it. A percentage alone\n * reveals no amount, so `\"percent\"` labels stay readable, and the slices themselves\n * are not tagged: a blurred ring is no picture at all.\n */\n redact?: boolean;\n /** What to show with nothing to draw. Default: `labels.empty`. */\n empty?: ReactNode;\n /** BCP 47 tag for the numbers and percentages. Default: the provider's. */\n locale?: string;\n labels?: LabelOverride<PieChartLabels>;\n}\n\n/** A slice as drawn: only the ones with a positive value, in `data` order. */\ninterface DrawnSlice {\n slice: PieChartSlice;\n value: number;\n color: string;\n /** Of what is drawn, 0–1. */\n share: number;\n}\n\n// Slices carry their own `fill`, and `ChartTooltipContent` falls back to it for the\n// swatch, so no series config is needed — and none is wanted, because a config key is a\n// CSS identifier and a slice key is the caller's data (see `ChartStyle`).\nconst PIE_CONFIG: ChartConfig = {};\n\nconst RADIAN = Math.PI / 180;\n\n/** The shared tooltip with the bubble tagged `data-private` — see the treemap's\n * `RedactedTooltipContent` for why it has to BE the element recharts clones. */\nfunction RedactedTooltipContent(props: ComponentProps<typeof ChartTooltipContent>) {\n return (\n <div data-private>\n <ChartTooltipContent {...props} />\n </div>\n );\n}\n\n/**\n * Part-to-whole chart: a donut (or `variant=\"pie\"`), one slice per\n * {@link PieChartSlice}, coloured from the chart ramp by index.\n *\n * **Every slice is reachable from the keyboard.** The slices are ONE tab stop (a roving\n * tabindex, as in the kit's grids): ←/→ and ↑/↓ step to the next and previous slice —\n * clockwise from twelve o'clock, the order of the legend — with ←/→ following the\n * reading direction in RTL; Home/End go to the first and last. Each slice is named\n * \"Label: value (xx%)\" (`labels.slice`), because a colour and an angle are nothing a\n * screen reader can say, and focusing one reads it out in the donut's centre. With\n * `onSliceClick`, Enter and Space activate it as a click does.\n *\n * The legend switches slices off (`legend=\"toggle\"`); the percentages are always of\n * what is drawn. The plot is not mirrored in RTL (see `ChartContainer`); the legend\n * and the centre follow the page.\n *\n * With nothing to draw it says so (`empty`) rather than drawing an empty box.\n *\n * Needs `recharts`, like everything behind `@eifi1/ui-kit/chart`.\n */\nexport function PieChart({\n data,\n variant = \"donut\",\n height = \"18.75rem\",\n formatValue: formatValueProp,\n sliceLabels = \"percent\",\n minLabelShare = 0.04,\n center = true,\n centerLabel,\n centerValue,\n legend = \"toggle\",\n hiddenSlices: hiddenProp,\n onHiddenSlicesChange,\n onSliceClick,\n redact = false,\n empty,\n locale: localeProp,\n labels: labelsProp,\n className,\n style,\n ...rest\n}: PieChartProps) {\n const labels = useKitLabels(\"pieChart\", DEFAULT_PIE_CHART_LABELS, labelsProp);\n const locale = useKitLocale(localeProp);\n // The slice labels are SVG text in px: they follow the text size by this (§10.10).\n const { scale } = useTextSize();\n const box = chartHeightProps(height);\n const numberFormat = useMemo(() => new Intl.NumberFormat(locale), [locale]);\n const percentFormats = useMemo(\n () => ({\n whole: new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 0 }),\n fine: new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 1 }),\n }),\n [locale],\n );\n // Memoised, like everything the sectors are drawn from: they must not be rebuilt on\n // this component's own re-renders (see `pie` below).\n const formatValue = useCallback(\n (v: number) => (formatValueProp ? formatValueProp(v) : numberFormat.format(v)),\n [formatValueProp, numberFormat],\n );\n // One decimal under 10 %, where rounding to whole percents starts to lie — a slice\n // of 0.4 % labelled \"0%\" says it is not there.\n const formatShare = useCallback(\n (share: number) => (share < 0.1 ? percentFormats.fine : percentFormats.whole).format(share),\n [percentFormats],\n );\n\n const [ownHidden, setOwnHidden] = useState<ReadonlySet<string>>(() => new Set());\n const hidden = hiddenProp ?? ownHidden;\n const toggle = (key: string) => {\n const next = toggleHidden(hidden, key);\n if (hiddenProp === undefined) setOwnHidden(next);\n onHiddenSlicesChange?.(next);\n };\n\n // Coloured by index into `data` AS GIVEN, before anything is dropped, so a slice\n // keeps its colour when a neighbour is switched off or has no value.\n const { drawable, drawn, total, pieData } = useMemo(() => {\n const drawable = data\n .map((slice, i) => ({ slice, value: slice.value, color: slice.color ?? paletteFor(i) }))\n .filter((d): d is { slice: PieChartSlice; value: number; color: string } =>\n typeof d.value === \"number\" && Number.isFinite(d.value) && d.value > 0,\n );\n const shown = drawable.filter((d) => !hidden.has(d.slice.key));\n const total = shown.reduce((acc, d) => acc + d.value, 0);\n const drawn: DrawnSlice[] = shown.map((d) => ({ ...d, share: total > 0 ? d.value / total : 0 }));\n const pieData = drawn.map((d) => ({\n key: d.slice.key,\n label: d.slice.label,\n value: d.value,\n fill: d.color,\n }));\n return { drawable, drawn, total, pieData };\n }, [data, hidden]);\n\n // The roving tab stop, and the slice that has focus (which the centre reads out).\n const [stop, setStop] = useState(0);\n const [focused, setFocused] = useState<number | undefined>(undefined);\n const current = Math.min(stop, Math.max(0, drawn.length - 1));\n const refs = useRef<(SVGGElement | null)[]>([]);\n const focusedSlice = focused !== undefined ? drawn[focused] : undefined;\n\n // Read through a ref: the sectors are drawn once per `data`, and a caller's inline\n // `onSliceClick` is a new function every render.\n const clickRef = useRef(onSliceClick);\n useLayoutEffect(() => {\n clickRef.current = onSliceClick;\n });\n const clickable = onSliceClick !== undefined;\n\n /**\n * One sector, wrapped in its keyboard stop. recharts renders it inside a layer of its\n * own that carries the pointer's hover and click, so those are untouched.\n *\n * Focus does NOT drive the recharts tooltip (`defaultIndex`), as it does in\n * `SeriesChart`: an active pie sector is re-parented into an extra layer, which\n * remounts this element and drops the focus it just took. The donut's centre is the\n * keyboard's read-out instead.\n */\n const shape = useCallback(\n (props: PieSectorShapeProps) => {\n const {\n index,\n isActive: _isActive,\n tabIndex: _tabIndex,\n animationElapsedTime: _elapsed,\n isAnimating: _animating,\n isEntrance: _entrance,\n // recharts hands the shape its own React `key` among the props. Spread into\n // JSX, a `key` inside a props object is a warning on every render (React 19),\n // so it is lifted out here and passed on its own.\n key,\n ...sector\n } = props as PieSectorShapeProps & {\n animationElapsedTime?: number;\n isAnimating?: boolean;\n isEntrance?: boolean;\n key?: string | number | null;\n };\n const d = drawn[index];\n if (!d) return <Sector key={key ?? undefined} {...sector} />;\n const onKeyDown = (event: KeyboardEvent<SVGGElement>) => {\n const step = horizontalStep(event.key, event.currentTarget);\n let target: number | undefined;\n if (step) target = index + step;\n else if (event.key === \"ArrowDown\") target = index + 1;\n else if (event.key === \"ArrowUp\") target = index - 1;\n else if (event.key === \"Home\") target = 0;\n else if (event.key === \"End\") target = drawn.length - 1;\n if (target !== undefined) {\n event.preventDefault();\n // Round the circle: past the last slice is the first, as the eye goes.\n refs.current[((target % drawn.length) + drawn.length) % drawn.length]?.focus();\n } else if (clickable && (event.key === \"Enter\" || event.key === \" \")) {\n event.preventDefault();\n clickRef.current?.(d.slice.key, d.slice);\n }\n };\n return (\n <g\n ref={(node) => {\n refs.current[index] = node;\n }}\n role={clickable ? \"button\" : \"img\"}\n // The first slice until the effect below moves the stop.\n tabIndex={index === 0 ? 0 : -1}\n aria-label={labels.slice(d.slice.label, formatValue(d.value), formatShare(d.share))}\n data-slice={d.slice.key}\n onKeyDown={onKeyDown}\n onFocus={() => {\n setStop(index);\n setFocused(index);\n }}\n onBlur={() => setFocused(undefined)}\n // CSS beats a presentation attribute, so this recolours the sector's own\n // stroke for as long as it has keyboard focus.\n className=\"outline-none [&:focus-visible>path]:stroke-primary\"\n >\n <Sector key={key ?? undefined} {...sector} />\n </g>\n );\n },\n [drawn, labels, formatValue, formatShare, clickable],\n );\n\n const label = useCallback(\n (props: PieLabelRenderProps) => {\n const d = drawn[props.index ?? -1];\n const cx = Number(props.cx);\n const cy = Number(props.cy);\n const radius = Number(props.outerRadius);\n const mid = Number(props.midAngle);\n if (!d || d.share < minLabelShare || ![cx, cy, radius, mid].every(Number.isFinite)) return null;\n const r = radius + 10 * scale;\n const x = cx + r * Math.cos(-mid * RADIAN);\n const y = cy + r * Math.sin(-mid * RADIAN);\n const isValue = sliceLabels === \"value\";\n return (\n <text\n x={x}\n y={y}\n textAnchor={x >= cx ? \"start\" : \"end\"}\n dominantBaseline=\"central\"\n fontSize={11 * scale}\n fill=\"var(--text-secondary)\"\n data-private={redact && isValue ? \"\" : undefined}\n style={{ pointerEvents: \"none\" }}\n >\n {isValue ? formatValue(d.value) : formatShare(d.share)}\n </text>\n );\n },\n [drawn, minLabelShare, sliceLabels, redact, formatValue, formatShare, scale],\n );\n\n const withLabels = sliceLabels !== \"none\";\n // Room outside the ring for the labels, or the ring fills the box.\n const outer = withLabels ? 72 : 88;\n const inner = variant === \"donut\" ? Math.round(outer * 0.6) : 0;\n\n /**\n * The `<Pie>`, as ONE element for as long as what it draws is unchanged. recharts\n * keys its sectors by an animation id that is renewed whenever the Pie's props object\n * is — i.e. on every render of the Pie — so re-rendering it remounts every sector,\n * the one with focus included, which dropped the focus to <body> on the very state\n * update that focusing a slice makes. An unchanged element is one React does not\n * re-render, so the sectors stay put, and the roving tab stop is moved on them in\n * place (the effect below).\n */\n const pie = useMemo(\n () => (\n <Pie\n data={pieData}\n dataKey=\"value\"\n nameKey=\"label\"\n cx=\"50%\"\n cy=\"50%\"\n outerRadius={`${outer}%`}\n innerRadius={`${inner}%`}\n startAngle={90}\n endAngle={-270}\n // The card showing through between the slices, as the treemap's gutter.\n stroke=\"var(--bg-surface)\"\n strokeWidth={2}\n rootTabIndex={-1}\n isAnimationActive={false}\n shape={shape}\n label={withLabels ? label : false}\n labelLine={false}\n onClick={\n clickable\n ? (_entry: unknown, index: number) => {\n const d = drawn[index];\n if (d) clickRef.current?.(d.slice.key, d.slice);\n }\n : undefined\n }\n />\n ),\n [pieData, drawn, shape, label, withLabels, outer, inner, clickable],\n );\n\n // The roving tab stop, moved on the sectors in place (they are not re-rendered).\n useEffect(() => {\n refs.current.forEach((el, i) => el?.setAttribute(\"tabindex\", i === current ? \"0\" : \"-1\"));\n });\n\n const legendEntries: LegendEntry[] = drawable.map((d) => ({\n key: d.slice.key,\n label: d.slice.label,\n color: d.color,\n }));\n\n const emptyBox = (\n <div\n className={cn(\"flex items-center justify-center text-sm text-muted\", box.className)}\n style={box.style}\n >\n {empty ?? labels.empty}\n </div>\n );\n\n // Nothing drawable at all: no chart, and no legend of switches for nothing.\n if (drawable.length === 0) {\n return (\n <div {...rest} className={cn(\"w-full\", className)} style={style}>\n {emptyBox}\n </div>\n );\n }\n\n const Content = redact ? RedactedTooltipContent : ChartTooltipContent;\n\n return (\n <div {...rest} className={cn(\"w-full\", className)} style={style}>\n {drawn.length === 0 ? (\n // Every slice switched off: the legend below is how they come back.\n emptyBox\n ) : (\n <div role=\"group\" aria-label={labels.slices} className={cn(\"relative\", box.className)} style={box.style}>\n <ChartContainer\n config={PIE_CONFIG}\n className={cn(\"h-full\", clickable && \"[&_.recharts-pie-sector]:cursor-pointer\")}\n >\n <RechartsPieChart accessibilityLayer={false}>\n <ChartTooltip\n content={\n <Content\n hideLabel\n valueFormatter={(v) =>\n `${formatValue(v)} (${formatShare(total > 0 ? v / total : 0)})`\n }\n />\n }\n />\n {pie}\n </RechartsPieChart>\n </ChartContainer>\n {variant === \"donut\" && center && (\n <div\n data-private={redact ? \"\" : undefined}\n // While a slice has keyboard focus the centre reads it out — the\n // keyboard's tooltip. Hidden from assistive technology then: the slice's\n // own name has just said the same.\n aria-hidden={focusedSlice ? true : undefined}\n className=\"pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center\"\n >\n {/* The label wraps from Large up, inside the hole's 40 % (text size\n §10.17, round 0.34 T1: the sweep read \"Spent in Sept…\"). */}\n <span className=\"max-w-[40%] truncate-until-large text-xs text-muted\">\n {focusedSlice ? focusedSlice.slice.label : (centerLabel ?? labels.total)}\n </span>\n <span className=\"max-w-[40%] truncate text-lg font-semibold tabular-nums text-primary\">\n {focusedSlice ? formatValue(focusedSlice.value) : (centerValue ?? formatValue(total))}\n </span>\n {focusedSlice && (\n <span className=\"text-xs tabular-nums text-muted\">\n {formatShare(focusedSlice.share)}\n </span>\n )}\n </div>\n )}\n </div>\n )}\n {legend === \"toggle\" && (\n <ToggleLegend\n entries={legendEntries}\n hidden={hidden}\n onToggle={toggle}\n showSingle\n className=\"justify-center\"\n labels={{ legend: labels.legend }}\n />\n )}\n {legend === \"static\" && (\n <StaticLegend entries={legendEntries} className=\"justify-center\" aria-label={labels.legend} />\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["drawable","total","drawn","pieData"]}
1
+ {"version":3,"sources":["../../src/components/pie-chart.tsx"],"sourcesContent":["// A part-to-whole chart: a donut (or a full pie), one slice per category, its ANGLE\n// the category's share of what is drawn. Lifted out of kastlan, where the dashboard's\n// occupancy card and the reports' maintenance card each drew a recharts `<Pie>` by\n// hand inside the kit's `ChartContainer` — with a legend that could not switch\n// anything, slices a keyboard could not reach, and colours from a local hex list.\n//\n// It uses the kit's own chart shell (`ChartContainer`, `ChartTooltipContent`) and\n// ramp (`paletteFor`), so it re-skins with the palette like every other chart, and the\n// kit's own legends (`ToggleLegend`, `StaticLegend`).\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { ComponentProps, ComponentPropsWithRef, KeyboardEvent, ReactNode } from \"react\";\nimport { Pie, PieChart as RechartsPieChart, Sector } from \"recharts\";\nimport type { PieLabelRenderProps, PieSectorShapeProps } from \"recharts\";\nimport { cn } from \"../lib/cn\";\nimport { horizontalStep } from \"../lib/direction\";\nimport { useKitLabels, useKitLocale, type LabelOverride } from \"../i18n/kit-labels\";\nimport { paletteFor } from \"../theme/chart-palette\";\nimport { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from \"./chart\";\nimport { DEFAULT_PIE_CHART_LABELS, type PieChartLabels } from \"./pie-chart-labels\";\nimport { StaticLegend, ToggleLegend, toggleHidden, type LegendEntry } from \"./toggle-legend\";\nimport { useTextSize } from \"../theme/text-size\";\nimport { chartHeightProps, type ChartHeight } from \"./chart-height\";\nimport { formatWithMinus } from \"../lib/minus\";\n\n/** One slice. */\nexport interface PieChartSlice {\n /** Stable key, handed back by {@link PieChartProps.onSliceClick} and used by the\n * legend's hidden set. Labels can collide; keys must not. */\n key: string;\n /** What the slice is called — in the legend, the tooltip and its accessible name. */\n label: string;\n /**\n * The slice's size. A value that is not a positive finite number — `null` for a\n * figure that is missing, a zero, a negative — is NOT drawn and not in the legend:\n * an angle cannot be negative, and a zero-degree slice is one a pointer and a\n * keyboard would both stop on to find nothing.\n */\n value: number | null | undefined;\n /** Overrides the by-index palette colour (`paletteFor(i)`, `i` the slice's index in\n * `data` AS GIVEN) — a status that has a colour of its own. Any CSS colour. */\n color?: string;\n}\n\n/**\n * Everything a `<div>` takes reaches the chart's root, so a test id or a `data-tour`\n * anchor can find it. `children` is the chart's own.\n */\nexport interface PieChartProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\"> {\n data: readonly PieChartSlice[];\n /** `\"donut\"` (default) leaves a hole with the total in it; `\"pie\"` is the full disc. */\n variant?: \"donut\" | \"pie\";\n /**\n * Height of the plot (the legend is under it): a CSS length (`\"20rem\"`,\n * `\"min(20rem, 50dvh)\"`), a Tailwind class, or px. Default `\"18.75rem\"` — 300 px at\n * Normal, growing with the text size so the slice labels keep their room (0.32,\n * docs/text-size-harmonization.md §10.10). A number does not follow the text size.\n */\n height?: ChartHeight;\n /** How a value is written in the tooltip, the slice's name, a `\"value\"` slice label\n * and the donut's total. Default: `Intl.NumberFormat` in `locale`. */\n formatValue?: (value: number) => string;\n /**\n * What is written beside each slice: its share (`\"percent\"`, the default), its\n * value, or nothing. Only on slices of at least {@link minLabelShare} — a label on a\n * sliver sits on its neighbour's. The figure is in the tooltip and the slice's name\n * either way.\n */\n sliceLabels?: \"percent\" | \"value\" | \"none\";\n /** The smallest share (0–1) that gets a slice label. Default 0.04. */\n minLabelShare?: number;\n /** Draw the donut's centre: {@link centerLabel} over {@link centerValue}. Default\n * `true`; ignored for `variant=\"pie\"`, which has no centre to draw in. */\n center?: boolean;\n /** The caption in the donut's centre. Default: `labels.total`. */\n centerLabel?: ReactNode;\n /** The figure in the donut's centre. Default: the drawn total through `formatValue`. */\n centerValue?: ReactNode;\n /**\n * The legend under the chart. `\"toggle\"` (default): each entry is a switch that\n * takes its slice off the chart, and the percentages are then of what is left —\n * the shares always read \"of this picture\". `\"static\"`: a key with nothing to press.\n */\n legend?: \"toggle\" | \"static\" | \"none\";\n /** The slices switched off, for a caller that owns the set (a URL, a saved view).\n * Left out, the chart keeps its own. */\n hiddenSlices?: ReadonlySet<string>;\n /** Called with the new hidden set whenever the legend switches a slice. */\n onHiddenSlicesChange?: (hidden: ReadonlySet<string>) => void;\n /** A slice was activated — by click, or Enter/Space on a focused slice. Makes each\n * slice a `role=\"button\"`. */\n onSliceClick?: (key: string, slice: PieChartSlice) => void;\n /**\n * Tag every figure ON SCREEN `data-private` — the tooltip bubble, the centre and\n * `\"value\"` slice labels — so the host's demo-mode rule blurs it. A percentage alone\n * reveals no amount, so `\"percent\"` labels stay readable, and the slices themselves\n * are not tagged: a blurred ring is no picture at all.\n */\n redact?: boolean;\n /** What to show with nothing to draw. Default: `labels.empty`. */\n empty?: ReactNode;\n /** BCP 47 tag for the numbers and percentages. Default: the provider's. */\n locale?: string;\n labels?: LabelOverride<PieChartLabels>;\n}\n\n/** A slice as drawn: only the ones with a positive value, in `data` order. */\ninterface DrawnSlice {\n slice: PieChartSlice;\n value: number;\n color: string;\n /** Of what is drawn, 0–1. */\n share: number;\n}\n\n// Slices carry their own `fill`, and `ChartTooltipContent` falls back to it for the\n// swatch, so no series config is needed — and none is wanted, because a config key is a\n// CSS identifier and a slice key is the caller's data (see `ChartStyle`).\nconst PIE_CONFIG: ChartConfig = {};\n\nconst RADIAN = Math.PI / 180;\n\n/** The shared tooltip with the bubble tagged `data-private` — see the treemap's\n * `RedactedTooltipContent` for why it has to BE the element recharts clones. */\nfunction RedactedTooltipContent(props: ComponentProps<typeof ChartTooltipContent>) {\n return (\n <div data-private>\n <ChartTooltipContent {...props} />\n </div>\n );\n}\n\n/**\n * Part-to-whole chart: a donut (or `variant=\"pie\"`), one slice per\n * {@link PieChartSlice}, coloured from the chart ramp by index.\n *\n * **Every slice is reachable from the keyboard.** The slices are ONE tab stop (a roving\n * tabindex, as in the kit's grids): ←/→ and ↑/↓ step to the next and previous slice —\n * clockwise from twelve o'clock, the order of the legend — with ←/→ following the\n * reading direction in RTL; Home/End go to the first and last. Each slice is named\n * \"Label: value (xx%)\" (`labels.slice`), because a colour and an angle are nothing a\n * screen reader can say, and focusing one reads it out in the donut's centre. With\n * `onSliceClick`, Enter and Space activate it as a click does.\n *\n * The legend switches slices off (`legend=\"toggle\"`); the percentages are always of\n * what is drawn. The plot is not mirrored in RTL (see `ChartContainer`); the legend\n * and the centre follow the page.\n *\n * With nothing to draw it says so (`empty`) rather than drawing an empty box.\n *\n * Needs `recharts`, like everything behind `@eifi1/ui-kit/chart`.\n */\nexport function PieChart({\n data,\n variant = \"donut\",\n height = \"18.75rem\",\n formatValue: formatValueProp,\n sliceLabels = \"percent\",\n minLabelShare = 0.04,\n center = true,\n centerLabel,\n centerValue,\n legend = \"toggle\",\n hiddenSlices: hiddenProp,\n onHiddenSlicesChange,\n onSliceClick,\n redact = false,\n empty,\n locale: localeProp,\n labels: labelsProp,\n className,\n style,\n ...rest\n}: PieChartProps) {\n const labels = useKitLabels(\"pieChart\", DEFAULT_PIE_CHART_LABELS, labelsProp);\n const locale = useKitLocale(localeProp);\n // The slice labels are SVG text in px: they follow the text size by this (§10.10).\n const { scale } = useTextSize();\n const box = chartHeightProps(height);\n const numberFormat = useMemo(() => new Intl.NumberFormat(locale), [locale]);\n const percentFormats = useMemo(\n () => ({\n whole: new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 0 }),\n fine: new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 1 }),\n }),\n [locale],\n );\n // Memoised, like everything the sectors are drawn from: they must not be rebuilt on\n // this component's own re-renders (see `pie` below).\n const formatValue = useCallback(\n (v: number) => (formatValueProp ? formatValueProp(v) : formatWithMinus(numberFormat, v)),\n [formatValueProp, numberFormat],\n );\n // One decimal under 10 %, where rounding to whole percents starts to lie — a slice\n // of 0.4 % labelled \"0%\" says it is not there.\n const formatShare = useCallback(\n (share: number) => (share < 0.1 ? percentFormats.fine : percentFormats.whole).format(share),\n [percentFormats],\n );\n\n const [ownHidden, setOwnHidden] = useState<ReadonlySet<string>>(() => new Set());\n const hidden = hiddenProp ?? ownHidden;\n const toggle = (key: string) => {\n const next = toggleHidden(hidden, key);\n if (hiddenProp === undefined) setOwnHidden(next);\n onHiddenSlicesChange?.(next);\n };\n\n // Coloured by index into `data` AS GIVEN, before anything is dropped, so a slice\n // keeps its colour when a neighbour is switched off or has no value.\n const { drawable, drawn, total, pieData } = useMemo(() => {\n const drawable = data\n .map((slice, i) => ({ slice, value: slice.value, color: slice.color ?? paletteFor(i) }))\n .filter((d): d is { slice: PieChartSlice; value: number; color: string } =>\n typeof d.value === \"number\" && Number.isFinite(d.value) && d.value > 0,\n );\n const shown = drawable.filter((d) => !hidden.has(d.slice.key));\n const total = shown.reduce((acc, d) => acc + d.value, 0);\n const drawn: DrawnSlice[] = shown.map((d) => ({ ...d, share: total > 0 ? d.value / total : 0 }));\n const pieData = drawn.map((d) => ({\n key: d.slice.key,\n label: d.slice.label,\n value: d.value,\n fill: d.color,\n }));\n return { drawable, drawn, total, pieData };\n }, [data, hidden]);\n\n // The roving tab stop, and the slice that has focus (which the centre reads out).\n const [stop, setStop] = useState(0);\n const [focused, setFocused] = useState<number | undefined>(undefined);\n const current = Math.min(stop, Math.max(0, drawn.length - 1));\n const refs = useRef<(SVGGElement | null)[]>([]);\n const focusedSlice = focused !== undefined ? drawn[focused] : undefined;\n\n // Read through a ref: the sectors are drawn once per `data`, and a caller's inline\n // `onSliceClick` is a new function every render.\n const clickRef = useRef(onSliceClick);\n useLayoutEffect(() => {\n clickRef.current = onSliceClick;\n });\n const clickable = onSliceClick !== undefined;\n\n /**\n * One sector, wrapped in its keyboard stop. recharts renders it inside a layer of its\n * own that carries the pointer's hover and click, so those are untouched.\n *\n * Focus does NOT drive the recharts tooltip (`defaultIndex`), as it does in\n * `SeriesChart`: an active pie sector is re-parented into an extra layer, which\n * remounts this element and drops the focus it just took. The donut's centre is the\n * keyboard's read-out instead.\n */\n const shape = useCallback(\n (props: PieSectorShapeProps) => {\n const {\n index,\n isActive: _isActive,\n tabIndex: _tabIndex,\n animationElapsedTime: _elapsed,\n isAnimating: _animating,\n isEntrance: _entrance,\n // recharts hands the shape its own React `key` among the props. Spread into\n // JSX, a `key` inside a props object is a warning on every render (React 19),\n // so it is lifted out here and passed on its own.\n key,\n ...sector\n } = props as PieSectorShapeProps & {\n animationElapsedTime?: number;\n isAnimating?: boolean;\n isEntrance?: boolean;\n key?: string | number | null;\n };\n const d = drawn[index];\n if (!d) return <Sector key={key ?? undefined} {...sector} />;\n const onKeyDown = (event: KeyboardEvent<SVGGElement>) => {\n const step = horizontalStep(event.key, event.currentTarget);\n let target: number | undefined;\n if (step) target = index + step;\n else if (event.key === \"ArrowDown\") target = index + 1;\n else if (event.key === \"ArrowUp\") target = index - 1;\n else if (event.key === \"Home\") target = 0;\n else if (event.key === \"End\") target = drawn.length - 1;\n if (target !== undefined) {\n event.preventDefault();\n // Round the circle: past the last slice is the first, as the eye goes.\n refs.current[((target % drawn.length) + drawn.length) % drawn.length]?.focus();\n } else if (clickable && (event.key === \"Enter\" || event.key === \" \")) {\n event.preventDefault();\n clickRef.current?.(d.slice.key, d.slice);\n }\n };\n return (\n <g\n ref={(node) => {\n refs.current[index] = node;\n }}\n role={clickable ? \"button\" : \"img\"}\n // The first slice until the effect below moves the stop.\n tabIndex={index === 0 ? 0 : -1}\n aria-label={labels.slice(d.slice.label, formatValue(d.value), formatShare(d.share))}\n data-slice={d.slice.key}\n onKeyDown={onKeyDown}\n onFocus={() => {\n setStop(index);\n setFocused(index);\n }}\n onBlur={() => setFocused(undefined)}\n // CSS beats a presentation attribute, so this recolours the sector's own\n // stroke for as long as it has keyboard focus.\n className=\"outline-none [&:focus-visible>path]:stroke-primary\"\n >\n <Sector key={key ?? undefined} {...sector} />\n </g>\n );\n },\n [drawn, labels, formatValue, formatShare, clickable],\n );\n\n const label = useCallback(\n (props: PieLabelRenderProps) => {\n const d = drawn[props.index ?? -1];\n const cx = Number(props.cx);\n const cy = Number(props.cy);\n const radius = Number(props.outerRadius);\n const mid = Number(props.midAngle);\n if (!d || d.share < minLabelShare || ![cx, cy, radius, mid].every(Number.isFinite)) return null;\n const r = radius + 10 * scale;\n const x = cx + r * Math.cos(-mid * RADIAN);\n const y = cy + r * Math.sin(-mid * RADIAN);\n const isValue = sliceLabels === \"value\";\n return (\n <text\n x={x}\n y={y}\n textAnchor={x >= cx ? \"start\" : \"end\"}\n dominantBaseline=\"central\"\n fontSize={11 * scale}\n fill=\"var(--text-secondary)\"\n data-private={redact && isValue ? \"\" : undefined}\n style={{ pointerEvents: \"none\" }}\n >\n {isValue ? formatValue(d.value) : formatShare(d.share)}\n </text>\n );\n },\n [drawn, minLabelShare, sliceLabels, redact, formatValue, formatShare, scale],\n );\n\n const withLabels = sliceLabels !== \"none\";\n // Room outside the ring for the labels, or the ring fills the box.\n const outer = withLabels ? 72 : 88;\n const inner = variant === \"donut\" ? Math.round(outer * 0.6) : 0;\n\n /**\n * The `<Pie>`, as ONE element for as long as what it draws is unchanged. recharts\n * keys its sectors by an animation id that is renewed whenever the Pie's props object\n * is — i.e. on every render of the Pie — so re-rendering it remounts every sector,\n * the one with focus included, which dropped the focus to <body> on the very state\n * update that focusing a slice makes. An unchanged element is one React does not\n * re-render, so the sectors stay put, and the roving tab stop is moved on them in\n * place (the effect below).\n */\n const pie = useMemo(\n () => (\n <Pie\n data={pieData}\n dataKey=\"value\"\n nameKey=\"label\"\n cx=\"50%\"\n cy=\"50%\"\n outerRadius={`${outer}%`}\n innerRadius={`${inner}%`}\n startAngle={90}\n endAngle={-270}\n // The card showing through between the slices, as the treemap's gutter.\n stroke=\"var(--bg-surface)\"\n strokeWidth={2}\n rootTabIndex={-1}\n isAnimationActive={false}\n shape={shape}\n label={withLabels ? label : false}\n labelLine={false}\n onClick={\n clickable\n ? (_entry: unknown, index: number) => {\n const d = drawn[index];\n if (d) clickRef.current?.(d.slice.key, d.slice);\n }\n : undefined\n }\n />\n ),\n [pieData, drawn, shape, label, withLabels, outer, inner, clickable],\n );\n\n // The roving tab stop, moved on the sectors in place (they are not re-rendered).\n useEffect(() => {\n refs.current.forEach((el, i) => el?.setAttribute(\"tabindex\", i === current ? \"0\" : \"-1\"));\n });\n\n const legendEntries: LegendEntry[] = drawable.map((d) => ({\n key: d.slice.key,\n label: d.slice.label,\n color: d.color,\n }));\n\n const emptyBox = (\n <div\n className={cn(\"flex items-center justify-center text-sm text-muted\", box.className)}\n style={box.style}\n >\n {empty ?? labels.empty}\n </div>\n );\n\n // Nothing drawable at all: no chart, and no legend of switches for nothing.\n if (drawable.length === 0) {\n return (\n <div {...rest} className={cn(\"w-full\", className)} style={style}>\n {emptyBox}\n </div>\n );\n }\n\n const Content = redact ? RedactedTooltipContent : ChartTooltipContent;\n\n return (\n <div {...rest} className={cn(\"w-full\", className)} style={style}>\n {drawn.length === 0 ? (\n // Every slice switched off: the legend below is how they come back.\n emptyBox\n ) : (\n <div role=\"group\" aria-label={labels.slices} className={cn(\"relative\", box.className)} style={box.style}>\n <ChartContainer\n config={PIE_CONFIG}\n className={cn(\"h-full\", clickable && \"[&_.recharts-pie-sector]:cursor-pointer\")}\n >\n <RechartsPieChart accessibilityLayer={false}>\n <ChartTooltip\n content={\n <Content\n hideLabel\n valueFormatter={(v) =>\n `${formatValue(v)} (${formatShare(total > 0 ? v / total : 0)})`\n }\n />\n }\n />\n {pie}\n </RechartsPieChart>\n </ChartContainer>\n {variant === \"donut\" && center && (\n <div\n data-private={redact ? \"\" : undefined}\n // While a slice has keyboard focus the centre reads it out — the\n // keyboard's tooltip. Hidden from assistive technology then: the slice's\n // own name has just said the same.\n aria-hidden={focusedSlice ? true : undefined}\n className=\"pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center\"\n >\n {/* The label wraps from Large up, inside the hole's 40 % (text size\n §10.17, round 0.34 T1: the sweep read \"Spent in Sept…\"). */}\n <span className=\"max-w-[40%] truncate-until-large text-xs text-muted\">\n {focusedSlice ? focusedSlice.slice.label : (centerLabel ?? labels.total)}\n </span>\n <span className=\"max-w-[40%] truncate text-lg font-semibold tabular-nums text-primary\">\n {focusedSlice ? formatValue(focusedSlice.value) : (centerValue ?? formatValue(total))}\n </span>\n {focusedSlice && (\n <span className=\"text-xs tabular-nums text-muted\">\n {formatShare(focusedSlice.share)}\n </span>\n )}\n </div>\n )}\n </div>\n )}\n {legend === \"toggle\" && (\n <ToggleLegend\n entries={legendEntries}\n hidden={hidden}\n onToggle={toggle}\n showSingle\n className=\"justify-center\"\n labels={{ legend: labels.legend }}\n />\n )}\n {legend === \"static\" && (\n <StaticLegend entries={legendEntries} className=\"justify-center\" aria-label={labels.legend} />\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["drawable","total","drawn","pieData"]}
@@ -5,6 +5,7 @@ import { cn } from "../lib/cn.js";
5
5
  import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels.js";
6
6
  import { toneTextClass } from "./signed-amount.js";
7
7
  import { DEFAULT_PROGRESS_BAR_LABELS } from "./progress-bar-labels.js";
8
+ import { formatWithMinus } from "../lib/minus.js";
8
9
  const FILL = {
9
10
  brand: "bg-brand",
10
11
  neutral: "bg-[var(--text-muted)]",
@@ -24,8 +25,7 @@ const SERIES_FILL = [
24
25
  "bg-[var(--chart-5)]",
25
26
  "bg-[var(--chart-6)]",
26
27
  "bg-[var(--chart-7)]",
27
- "bg-[var(--chart-8)]",
28
- "bg-[var(--chart-9)]"
28
+ "bg-[var(--chart-8)]"
29
29
  ];
30
30
  function segmentFills(segments) {
31
31
  let next = 0;
@@ -73,7 +73,7 @@ function ProgressBar({
73
73
  const hintId = useId();
74
74
  const hintNode = hint != null ? /* @__PURE__ */ jsx("p", { id: hintId, "data-part": "hint", className: "mt-1 text-xs text-muted", children: hint }) : null;
75
75
  if (maxProp === null) {
76
- const shown = value === void 0 || !Number.isFinite(value) ? void 0 : formatValue ? formatValue(value, Infinity, min) : new Intl.NumberFormat(kitLocale).format(value);
76
+ const shown = value === void 0 || !Number.isFinite(value) ? void 0 : formatValue ? formatValue(value, Infinity, min) : formatWithMinus(new Intl.NumberFormat(kitLocale), value);
77
77
  return /* @__PURE__ */ jsxs(Tag, { ...rest, ref: tagRef, className: cn("min-w-0", inlineClass, className), "data-state": "unlimited", children: [
78
78
  /* @__PURE__ */ jsxs(Tag, { className: "flex items-baseline gap-2 text-sm", children: [
79
79
  label != null && /* @__PURE__ */ jsx("span", { id: labelId, className: "min-w-0 text-secondary", children: label }),
@@ -99,7 +99,8 @@ function ProgressBar({
99
99
  const formatPart = (v) => {
100
100
  if (!Number.isFinite(v)) return format(min);
101
101
  if (formatValue) return formatValue(v, max, min);
102
- return new Intl.NumberFormat(kitLocale, { style: "percent", maximumFractionDigits: 0 }).format(
102
+ return formatWithMinus(
103
+ new Intl.NumberFormat(kitLocale, { style: "percent", maximumFractionDigits: 0 }),
103
104
  span <= 0 ? 0 : (v - min) / span
104
105
  );
105
106
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/progress-bar.tsx"],"sourcesContent":["import { useId } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale, type LabelOverride } from \"../i18n/kit-labels\";\nimport { toneTextClass } from \"./signed-amount\";\nimport type { TextTone } from \"./signed-amount\";\nimport { DEFAULT_PROGRESS_BAR_LABELS } from \"./progress-bar-labels\";\n\n/** The words a progress bar adds of its own. */\nexport interface ProgressBarLabels {\n /** Shown after the value when `max={null}`: a quota with no ceiling. */\n unlimited: string;\n /** The `overage` line: how far past `max` the value is. `amount` is formatted. */\n overLimit: (amount: string) => string;\n}\n\n// Defined in ./progress-bar-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_PROGRESS_BAR_LABELS };\n\n/**\n * `income` / `expense` are the money pair (`--money-income` / `--money-expense`), the\n * names `Chip`, `StatTile` and `Sparkline` already use for them: a share bar of what\n * came in or went out is money, and painting it `success` / `warning` said \"good\" and\n * \"careful\" about a figure that is neither. keksdose's report meters (food-group-card,\n * drilldown-modal) sit beside amounts that are already in the money colours.\n */\nexport type ProgressBarTone =\n | \"brand\"\n | \"neutral\"\n | \"success\"\n | \"warning\"\n | \"danger\"\n | \"info\"\n | \"income\"\n | \"expense\";\n/**\n * Track heights: `sm` 4px, `slim` 6px, `md` 8px, `lg` 12px. `slim` is a word and not a\n * letter because the letters are taken in order — an `xs` at 6px would be THICKER than\n * `sm` — and renaming `sm` would move every bar already on it. 6px is the Slider's\n * track and keksdose's hand-drawn meters (`h-1.5`, landing-visuals), for a bar under a\n * line of text where 8px outweighs the text.\n */\nexport type ProgressBarSize = \"sm\" | \"slim\" | \"md\" | \"lg\";\n\nconst FILL: Record<ProgressBarTone, string> = {\n brand: \"bg-brand\",\n neutral: \"bg-[var(--text-muted)]\",\n success: \"bg-success\",\n warning: \"bg-warning\",\n danger: \"bg-danger\",\n info: \"bg-info\",\n income: \"bg-money-income\",\n expense: \"bg-money-expense\",\n};\n\nconst TRACK_HEIGHT: Record<ProgressBarSize, string> = { sm: \"h-1\", slim: \"h-1.5\", md: \"h-2\", lg: \"h-3\" };\n\n// A segment with no tone of its own takes the next categorical chart colour, so a\n// stacked bar of unnamed parts is still a set of tellable parts rather than one fill.\nconst SERIES_FILL = [\n \"bg-[var(--chart-1)]\",\n \"bg-[var(--chart-2)]\",\n \"bg-[var(--chart-3)]\",\n \"bg-[var(--chart-4)]\",\n \"bg-[var(--chart-5)]\",\n \"bg-[var(--chart-6)]\",\n \"bg-[var(--chart-7)]\",\n \"bg-[var(--chart-8)]\",\n \"bg-[var(--chart-9)]\",\n];\n\n/** Each segment's fill classes: its own classes, else its tone, else (with no `color`)\n * the next chart colour. \"Next\" counts the UNCOLOURED segments only — a segment with\n * its own `className`/`tone`/`color` does not use up `--chart-1`, so an \"Other\" in grey\n * ahead of two plain parts leaves them `--chart-1` and `--chart-2`. */\nfunction segmentFills(segments: ProgressBarSegment[]): Array<string | undefined> {\n let next = 0;\n return segments.map((seg) => {\n if (seg.className) return seg.className;\n if (seg.tone) return FILL[seg.tone];\n return seg.color ? undefined : SERIES_FILL[next++ % SERIES_FILL.length];\n });\n}\n\n/** One part of a stacked bar. See {@link ProgressBarProps.segments}. */\nexport interface ProgressBarSegment {\n /** Its size, on the bar's own `min`–`max` scale. */\n value: number;\n /** A kit tone. Left out (and no `color`/`className`), the next `--chart-N` colour. */\n tone?: ProgressBarTone;\n /** Any CSS colour (`var(--token)`), for a palette the tones do not cover. Wins over `tone`. */\n color?: string;\n /** Classes for the segment's fill — keksdose's `STEP_CLASS` ramp. Wins over `tone`. */\n className?: string;\n /** The part's name: said in the bar's `aria-valuetext` and shown in the legend. */\n label?: ReactNode;\n /** A stable React key, when `label` is not a string. */\n key?: string;\n /**\n * List it in the `legend` but leave it out of the bar — its value, its share of the\n * `aria-valuetext`, and the sum. keksdose's cut card (reports/cut-card:132) names\n * EVERY bucket under its bar, the empty ones included, while the bar draws only the\n * ones with a share: pass the empty buckets as `legendOnly` entries.\n */\n legendOnly?: boolean;\n}\n\nexport interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\" | \"role\"> {\n /**\n * `\"span\"` builds the bar from spans, for a place only phrasing content may go: inside\n * a button or a link (ListItem's `content`). The `legend` stays a list, so leave it off\n * there. Default `\"div\"`.\n */\n as?: \"div\" | \"span\";\n /** Where it stands. Leave it undefined for an INDETERMINATE bar — work is under way\n * and nobody knows how much is left (an import that reports no count). */\n value?: number;\n min?: number;\n /**\n * The top of the scale (default 100). `null` is a quota with NO ceiling — kastlan's\n * billing usage on an unlimited plan (usage-progress-bar:25): nothing to fill, so no\n * bar is drawn; the label row shows the value and \"Unlimited\" (`progressBar.unlimited`)\n * as plain text. `formatValue` is then called with `max` = `Infinity`; without it,\n * the value is a plain number in the kit's locale.\n */\n max?: number | null;\n /**\n * `progress` (default): a task moving toward completion — `role=\"progressbar\"`.\n * `meter`: a share of a known whole that is not going anywhere (budget used, disk\n * space, one category's slice of spending) — `role=\"meter\"`. keksdose's share bars\n * were progress bars to a screen reader, which reads \"busy\" into a figure that is\n * simply a figure. A meter is never indeterminate; a missing value reads as `min`.\n */\n variant?: \"progress\" | \"meter\";\n /** Visible label above the bar, and its accessible name. Without it, pass\n * `aria-label` (an indeterminate bar with neither is named `common.loading`). */\n label?: ReactNode;\n /** Show the value text at the end of the label row. */\n showValue?: boolean;\n /**\n * The value as words — both the visible `showValue` text and `aria-valuetext`.\n * Default: the fraction as a percentage in the kit's locale (\"42 %\" in French).\n * Pass one for anything that is not a percentage: `(v, max) => \\`${v} of ${max} files\\``.\n */\n formatValue?: (value: number, max: number, min: number) => string;\n tone?: ProgressBarTone;\n size?: ProgressBarSize;\n locale?: string;\n /**\n * Draw a METER OF PARTS: one bar, several fills side by side in order, a 2px gap\n * between them — keksdose's discretionary-spending bar (cut-card:133), a whole\n * split into the steps of a scale. Forces `variant=\"meter\"`: parts of a whole are\n * never \"busy\". `aria-valuenow` is the sum; `aria-valuetext` names every part\n * (\"Rent: 40%, Food: 25%\"), each part's value through `formatValue` and joined in the\n * kit's locale — the words keksdose's hand-drawn bar had to hide from a reader\n * altogether, leaving the legend as the only way in. The sum is clamped to `max`,\n * and parts past it are cut off at the track's end.\n */\n segments?: ProgressBarSegment[];\n /** With `segments`: a list under the bar naming each part with its swatch and value\n * — the table view of the bar, in text tokens rather than the segment colour. */\n legend?: boolean;\n /**\n * What a legend row shows at its end, in place of the part's formatted value — which\n * it is handed, with the segment and its index. That value is the part's OWN, signed\n * and unclamped (`formatValue`, or a percentage of the scale): a part of −40 on a\n * 0–100 scale says \"-40%\" though the bar draws nothing for it. keksdose's cut card\n * shows the share AND a private outflow amount per bucket (cut-card:150), which one\n * figure could not:\n * `legendValue={(seg, share) => <>{share} <Amount … /></>}`.\n */\n legendValue?: (segment: ProgressBarSegment, formatted: string, index: number) => ReactNode;\n /**\n * The colour of each legend row's figure, as a {@link TextTone} — `toneTextClass`'s\n * palette, the money pair included. Default `\"muted\"`: the legend explains the bar and\n * should not outshout the headline above it. One tone for every row, or a function\n * per row. A tone and not the segment's own `tone`: keksdose's tax card fills its\n * three parts with one bucket's certainty ramp (`className`), while the FIGURES carry\n * the bucket's money direction — `legendTone=\"expense\"` for the tax owed — which it\n * could only say before through `legendValue` and an app-side `Amount`. A function\n * returning `undefined` keeps that row muted. Applies to `legendValue`'s node too\n * (it inherits the colour unless it sets its own).\n */\n legendTone?: TextTone | ((segment: ProgressBarSegment, index: number) => TextTone | undefined);\n /** A muted line under the bar — the plan's reset date, \"3 of 5 seats in use\". It\n * describes the bar (`aria-describedby`). */\n hint?: ReactNode;\n /**\n * When `value` is past `max`, say by how much, in a danger-coloured line under the bar\n * (kastlan usage-progress-bar:46): `true` prints `progressBar.overLimit` with the\n * excess as a plain number; a function renders the line itself from the excess (a\n * currency, \"2 seats\"). The bar stays full at `max` either way — colour it with\n * `tone=\"danger\"` if it should read as over. Ignored for `segments` and `max={null}`.\n */\n overage?: boolean | ((over: number) => ReactNode);\n /**\n * Tag every figure the bar PRINTS `data-private`, so the host's demo-mode rule blurs\n * it — the attribute `StatTile sensitive` and `Tooltip redact` carry: the `showValue`\n * text, each `legend` row's value (yours from `legendValue` too), the unlimited\n * figure and the `overage` line. The track, the swatches and the part names stay\n * readable — a blurred bar is no picture at all. Before this an app with a demo-mode\n * blur had to pass `legendValue` only to wrap the figure in a tagged span (keksdose).\n * The `aria-valuetext` is not touched: a screen reader is not a screen-share.\n */\n sensitive?: boolean;\n labels?: LabelOverride<ProgressBarLabels>;\n /** The element the rest of the props go on — the `progressbar` (or `meter`) itself,\n * the box round the figure for an unlimited one — a `div` or a `span` as `as` says,\n * hence `HTMLElement` (0.34, API-2, refactor audit 2026-10). */\n ref?: Ref<HTMLElement>;\n}\n\n/**\n * A horizontal bar that fills toward `max`.\n *\n * keksdose (import progress, budget share bars) and kastlan (upload and job\n * progress) each drew their own div-in-a-div. None had a role, so a reader heard\n * nothing at all; the share bars that did have one claimed to be progress.\n *\n * `aria-valuetext` is always set when there is a value: a bare `aria-valuenow=\"37\"`\n * is read as \"37\" by some readers and \"37 percent\" by others, and neither is right\n * when `max` is 212 files.\n *\n * The indeterminate sweep stops under `prefers-reduced-motion`. It becomes a still,\n * half-strength bar across the whole track — still visibly \"something is happening\",\n * with nothing moving. The sweep is a Tailwind arbitrary `animate-[…]` rather than\n * tokens.css's `.animate-indeterminate` class because that one is UNLAYERED, and an\n * unlayered rule beats every layered utility, `motion-reduce:animate-none` included.\n */\nexport function ProgressBar({\n value,\n min = 0,\n max: maxProp = 100,\n variant = \"progress\",\n label,\n showValue = false,\n formatValue,\n tone = \"brand\",\n size = \"md\",\n locale,\n segments,\n legend = false,\n legendValue,\n legendTone = \"muted\",\n hint,\n overage,\n sensitive = false,\n labels,\n className,\n as = \"div\",\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledBy,\n \"aria-describedby\": ariaDescribedBy,\n ref,\n ...rest\n}: ProgressBarProps) {\n // `as=\"span\"`: the same bar from phrasing elements only, so it is valid inside a\n // <button> or <a> — ListItem's `content` puts a budget bar inside the row's target\n // (keksdose P2). Every span is made a block except those already laid out as flex.\n const Tag = as;\n // Either element's ref, as List types its `ul`/`ol` one.\n const tagRef = ref as Ref<HTMLDivElement & HTMLSpanElement> | undefined;\n const inlineClass = as === \"span\" ? \"block [&_span:not(.flex):not(.inline-flex)]:block\" : undefined;\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const text = useKitLabels(\"progressBar\", DEFAULT_PROGRESS_BAR_LABELS, labels);\n const kitLocale = useKitLocale(locale);\n const labelId = useId();\n const priv = sensitive ? \"\" : undefined;\n const hintId = useId();\n const hintNode =\n hint != null ? (\n <p id={hintId} data-part=\"hint\" className=\"mt-1 text-xs text-muted\">\n {hint}\n </p>\n ) : null;\n\n if (maxProp === null) {\n // No ceiling: no bar, no role — a meter with no maximum is not one. The value and\n // \"Unlimited\" are the content, so they are plain visible text.\n const shown =\n value === undefined || !Number.isFinite(value)\n ? undefined\n : formatValue\n ? formatValue(value, Infinity, min)\n : new Intl.NumberFormat(kitLocale).format(value);\n return (\n <Tag {...rest} ref={tagRef} className={cn(\"min-w-0\", inlineClass, className)} data-state=\"unlimited\">\n <Tag className=\"flex items-baseline gap-2 text-sm\">\n {label != null && (\n <span id={labelId} className=\"min-w-0 text-secondary\">\n {label}\n </span>\n )}\n <span data-part=\"unlimited\" data-private={priv} className=\"ms-auto shrink-0 tabular-nums text-muted\">\n {shown !== undefined && `${shown} · `}\n {text.unlimited}\n </span>\n </Tag>\n {hintNode}\n </Tag>\n );\n }\n const max = maxProp;\n\n const stacked = segments !== undefined;\n const meter = stacked || variant === \"meter\";\n const span = max - min;\n const partValue = (v: number) => (Number.isFinite(v) ? Math.max(0, v - min) : 0);\n // Colours are dealt over EVERY segment, legend-only ones included, so an entry keeps\n // its swatch whether or not it is in the bar this time.\n const fills = stacked ? segmentFills(segments) : [];\n const barParts = stacked ? segments.filter((seg) => !seg.legendOnly) : [];\n const partTotal = stacked ? min + barParts.reduce((sum, seg) => sum + partValue(seg.value), 0) : undefined;\n const format = (v: number) =>\n formatValue\n ? formatValue(v, max, min)\n : new Intl.NumberFormat(kitLocale, { style: \"percent\", maximumFractionDigits: 0 }).format(\n span <= 0 ? 0 : (Math.min(max, Math.max(min, v)) - min) / span,\n );\n // A PART is stated as its own value, sign and all — not as the slice of track it\n // draws. The bar cannot draw a negative part (a refund inside a month's spending) or\n // one past `max`, but the legend and the reader can still say it: before 0.16 a\n // −CHF 40 part read \"0 %\" in both, and keksdose had to pass `legendValue` just to get\n // the minus back. For a part inside `min`–`max` this is the same text as before.\n const formatPart = (v: number) => {\n if (!Number.isFinite(v)) return format(min);\n if (formatValue) return formatValue(v, max, min);\n return new Intl.NumberFormat(kitLocale, { style: \"percent\", maximumFractionDigits: 0 }).format(\n span <= 0 ? 0 : (v - min) / span,\n );\n };\n const partTexts = stacked\n ? barParts.map((seg) => {\n const text = formatPart(seg.value);\n return typeof seg.label === \"string\" || typeof seg.label === \"number\"\n ? common.fieldValue(String(seg.label), text)\n : text;\n })\n : [];\n const current =\n partTotal ?? (value === undefined || !Number.isFinite(value) ? (meter ? min : undefined) : value);\n const indeterminate = current === undefined;\n const clamped = indeterminate ? undefined : Math.min(max, Math.max(min, current));\n const fraction = clamped === undefined || span <= 0 ? 0 : (clamped - min) / span;\n\n const valueText = clamped === undefined ? undefined : format(clamped);\n const ariaValueText =\n stacked && partTexts.length > 0\n ? new Intl.ListFormat(kitLocale, { style: \"short\", type: \"unit\" }).format(partTexts)\n : valueText;\n\n const over =\n overage && !stacked && value !== undefined && Number.isFinite(value) && value > max ? value - max : 0;\n const overageNode =\n over > 0 ? (\n <p id={`${hintId}-over`} data-part=\"overage\" data-private={priv} className=\"mt-1 text-xs text-danger\">\n {typeof overage === \"function\"\n ? overage(over)\n : text.overLimit(new Intl.NumberFormat(kitLocale).format(over))}\n </p>\n ) : null;\n const describedBy =\n [ariaDescribedBy, hintNode && hintId, overageNode && `${hintId}-over`].filter(Boolean).join(\" \") || undefined;\n\n const labelledBy = ariaLabelledBy ?? (label != null ? labelId : undefined);\n const name = labelledBy ? undefined : (ariaLabel ?? (indeterminate ? common.loading : undefined));\n\n return (\n <Tag className={cn(\"min-w-0\", inlineClass, className)} data-state={indeterminate ? \"indeterminate\" : \"determinate\"}>\n {(label != null || (showValue && valueText !== undefined)) && (\n <Tag className=\"mb-1 flex items-baseline gap-2 text-sm\">\n {label != null && (\n <span id={labelId} className=\"min-w-0 text-secondary\">\n {label}\n </span>\n )}\n {showValue && valueText !== undefined && (\n // Hidden: the same words are the bar's aria-valuetext, and reading them\n // twice in a row is noise.\n <span aria-hidden data-part=\"value\" data-private={priv} className=\"ms-auto shrink-0 tabular-nums text-muted\">\n {valueText}\n </span>\n )}\n </Tag>\n )}\n <Tag\n {...rest}\n ref={tagRef}\n role={meter ? \"meter\" : \"progressbar\"}\n aria-label={name}\n aria-labelledby={labelledBy}\n aria-describedby={describedBy}\n aria-valuemin={indeterminate ? undefined : min}\n aria-valuemax={indeterminate ? undefined : max}\n aria-valuenow={clamped}\n aria-valuetext={ariaValueText}\n aria-busy={!meter && indeterminate ? true : undefined}\n // `relative` + `overflow-hidden`: the indeterminate segment is positioned in\n // here and must not paint past the rounded ends.\n className={cn(\"relative w-full overflow-hidden rounded-full bg-active\", TRACK_HEIGHT[size])}\n >\n {stacked ? (\n // The parts in a flex row with a 2px gap, so two neighbouring steps of one\n // hue are parted by the track rather than by their difference in lightness.\n <Tag className=\"flex h-full w-full gap-0.5\">\n {segments.map((seg, i) =>\n seg.legendOnly ? null : (\n <Tag\n key={seg.key ?? i}\n data-part=\"segment\"\n className={cn(\n \"h-full shrink-0 rounded-full transition-[width] duration-300 motion-reduce:transition-none\",\n fills[i],\n )}\n style={{\n width: `${span <= 0 ? 0 : (partValue(seg.value) / span) * 100}%`,\n backgroundColor: seg.className ? undefined : seg.color,\n }}\n />\n ),\n )}\n </Tag>\n ) : indeterminate ? (\n <Tag\n data-part=\"fill\"\n className={cn(\n // `left-0`, deliberately physical: the keyframes (tokens.css\n // `indeterminate-sweep`) translate along the physical x axis, and under\n // RTL the same keyframes are run in REVERSE — from the right edge back\n // to the left. A logical `start-0` would park the segment at the right\n // in RTL and the reversed sweep would then start off-screen.\n \"absolute inset-y-0 left-0 w-1/3 rounded-full\",\n \"animate-[indeterminate-sweep_1.4s_ease-in-out_infinite] rtl:[animation-direction:reverse]\",\n \"motion-reduce:w-full motion-reduce:animate-none motion-reduce:opacity-50\",\n FILL[tone],\n )}\n />\n ) : (\n <Tag\n data-part=\"fill\"\n // Block flow starts at the inline start, so a plain width fills from the\n // right under `dir=\"rtl\"` with nothing extra.\n className={cn(\"h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none\", FILL[tone])}\n style={{ width: `${fraction * 100}%` }}\n />\n )}\n </Tag>\n {stacked && legend && segments.length > 0 && (\n <ul className=\"mt-2 space-y-1 text-sm\" data-part=\"legend\">\n {segments.map((seg, i) => (\n <li key={seg.key ?? i} className=\"flex items-center gap-2\">\n <span\n aria-hidden\n className={cn(\n \"size-2.5 shrink-0 rounded-sm\",\n fills[i],\n )}\n style={{ backgroundColor: seg.className ? undefined : seg.color }}\n />\n {seg.label != null && (\n // Wraps from Large up (text size §10.17, round 0.34 T1); the value\n // beside it keeps its width.\n <span className=\"min-w-0 flex-1 truncate-until-large text-secondary\">{seg.label}</span>\n )}\n <span\n data-part=\"legend-value\"\n data-private={priv}\n className={cn(\n \"ms-auto shrink-0 text-xs tabular-nums\",\n toneTextClass(\n (typeof legendTone === \"function\" ? legendTone(seg, i) : legendTone) ?? \"muted\",\n ),\n )}\n >\n {legendValue ? legendValue(seg, formatPart(seg.value), i) : formatPart(seg.value)}\n </span>\n </li>\n ))}\n </ul>\n )}\n {overageNode}\n {hintNode}\n </Tag>\n );\n}\n"],"mappings":"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","names":["text"]}
1
+ {"version":3,"sources":["../../src/components/progress-bar.tsx"],"sourcesContent":["import { useId } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale, type LabelOverride } from \"../i18n/kit-labels\";\nimport { toneTextClass } from \"./signed-amount\";\nimport type { TextTone } from \"./signed-amount\";\nimport { DEFAULT_PROGRESS_BAR_LABELS } from \"./progress-bar-labels\";\nimport { formatWithMinus } from \"../lib/minus\";\n\n/** The words a progress bar adds of its own. */\nexport interface ProgressBarLabels {\n /** Shown after the value when `max={null}`: a quota with no ceiling. */\n unlimited: string;\n /** The `overage` line: how far past `max` the value is. `amount` is formatted. */\n overLimit: (amount: string) => string;\n}\n\n// Defined in ./progress-bar-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_PROGRESS_BAR_LABELS };\n\n/**\n * `income` / `expense` are the money pair (`--money-income` / `--money-expense`), the\n * names `Chip`, `StatTile` and `Sparkline` already use for them: a share bar of what\n * came in or went out is money, and painting it `success` / `warning` said \"good\" and\n * \"careful\" about a figure that is neither. keksdose's report meters (food-group-card,\n * drilldown-modal) sit beside amounts that are already in the money colours.\n */\nexport type ProgressBarTone =\n | \"brand\"\n | \"neutral\"\n | \"success\"\n | \"warning\"\n | \"danger\"\n | \"info\"\n | \"income\"\n | \"expense\";\n/**\n * Track heights: `sm` 4px, `slim` 6px, `md` 8px, `lg` 12px. `slim` is a word and not a\n * letter because the letters are taken in order — an `xs` at 6px would be THICKER than\n * `sm` — and renaming `sm` would move every bar already on it. 6px is the Slider's\n * track and keksdose's hand-drawn meters (`h-1.5`, landing-visuals), for a bar under a\n * line of text where 8px outweighs the text.\n */\nexport type ProgressBarSize = \"sm\" | \"slim\" | \"md\" | \"lg\";\n\nconst FILL: Record<ProgressBarTone, string> = {\n brand: \"bg-brand\",\n neutral: \"bg-[var(--text-muted)]\",\n success: \"bg-success\",\n warning: \"bg-warning\",\n danger: \"bg-danger\",\n info: \"bg-info\",\n income: \"bg-money-income\",\n expense: \"bg-money-expense\",\n};\n\nconst TRACK_HEIGHT: Record<ProgressBarSize, string> = { sm: \"h-1\", slim: \"h-1.5\", md: \"h-2\", lg: \"h-3\" };\n\n// A segment with no tone of its own takes the next categorical chart colour, so a\n// stacked bar of unnamed parts is still a set of tellable parts rather than one fill.\n// Eight, cycled like `paletteFor` (0.49): `--chart-9` only repeats slot 1.\nconst SERIES_FILL = [\n \"bg-[var(--chart-1)]\",\n \"bg-[var(--chart-2)]\",\n \"bg-[var(--chart-3)]\",\n \"bg-[var(--chart-4)]\",\n \"bg-[var(--chart-5)]\",\n \"bg-[var(--chart-6)]\",\n \"bg-[var(--chart-7)]\",\n \"bg-[var(--chart-8)]\",\n];\n\n/** Each segment's fill classes: its own classes, else its tone, else (with no `color`)\n * the next chart colour. \"Next\" counts the UNCOLOURED segments only — a segment with\n * its own `className`/`tone`/`color` does not use up `--chart-1`, so an \"Other\" in grey\n * ahead of two plain parts leaves them `--chart-1` and `--chart-2`. */\nfunction segmentFills(segments: ProgressBarSegment[]): Array<string | undefined> {\n let next = 0;\n return segments.map((seg) => {\n if (seg.className) return seg.className;\n if (seg.tone) return FILL[seg.tone];\n return seg.color ? undefined : SERIES_FILL[next++ % SERIES_FILL.length];\n });\n}\n\n/** One part of a stacked bar. See {@link ProgressBarProps.segments}. */\nexport interface ProgressBarSegment {\n /** Its size, on the bar's own `min`–`max` scale. */\n value: number;\n /** A kit tone. Left out (and no `color`/`className`), the next `--chart-N` colour. */\n tone?: ProgressBarTone;\n /** Any CSS colour (`var(--token)`), for a palette the tones do not cover. Wins over `tone`. */\n color?: string;\n /** Classes for the segment's fill — keksdose's `STEP_CLASS` ramp. Wins over `tone`. */\n className?: string;\n /** The part's name: said in the bar's `aria-valuetext` and shown in the legend. */\n label?: ReactNode;\n /** A stable React key, when `label` is not a string. */\n key?: string;\n /**\n * List it in the `legend` but leave it out of the bar — its value, its share of the\n * `aria-valuetext`, and the sum. keksdose's cut card (reports/cut-card:132) names\n * EVERY bucket under its bar, the empty ones included, while the bar draws only the\n * ones with a share: pass the empty buckets as `legendOnly` entries.\n */\n legendOnly?: boolean;\n}\n\nexport interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\" | \"role\"> {\n /**\n * `\"span\"` builds the bar from spans, for a place only phrasing content may go: inside\n * a button or a link (ListItem's `content`). The `legend` stays a list, so leave it off\n * there. Default `\"div\"`.\n */\n as?: \"div\" | \"span\";\n /** Where it stands. Leave it undefined for an INDETERMINATE bar — work is under way\n * and nobody knows how much is left (an import that reports no count). */\n value?: number;\n min?: number;\n /**\n * The top of the scale (default 100). `null` is a quota with NO ceiling — kastlan's\n * billing usage on an unlimited plan (usage-progress-bar:25): nothing to fill, so no\n * bar is drawn; the label row shows the value and \"Unlimited\" (`progressBar.unlimited`)\n * as plain text. `formatValue` is then called with `max` = `Infinity`; without it,\n * the value is a plain number in the kit's locale.\n */\n max?: number | null;\n /**\n * `progress` (default): a task moving toward completion — `role=\"progressbar\"`.\n * `meter`: a share of a known whole that is not going anywhere (budget used, disk\n * space, one category's slice of spending) — `role=\"meter\"`. keksdose's share bars\n * were progress bars to a screen reader, which reads \"busy\" into a figure that is\n * simply a figure. A meter is never indeterminate; a missing value reads as `min`.\n */\n variant?: \"progress\" | \"meter\";\n /** Visible label above the bar, and its accessible name. Without it, pass\n * `aria-label` (an indeterminate bar with neither is named `common.loading`). */\n label?: ReactNode;\n /** Show the value text at the end of the label row. */\n showValue?: boolean;\n /**\n * The value as words — both the visible `showValue` text and `aria-valuetext`.\n * Default: the fraction as a percentage in the kit's locale (\"42 %\" in French).\n * Pass one for anything that is not a percentage: `(v, max) => \\`${v} of ${max} files\\``.\n */\n formatValue?: (value: number, max: number, min: number) => string;\n tone?: ProgressBarTone;\n size?: ProgressBarSize;\n locale?: string;\n /**\n * Draw a METER OF PARTS: one bar, several fills side by side in order, a 2px gap\n * between them — keksdose's discretionary-spending bar (cut-card:133), a whole\n * split into the steps of a scale. Forces `variant=\"meter\"`: parts of a whole are\n * never \"busy\". `aria-valuenow` is the sum; `aria-valuetext` names every part\n * (\"Rent: 40%, Food: 25%\"), each part's value through `formatValue` and joined in the\n * kit's locale — the words keksdose's hand-drawn bar had to hide from a reader\n * altogether, leaving the legend as the only way in. The sum is clamped to `max`,\n * and parts past it are cut off at the track's end.\n */\n segments?: ProgressBarSegment[];\n /** With `segments`: a list under the bar naming each part with its swatch and value\n * — the table view of the bar, in text tokens rather than the segment colour. */\n legend?: boolean;\n /**\n * What a legend row shows at its end, in place of the part's formatted value — which\n * it is handed, with the segment and its index. That value is the part's OWN, signed\n * and unclamped (`formatValue`, or a percentage of the scale): a part of −40 on a\n * 0–100 scale says \"-40%\" though the bar draws nothing for it. keksdose's cut card\n * shows the share AND a private outflow amount per bucket (cut-card:150), which one\n * figure could not:\n * `legendValue={(seg, share) => <>{share} <Amount … /></>}`.\n */\n legendValue?: (segment: ProgressBarSegment, formatted: string, index: number) => ReactNode;\n /**\n * The colour of each legend row's figure, as a {@link TextTone} — `toneTextClass`'s\n * palette, the money pair included. Default `\"muted\"`: the legend explains the bar and\n * should not outshout the headline above it. One tone for every row, or a function\n * per row. A tone and not the segment's own `tone`: keksdose's tax card fills its\n * three parts with one bucket's certainty ramp (`className`), while the FIGURES carry\n * the bucket's money direction — `legendTone=\"expense\"` for the tax owed — which it\n * could only say before through `legendValue` and an app-side `Amount`. A function\n * returning `undefined` keeps that row muted. Applies to `legendValue`'s node too\n * (it inherits the colour unless it sets its own).\n */\n legendTone?: TextTone | ((segment: ProgressBarSegment, index: number) => TextTone | undefined);\n /** A muted line under the bar — the plan's reset date, \"3 of 5 seats in use\". It\n * describes the bar (`aria-describedby`). */\n hint?: ReactNode;\n /**\n * When `value` is past `max`, say by how much, in a danger-coloured line under the bar\n * (kastlan usage-progress-bar:46): `true` prints `progressBar.overLimit` with the\n * excess as a plain number; a function renders the line itself from the excess (a\n * currency, \"2 seats\"). The bar stays full at `max` either way — colour it with\n * `tone=\"danger\"` if it should read as over. Ignored for `segments` and `max={null}`.\n */\n overage?: boolean | ((over: number) => ReactNode);\n /**\n * Tag every figure the bar PRINTS `data-private`, so the host's demo-mode rule blurs\n * it — the attribute `StatTile sensitive` and `Tooltip redact` carry: the `showValue`\n * text, each `legend` row's value (yours from `legendValue` too), the unlimited\n * figure and the `overage` line. The track, the swatches and the part names stay\n * readable — a blurred bar is no picture at all. Before this an app with a demo-mode\n * blur had to pass `legendValue` only to wrap the figure in a tagged span (keksdose).\n * The `aria-valuetext` is not touched: a screen reader is not a screen-share.\n */\n sensitive?: boolean;\n labels?: LabelOverride<ProgressBarLabels>;\n /** The element the rest of the props go on — the `progressbar` (or `meter`) itself,\n * the box round the figure for an unlimited one — a `div` or a `span` as `as` says,\n * hence `HTMLElement` (0.34, API-2, refactor audit 2026-10). */\n ref?: Ref<HTMLElement>;\n}\n\n/**\n * A horizontal bar that fills toward `max`.\n *\n * keksdose (import progress, budget share bars) and kastlan (upload and job\n * progress) each drew their own div-in-a-div. None had a role, so a reader heard\n * nothing at all; the share bars that did have one claimed to be progress.\n *\n * `aria-valuetext` is always set when there is a value: a bare `aria-valuenow=\"37\"`\n * is read as \"37\" by some readers and \"37 percent\" by others, and neither is right\n * when `max` is 212 files.\n *\n * The indeterminate sweep stops under `prefers-reduced-motion`. It becomes a still,\n * half-strength bar across the whole track — still visibly \"something is happening\",\n * with nothing moving. The sweep is a Tailwind arbitrary `animate-[…]` rather than\n * tokens.css's `.animate-indeterminate` class because that one is UNLAYERED, and an\n * unlayered rule beats every layered utility, `motion-reduce:animate-none` included.\n */\nexport function ProgressBar({\n value,\n min = 0,\n max: maxProp = 100,\n variant = \"progress\",\n label,\n showValue = false,\n formatValue,\n tone = \"brand\",\n size = \"md\",\n locale,\n segments,\n legend = false,\n legendValue,\n legendTone = \"muted\",\n hint,\n overage,\n sensitive = false,\n labels,\n className,\n as = \"div\",\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledBy,\n \"aria-describedby\": ariaDescribedBy,\n ref,\n ...rest\n}: ProgressBarProps) {\n // `as=\"span\"`: the same bar from phrasing elements only, so it is valid inside a\n // <button> or <a> — ListItem's `content` puts a budget bar inside the row's target\n // (keksdose P2). Every span is made a block except those already laid out as flex.\n const Tag = as;\n // Either element's ref, as List types its `ul`/`ol` one.\n const tagRef = ref as Ref<HTMLDivElement & HTMLSpanElement> | undefined;\n const inlineClass = as === \"span\" ? \"block [&_span:not(.flex):not(.inline-flex)]:block\" : undefined;\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const text = useKitLabels(\"progressBar\", DEFAULT_PROGRESS_BAR_LABELS, labels);\n const kitLocale = useKitLocale(locale);\n const labelId = useId();\n const priv = sensitive ? \"\" : undefined;\n const hintId = useId();\n const hintNode =\n hint != null ? (\n <p id={hintId} data-part=\"hint\" className=\"mt-1 text-xs text-muted\">\n {hint}\n </p>\n ) : null;\n\n if (maxProp === null) {\n // No ceiling: no bar, no role — a meter with no maximum is not one. The value and\n // \"Unlimited\" are the content, so they are plain visible text.\n const shown =\n value === undefined || !Number.isFinite(value)\n ? undefined\n : formatValue\n ? formatValue(value, Infinity, min)\n : formatWithMinus(new Intl.NumberFormat(kitLocale), value);\n return (\n <Tag {...rest} ref={tagRef} className={cn(\"min-w-0\", inlineClass, className)} data-state=\"unlimited\">\n <Tag className=\"flex items-baseline gap-2 text-sm\">\n {label != null && (\n <span id={labelId} className=\"min-w-0 text-secondary\">\n {label}\n </span>\n )}\n <span data-part=\"unlimited\" data-private={priv} className=\"ms-auto shrink-0 tabular-nums text-muted\">\n {shown !== undefined && `${shown} · `}\n {text.unlimited}\n </span>\n </Tag>\n {hintNode}\n </Tag>\n );\n }\n const max = maxProp;\n\n const stacked = segments !== undefined;\n const meter = stacked || variant === \"meter\";\n const span = max - min;\n const partValue = (v: number) => (Number.isFinite(v) ? Math.max(0, v - min) : 0);\n // Colours are dealt over EVERY segment, legend-only ones included, so an entry keeps\n // its swatch whether or not it is in the bar this time.\n const fills = stacked ? segmentFills(segments) : [];\n const barParts = stacked ? segments.filter((seg) => !seg.legendOnly) : [];\n const partTotal = stacked ? min + barParts.reduce((sum, seg) => sum + partValue(seg.value), 0) : undefined;\n const format = (v: number) =>\n formatValue\n ? formatValue(v, max, min)\n : new Intl.NumberFormat(kitLocale, { style: \"percent\", maximumFractionDigits: 0 }).format(\n span <= 0 ? 0 : (Math.min(max, Math.max(min, v)) - min) / span,\n );\n // A PART is stated as its own value, sign and all — not as the slice of track it\n // draws. The bar cannot draw a negative part (a refund inside a month's spending) or\n // one past `max`, but the legend and the reader can still say it: before 0.16 a\n // −CHF 40 part read \"0 %\" in both, and keksdose had to pass `legendValue` just to get\n // the minus back. For a part inside `min`–`max` this is the same text as before.\n const formatPart = (v: number) => {\n if (!Number.isFinite(v)) return format(min);\n if (formatValue) return formatValue(v, max, min);\n // A part can be negative (a refund): U+2212, as everywhere (0.49, KIT-1).\n return formatWithMinus(\n new Intl.NumberFormat(kitLocale, { style: \"percent\", maximumFractionDigits: 0 }),\n span <= 0 ? 0 : (v - min) / span,\n );\n };\n const partTexts = stacked\n ? barParts.map((seg) => {\n const text = formatPart(seg.value);\n return typeof seg.label === \"string\" || typeof seg.label === \"number\"\n ? common.fieldValue(String(seg.label), text)\n : text;\n })\n : [];\n const current =\n partTotal ?? (value === undefined || !Number.isFinite(value) ? (meter ? min : undefined) : value);\n const indeterminate = current === undefined;\n const clamped = indeterminate ? undefined : Math.min(max, Math.max(min, current));\n const fraction = clamped === undefined || span <= 0 ? 0 : (clamped - min) / span;\n\n const valueText = clamped === undefined ? undefined : format(clamped);\n const ariaValueText =\n stacked && partTexts.length > 0\n ? new Intl.ListFormat(kitLocale, { style: \"short\", type: \"unit\" }).format(partTexts)\n : valueText;\n\n const over =\n overage && !stacked && value !== undefined && Number.isFinite(value) && value > max ? value - max : 0;\n const overageNode =\n over > 0 ? (\n <p id={`${hintId}-over`} data-part=\"overage\" data-private={priv} className=\"mt-1 text-xs text-danger\">\n {typeof overage === \"function\"\n ? overage(over)\n : text.overLimit(new Intl.NumberFormat(kitLocale).format(over))}\n </p>\n ) : null;\n const describedBy =\n [ariaDescribedBy, hintNode && hintId, overageNode && `${hintId}-over`].filter(Boolean).join(\" \") || undefined;\n\n const labelledBy = ariaLabelledBy ?? (label != null ? labelId : undefined);\n const name = labelledBy ? undefined : (ariaLabel ?? (indeterminate ? common.loading : undefined));\n\n return (\n <Tag className={cn(\"min-w-0\", inlineClass, className)} data-state={indeterminate ? \"indeterminate\" : \"determinate\"}>\n {(label != null || (showValue && valueText !== undefined)) && (\n <Tag className=\"mb-1 flex items-baseline gap-2 text-sm\">\n {label != null && (\n <span id={labelId} className=\"min-w-0 text-secondary\">\n {label}\n </span>\n )}\n {showValue && valueText !== undefined && (\n // Hidden: the same words are the bar's aria-valuetext, and reading them\n // twice in a row is noise.\n <span aria-hidden data-part=\"value\" data-private={priv} className=\"ms-auto shrink-0 tabular-nums text-muted\">\n {valueText}\n </span>\n )}\n </Tag>\n )}\n <Tag\n {...rest}\n ref={tagRef}\n role={meter ? \"meter\" : \"progressbar\"}\n aria-label={name}\n aria-labelledby={labelledBy}\n aria-describedby={describedBy}\n aria-valuemin={indeterminate ? undefined : min}\n aria-valuemax={indeterminate ? undefined : max}\n aria-valuenow={clamped}\n aria-valuetext={ariaValueText}\n aria-busy={!meter && indeterminate ? true : undefined}\n // `relative` + `overflow-hidden`: the indeterminate segment is positioned in\n // here and must not paint past the rounded ends.\n className={cn(\"relative w-full overflow-hidden rounded-full bg-active\", TRACK_HEIGHT[size])}\n >\n {stacked ? (\n // The parts in a flex row with a 2px gap, so two neighbouring steps of one\n // hue are parted by the track rather than by their difference in lightness.\n <Tag className=\"flex h-full w-full gap-0.5\">\n {segments.map((seg, i) =>\n seg.legendOnly ? null : (\n <Tag\n key={seg.key ?? i}\n data-part=\"segment\"\n className={cn(\n \"h-full shrink-0 rounded-full transition-[width] duration-300 motion-reduce:transition-none\",\n fills[i],\n )}\n style={{\n width: `${span <= 0 ? 0 : (partValue(seg.value) / span) * 100}%`,\n backgroundColor: seg.className ? undefined : seg.color,\n }}\n />\n ),\n )}\n </Tag>\n ) : indeterminate ? (\n <Tag\n data-part=\"fill\"\n className={cn(\n // `left-0`, deliberately physical: the keyframes (tokens.css\n // `indeterminate-sweep`) translate along the physical x axis, and under\n // RTL the same keyframes are run in REVERSE — from the right edge back\n // to the left. A logical `start-0` would park the segment at the right\n // in RTL and the reversed sweep would then start off-screen.\n \"absolute inset-y-0 left-0 w-1/3 rounded-full\",\n \"animate-[indeterminate-sweep_1.4s_ease-in-out_infinite] rtl:[animation-direction:reverse]\",\n \"motion-reduce:w-full motion-reduce:animate-none motion-reduce:opacity-50\",\n FILL[tone],\n )}\n />\n ) : (\n <Tag\n data-part=\"fill\"\n // Block flow starts at the inline start, so a plain width fills from the\n // right under `dir=\"rtl\"` with nothing extra.\n className={cn(\"h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none\", FILL[tone])}\n style={{ width: `${fraction * 100}%` }}\n />\n )}\n </Tag>\n {stacked && legend && segments.length > 0 && (\n <ul className=\"mt-2 space-y-1 text-sm\" data-part=\"legend\">\n {segments.map((seg, i) => (\n <li key={seg.key ?? i} className=\"flex items-center gap-2\">\n <span\n aria-hidden\n className={cn(\n \"size-2.5 shrink-0 rounded-sm\",\n fills[i],\n )}\n style={{ backgroundColor: seg.className ? undefined : seg.color }}\n />\n {seg.label != null && (\n // Wraps from Large up (text size §10.17, round 0.34 T1); the value\n // beside it keeps its width.\n <span className=\"min-w-0 flex-1 truncate-until-large text-secondary\">{seg.label}</span>\n )}\n <span\n data-part=\"legend-value\"\n data-private={priv}\n className={cn(\n \"ms-auto shrink-0 text-xs tabular-nums\",\n toneTextClass(\n (typeof legendTone === \"function\" ? legendTone(seg, i) : legendTone) ?? \"muted\",\n ),\n )}\n >\n {legendValue ? legendValue(seg, formatPart(seg.value), i) : formatPart(seg.value)}\n </span>\n </li>\n ))}\n </ul>\n )}\n {overageNode}\n {hintNode}\n </Tag>\n );\n}\n"],"mappings":"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","names":["text"]}
@@ -133,7 +133,7 @@ function ReauthActions({
133
133
  {
134
134
  type: "submit",
135
135
  form: formId,
136
- variant: danger ? "danger" : "primary",
136
+ variant: danger ? "danger" : "default",
137
137
  disabled: !canSubmit || busy,
138
138
  "aria-busy": busy || void 0,
139
139
  children: [
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/reauth-dialog.tsx"],"sourcesContent":["import { useContext, useEffect, useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\n\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { CurrentPasswordInput, usePromisePending } from \"./danger-confirm\";\nimport { DialogFrame } from \"./dialog-frame\";\nimport { ModalCloseContext } from \"./modal\";\nimport { Button } from \"./button\";\nimport { Spinner } from \"./spinner\";\nimport { DEFAULT_REAUTH_DIALOG_LABELS } from \"./reauth-dialog-labels\";\n\n/**\n * Every string the dialog renders — the `reauthDialog` namespace of\n * `<UiKitProvider labels>`, and overridable per instance through `labels`. The title,\n * description and submit button are usually worded per action (\"Add a passkey\"),\n * which is what the `title` / `description` / `submitLabel` props are for; these are\n * the fallbacks.\n */\nexport interface ReauthDialogLabels {\n title: string;\n description: string;\n /** Label of the password field. */\n password: string;\n submit: string;\n cancel: string;\n}\n\n// Defined in ./reauth-dialog-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_REAUTH_DIALOG_LABELS };\n\nexport interface ReauthDialogProps {\n /**\n * Verifies the password — the app's call, e.g. the request the password guards.\n *\n * Return a promise and the dialog manages itself: busy until it settles, closes\n * (`onClose(true)`) when it resolves, STAYS OPEN with the password kept and selected\n * when it rejects — a wrong password is a typo to correct, not a sign-out. Pass the\n * reason as `error`. Return nothing and the app owns the rest: `busy` for the\n * pending state, unmounting the dialog once it is done.\n */\n onSubmit: (password: string) => void | Promise<unknown>;\n /**\n * The dialog has closed: `true` after a resolved `onSubmit`, `false` for Cancel,\n * Escape, the backdrop and Back. A dismissal while `onSubmit` is still running also\n * answers `false`, and that call's late result is then ignored.\n */\n onClose: (confirmed: boolean) => void;\n /**\n * Why the last attempt failed (\"Wrong password\"), shown under the field, which turns\n * invalid (`aria-invalid`, `--danger-border-strong`). The app decides the wording —\n * a 400 is a wrong password, a 500 is not. Hidden again as soon as the user edits\n * the field (the message is about the password they had typed), and shown again by\n * the next failed submit.\n */\n error?: ReactNode;\n /** The check is running, for a caller that tracks it itself (a mutation's\n * `isPending`); a promise returned from `onSubmit` does the same on its own. */\n busy?: boolean;\n /** Default: `labels.title` — \"Confirm it’s you\". */\n title?: ReactNode;\n /** The line under the title. Default: `labels.description`. */\n description?: ReactNode;\n /** Text of the submit button. Default: `labels.submit` — \"Continue\". */\n submitLabel?: ReactNode;\n /** `\"danger\"` when what the password unlocks cannot be undone: the submit is then the\n * destructive button. Default `\"neutral\"`. */\n tone?: \"neutral\" | \"danger\";\n /** Extra body above the field — what is about to happen, a warning. */\n children?: ReactNode;\n /** Kept-mounted mode, as on `Modal`: left out, the dialog is open while mounted. A\n * flip to `false` animates it out and wipes the password. */\n open?: boolean;\n /** User-facing strings; see {@link ReauthDialogLabels}. */\n labels?: LabelOverride<ReauthDialogLabels>;\n}\n\n/**\n * \"Enter your current password to continue\" — the step-up check in front of an\n * action that changes how an account signs in (adding a passkey) or what it can\n * reach. Kurvenschmiede asked for the password before registering a passkey in a\n * dialog of its own; keksdose asks for it inline in `DangerConfirm`'s\n * `requirePassword` tile. Both are the same three facts — the field is the CURRENT\n * password (the password manager fills it, never generates one), a wrong one stays in\n * the dialog with its error, and the APP verifies it — so the promise handling is\n * `DangerConfirm`'s own, shared, the field is its `CurrentPasswordInput` with an\n * `error`, and this is the modal form of them.\n *\n * A component rather than a `useReauth()` promise: the step that follows the check\n * (Kurvenschmiede's WebAuthn ceremony) needs what the server answered, and the error\n * wording needs the app's own status codes — both already live in the app's mutation,\n * which this dialog only has to be handed (`onSubmit={(pw) => begin.mutateAsync(pw)}`,\n * `error={…}`).\n *\n * The field is a `<form>`, so Enter submits once something is typed. Focus starts in\n * the field and returns to the trigger through `Modal`'s trap. While a check runs,\n * the field is read-only, Cancel is disabled and Escape is held: a request that has\n * left cannot be cancelled by closing the dialog, and the dialog should not pretend it\n * can.\n */\nexport function ReauthDialog({\n onSubmit,\n onClose,\n error,\n busy: busyProp,\n title,\n description,\n submitLabel,\n tone = \"neutral\",\n children,\n open,\n labels: labelsProp,\n}: ReauthDialogProps) {\n const labels = useKitLabels(\"reauthDialog\", DEFAULT_REAUTH_DIALOG_LABELS, labelsProp);\n const [password, setPassword] = useState(\"\");\n // The error describes the password that was submitted; once the user edits it, it\n // describes nothing on screen. Cleared by the next submit, so a second failure with\n // the same message shows again.\n const [edited, setEdited] = useState(false);\n const { pending, run } = usePromisePending();\n const busy = Boolean(busyProp) || pending;\n const inputRef = useRef<HTMLInputElement>(null);\n // `onSubmit` resolved: the actions row lowers the panel (state, so it re-renders),\n // and `onClose` answers true (a ref, because `Modal` may call the `onClose` it saw\n // BEFORE that re-render — its latest-callback ref is refreshed in an effect that runs\n // after the actions row's).\n const [done, setDone] = useState(false);\n const confirmed = useRef(false);\n const formId = useId();\n const isOpen = open ?? true;\n\n // A close (kept-mounted) wipes the password: it must not sit in a hidden dialog.\n // During render, like DangerConfirm's disarm, so no frame holds it.\n const [wasOpen, setWasOpen] = useState(isOpen);\n if (wasOpen !== isOpen) {\n setWasOpen(isOpen);\n if (!isOpen) {\n setPassword(\"\");\n setEdited(false);\n setDone(false);\n }\n }\n\n // After `Modal`'s own effect (a parent's effects run after its children's), so the\n // trap has recorded the trigger as its restore target before focus moves in.\n useEffect(() => {\n if (!isOpen) return;\n confirmed.current = false;\n inputRef.current?.focus();\n }, [isOpen]);\n\n const submit = () => {\n if (busy || password === \"\") return;\n setEdited(false);\n run(onSubmit(password), () => {\n confirmed.current = true;\n setDone(true);\n });\n };\n\n // A rejection leaves focus where it was (the field, or the submit button) — select\n // the password there so the retry replaces it rather than appending to it.\n const wasPending = useRef(false);\n useEffect(() => {\n if (wasPending.current && !pending && !done) inputRef.current?.select();\n wasPending.current = pending;\n }, [pending, done]);\n\n const shownError = edited ? undefined : error;\n\n return (\n <DialogFrame\n open={open}\n title={title ?? labels.title}\n description={description ?? labels.description}\n onClose={() => onClose(confirmed.current)}\n onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {\n if (busy && e.key === \"Escape\") e.preventDefault();\n }}\n aria-busy={busy || undefined}\n actions={\n <ReauthActions\n formId={formId}\n done={done}\n busy={busy}\n canSubmit={password !== \"\"}\n danger={tone === \"danger\"}\n cancelLabel={labels.cancel}\n submitLabel={submitLabel ?? labels.submit}\n />\n }\n >\n {children}\n <form\n id={formId}\n noValidate\n onSubmit={(e) => {\n e.preventDefault();\n submit();\n }}\n >\n {/* The kit's CurrentPasswordInput (0.23; it drew the same field by hand until\n then): the password manager fills it with the CURRENT password and never\n offers to generate one; `busy` makes it `readOnly`, not `disabled`, because\n disabling the field that has focus drops focus to <body>. Labelled by this\n namespace's `password` (\"Current password\"), not `dangerConfirm.password`.\n The `error` is Input's own: it paints the field, sits under it and is its\n `aria-describedby`; passing the prop on every render keeps the field's box\n steady, so the message going away on the first keystroke of a retry does not\n remount the <input> under the typist. */}\n <CurrentPasswordInput\n ref={inputRef}\n label={labels.password}\n value={password}\n busy={busy}\n error={shownError}\n onValueChange={(next) => {\n setPassword(next);\n setEdited(true);\n }}\n />\n </form>\n </DialogFrame>\n );\n}\n\n/**\n * The actions row. A component of its own because it renders INSIDE the `Modal`, where\n * the panel's animated close is in context: a resolved `onSubmit` lowers the panel\n * through it, the way Cancel and Escape do, and `Modal` then calls `onClose`.\n */\nfunction ReauthActions({\n formId,\n done,\n busy,\n canSubmit,\n danger,\n cancelLabel,\n submitLabel,\n}: {\n formId: string;\n done: boolean;\n busy: boolean;\n canSubmit: boolean;\n danger: boolean;\n cancelLabel: ReactNode;\n submitLabel: ReactNode;\n}) {\n const close = useContext(ModalCloseContext);\n useEffect(() => {\n if (done) close?.();\n }, [done, close]);\n return (\n <>\n <Button type=\"button\" variant=\"ghost\" disabled={busy} onClick={() => close?.()}>\n {cancelLabel}\n </Button>\n <Button\n type=\"submit\"\n form={formId}\n variant={danger ? \"danger\" : \"primary\"}\n disabled={!canSubmit || busy}\n aria-busy={busy || undefined}\n >\n {busy && <Spinner label={null} className=\"h-4 w-4\" />}\n {submitLabel}\n </Button>\n </>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/reauth-dialog.tsx"],"sourcesContent":["import { useContext, useEffect, useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\n\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { CurrentPasswordInput, usePromisePending } from \"./danger-confirm\";\nimport { DialogFrame } from \"./dialog-frame\";\nimport { ModalCloseContext } from \"./modal\";\nimport { Button } from \"./button\";\nimport { Spinner } from \"./spinner\";\nimport { DEFAULT_REAUTH_DIALOG_LABELS } from \"./reauth-dialog-labels\";\n\n/**\n * Every string the dialog renders — the `reauthDialog` namespace of\n * `<UiKitProvider labels>`, and overridable per instance through `labels`. The title,\n * description and submit button are usually worded per action (\"Add a passkey\"),\n * which is what the `title` / `description` / `submitLabel` props are for; these are\n * the fallbacks.\n */\nexport interface ReauthDialogLabels {\n title: string;\n description: string;\n /** Label of the password field. */\n password: string;\n submit: string;\n cancel: string;\n}\n\n// Defined in ./reauth-dialog-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_REAUTH_DIALOG_LABELS };\n\nexport interface ReauthDialogProps {\n /**\n * Verifies the password — the app's call, e.g. the request the password guards.\n *\n * Return a promise and the dialog manages itself: busy until it settles, closes\n * (`onClose(true)`) when it resolves, STAYS OPEN with the password kept and selected\n * when it rejects — a wrong password is a typo to correct, not a sign-out. Pass the\n * reason as `error`. Return nothing and the app owns the rest: `busy` for the\n * pending state, unmounting the dialog once it is done.\n */\n onSubmit: (password: string) => void | Promise<unknown>;\n /**\n * The dialog has closed: `true` after a resolved `onSubmit`, `false` for Cancel,\n * Escape, the backdrop and Back. A dismissal while `onSubmit` is still running also\n * answers `false`, and that call's late result is then ignored.\n */\n onClose: (confirmed: boolean) => void;\n /**\n * Why the last attempt failed (\"Wrong password\"), shown under the field, which turns\n * invalid (`aria-invalid`, `--danger-border-strong`). The app decides the wording —\n * a 400 is a wrong password, a 500 is not. Hidden again as soon as the user edits\n * the field (the message is about the password they had typed), and shown again by\n * the next failed submit.\n */\n error?: ReactNode;\n /** The check is running, for a caller that tracks it itself (a mutation's\n * `isPending`); a promise returned from `onSubmit` does the same on its own. */\n busy?: boolean;\n /** Default: `labels.title` — \"Confirm it’s you\". */\n title?: ReactNode;\n /** The line under the title. Default: `labels.description`. */\n description?: ReactNode;\n /** Text of the submit button. Default: `labels.submit` — \"Continue\". */\n submitLabel?: ReactNode;\n /** `\"danger\"` when what the password unlocks cannot be undone: the submit is then the\n * destructive button. Default `\"neutral\"`. */\n tone?: \"neutral\" | \"danger\";\n /** Extra body above the field — what is about to happen, a warning. */\n children?: ReactNode;\n /** Kept-mounted mode, as on `Modal`: left out, the dialog is open while mounted. A\n * flip to `false` animates it out and wipes the password. */\n open?: boolean;\n /** User-facing strings; see {@link ReauthDialogLabels}. */\n labels?: LabelOverride<ReauthDialogLabels>;\n}\n\n/**\n * \"Enter your current password to continue\" — the step-up check in front of an\n * action that changes how an account signs in (adding a passkey) or what it can\n * reach. Kurvenschmiede asked for the password before registering a passkey in a\n * dialog of its own; keksdose asks for it inline in `DangerConfirm`'s\n * `requirePassword` tile. Both are the same three facts — the field is the CURRENT\n * password (the password manager fills it, never generates one), a wrong one stays in\n * the dialog with its error, and the APP verifies it — so the promise handling is\n * `DangerConfirm`'s own, shared, the field is its `CurrentPasswordInput` with an\n * `error`, and this is the modal form of them.\n *\n * A component rather than a `useReauth()` promise: the step that follows the check\n * (Kurvenschmiede's WebAuthn ceremony) needs what the server answered, and the error\n * wording needs the app's own status codes — both already live in the app's mutation,\n * which this dialog only has to be handed (`onSubmit={(pw) => begin.mutateAsync(pw)}`,\n * `error={…}`).\n *\n * The field is a `<form>`, so Enter submits once something is typed. Focus starts in\n * the field and returns to the trigger through `Modal`'s trap. While a check runs,\n * the field is read-only, Cancel is disabled and Escape is held: a request that has\n * left cannot be cancelled by closing the dialog, and the dialog should not pretend it\n * can.\n */\nexport function ReauthDialog({\n onSubmit,\n onClose,\n error,\n busy: busyProp,\n title,\n description,\n submitLabel,\n tone = \"neutral\",\n children,\n open,\n labels: labelsProp,\n}: ReauthDialogProps) {\n const labels = useKitLabels(\"reauthDialog\", DEFAULT_REAUTH_DIALOG_LABELS, labelsProp);\n const [password, setPassword] = useState(\"\");\n // The error describes the password that was submitted; once the user edits it, it\n // describes nothing on screen. Cleared by the next submit, so a second failure with\n // the same message shows again.\n const [edited, setEdited] = useState(false);\n const { pending, run } = usePromisePending();\n const busy = Boolean(busyProp) || pending;\n const inputRef = useRef<HTMLInputElement>(null);\n // `onSubmit` resolved: the actions row lowers the panel (state, so it re-renders),\n // and `onClose` answers true (a ref, because `Modal` may call the `onClose` it saw\n // BEFORE that re-render — its latest-callback ref is refreshed in an effect that runs\n // after the actions row's).\n const [done, setDone] = useState(false);\n const confirmed = useRef(false);\n const formId = useId();\n const isOpen = open ?? true;\n\n // A close (kept-mounted) wipes the password: it must not sit in a hidden dialog.\n // During render, like DangerConfirm's disarm, so no frame holds it.\n const [wasOpen, setWasOpen] = useState(isOpen);\n if (wasOpen !== isOpen) {\n setWasOpen(isOpen);\n if (!isOpen) {\n setPassword(\"\");\n setEdited(false);\n setDone(false);\n }\n }\n\n // After `Modal`'s own effect (a parent's effects run after its children's), so the\n // trap has recorded the trigger as its restore target before focus moves in.\n useEffect(() => {\n if (!isOpen) return;\n confirmed.current = false;\n inputRef.current?.focus();\n }, [isOpen]);\n\n const submit = () => {\n if (busy || password === \"\") return;\n setEdited(false);\n run(onSubmit(password), () => {\n confirmed.current = true;\n setDone(true);\n });\n };\n\n // A rejection leaves focus where it was (the field, or the submit button) — select\n // the password there so the retry replaces it rather than appending to it.\n const wasPending = useRef(false);\n useEffect(() => {\n if (wasPending.current && !pending && !done) inputRef.current?.select();\n wasPending.current = pending;\n }, [pending, done]);\n\n const shownError = edited ? undefined : error;\n\n return (\n <DialogFrame\n open={open}\n title={title ?? labels.title}\n description={description ?? labels.description}\n onClose={() => onClose(confirmed.current)}\n onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {\n if (busy && e.key === \"Escape\") e.preventDefault();\n }}\n aria-busy={busy || undefined}\n actions={\n <ReauthActions\n formId={formId}\n done={done}\n busy={busy}\n canSubmit={password !== \"\"}\n danger={tone === \"danger\"}\n cancelLabel={labels.cancel}\n submitLabel={submitLabel ?? labels.submit}\n />\n }\n >\n {children}\n <form\n id={formId}\n noValidate\n onSubmit={(e) => {\n e.preventDefault();\n submit();\n }}\n >\n {/* The kit's CurrentPasswordInput (0.23; it drew the same field by hand until\n then): the password manager fills it with the CURRENT password and never\n offers to generate one; `busy` makes it `readOnly`, not `disabled`, because\n disabling the field that has focus drops focus to <body>. Labelled by this\n namespace's `password` (\"Current password\"), not `dangerConfirm.password`.\n The `error` is Input's own: it paints the field, sits under it and is its\n `aria-describedby`; passing the prop on every render keeps the field's box\n steady, so the message going away on the first keystroke of a retry does not\n remount the <input> under the typist. */}\n <CurrentPasswordInput\n ref={inputRef}\n label={labels.password}\n value={password}\n busy={busy}\n error={shownError}\n onValueChange={(next) => {\n setPassword(next);\n setEdited(true);\n }}\n />\n </form>\n </DialogFrame>\n );\n}\n\n/**\n * The actions row. A component of its own because it renders INSIDE the `Modal`, where\n * the panel's animated close is in context: a resolved `onSubmit` lowers the panel\n * through it, the way Cancel and Escape do, and `Modal` then calls `onClose`.\n */\nfunction ReauthActions({\n formId,\n done,\n busy,\n canSubmit,\n danger,\n cancelLabel,\n submitLabel,\n}: {\n formId: string;\n done: boolean;\n busy: boolean;\n canSubmit: boolean;\n danger: boolean;\n cancelLabel: ReactNode;\n submitLabel: ReactNode;\n}) {\n const close = useContext(ModalCloseContext);\n useEffect(() => {\n if (done) close?.();\n }, [done, close]);\n return (\n <>\n <Button type=\"button\" variant=\"ghost\" disabled={busy} onClick={() => close?.()}>\n {cancelLabel}\n </Button>\n <Button\n type=\"submit\"\n form={formId}\n variant={danger ? \"danger\" : \"default\"}\n disabled={!canSubmit || busy}\n aria-busy={busy || undefined}\n >\n {busy && <Spinner label={null} className=\"h-4 w-4\" />}\n {submitLabel}\n </Button>\n </>\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,7 +1,9 @@
1
1
  "use client";
2
2
  const DEFAULT_ROW_ACTIONS_LABELS = {
3
3
  actions: "Actions",
4
- actionsFor: (name) => `Actions for ${name}`
4
+ actionsFor: (name) => `Actions for ${name}`,
5
+ confirmTitle: (action, name) => name ? `${action} \u201C${name}\u201D?` : `${action}?`,
6
+ confirmBody: "This cannot be undone."
5
7
  };
6
8
  export {
7
9
  DEFAULT_ROW_ACTIONS_LABELS
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/row-actions-labels.ts"],"sourcesContent":["// The English words of ./row-actions, in a module with no component code: the English\n// catalogue (src/i18n/defaults.ts) imports them without the component, so `en` loads as one\n// chunk like the other languages (0.34, KS U8). ./row-actions re-exports them.\n\nimport type { RowActionsLabels } from \"./row-actions\";\n\nexport const DEFAULT_ROW_ACTIONS_LABELS: RowActionsLabels = {\n actions: \"Actions\",\n actionsFor: (name) => `Actions for ${name}`,\n};\n"],"mappings":";AAMO,MAAM,6BAA+C;AAAA,EAC1D,SAAS;AAAA,EACT,YAAY,CAAC,SAAS,eAAe,IAAI;AAC3C;","names":[]}
1
+ {"version":3,"sources":["../../src/components/row-actions-labels.ts"],"sourcesContent":["// The English words of ./row-actions, in a module with no component code: the English\n// catalogue (src/i18n/defaults.ts) imports them without the component, so `en` loads as one\n// chunk like the other languages (0.34, KS U8). ./row-actions re-exports them.\n\nimport type { RowActionsLabels } from \"./row-actions\";\n\nexport const DEFAULT_ROW_ACTIONS_LABELS: RowActionsLabels = {\n actions: \"Actions\",\n actionsFor: (name) => `Actions for ${name}`,\n confirmTitle: (action, name) => (name ? `${action} “${name}”?` : `${action}?`),\n confirmBody: \"This cannot be undone.\",\n};\n"],"mappings":";AAMO,MAAM,6BAA+C;AAAA,EAC1D,SAAS;AAAA,EACT,YAAY,CAAC,SAAS,eAAe,IAAI;AAAA,EACzC,cAAc,CAAC,QAAQ,SAAU,OAAO,GAAG,MAAM,UAAK,IAAI,YAAO,GAAG,MAAM;AAAA,EAC1E,aAAa;AACf;","names":[]}