@eifi1/ui-kit 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (410) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +498 -57
  3. package/dist/chart.d.ts +10 -0
  4. package/dist/chart.js +10 -0
  5. package/dist/chart.js.map +1 -0
  6. package/dist/components/account-settings.d.ts +11 -0
  7. package/dist/components/account-settings.js +19 -12
  8. package/dist/components/account-settings.js.map +1 -1
  9. package/dist/components/alert-banner.d.ts +10 -6
  10. package/dist/components/alert-banner.js +20 -16
  11. package/dist/components/alert-banner.js.map +1 -1
  12. package/dist/components/amount-input.d.ts +30 -4
  13. package/dist/components/amount-input.js +36 -14
  14. package/dist/components/amount-input.js.map +1 -1
  15. package/dist/components/calculator.d.ts +24 -7
  16. package/dist/components/calculator.js +37 -20
  17. package/dist/components/calculator.js.map +1 -1
  18. package/dist/components/chart-zoom.d.ts +156 -0
  19. package/dist/components/chart-zoom.js +262 -0
  20. package/dist/components/chart-zoom.js.map +1 -0
  21. package/dist/components/chart.d.ts +28 -3
  22. package/dist/components/chart.js +71 -32
  23. package/dist/components/chart.js.map +1 -1
  24. package/dist/components/checkbox.d.ts +51 -0
  25. package/dist/components/checkbox.js +123 -0
  26. package/dist/components/checkbox.js.map +1 -0
  27. package/dist/components/chip.d.ts +128 -0
  28. package/dist/components/chip.js +357 -0
  29. package/dist/components/chip.js.map +1 -0
  30. package/dist/components/choice-parts.d.ts +24 -0
  31. package/dist/components/choice-parts.js +18 -0
  32. package/dist/components/choice-parts.js.map +1 -0
  33. package/dist/components/combobox-core.d.ts +52 -9
  34. package/dist/components/combobox-core.js +91 -20
  35. package/dist/components/combobox-core.js.map +1 -1
  36. package/dist/components/combobox.d.ts +43 -33
  37. package/dist/components/combobox.js +409 -340
  38. package/dist/components/combobox.js.map +1 -1
  39. package/dist/components/currency-select.d.ts +48 -8
  40. package/dist/components/currency-select.js +107 -65
  41. package/dist/components/currency-select.js.map +1 -1
  42. package/dist/components/data-table-filter-popover.d.ts +15 -3
  43. package/dist/components/data-table-filter-popover.js +23 -17
  44. package/dist/components/data-table-filter-popover.js.map +1 -1
  45. package/dist/components/data-table-filters.d.ts +1 -1
  46. package/dist/components/data-table-filters.js +3 -2
  47. package/dist/components/data-table-filters.js.map +1 -1
  48. package/dist/components/data-table-labels.d.ts +53 -2
  49. package/dist/components/data-table-labels.js +31 -2
  50. package/dist/components/data-table-labels.js.map +1 -1
  51. package/dist/components/data-table-pagination.d.ts +3 -1
  52. package/dist/components/data-table-pagination.js +16 -10
  53. package/dist/components/data-table-pagination.js.map +1 -1
  54. package/dist/components/data-table-sort.js +1 -0
  55. package/dist/components/data-table-sort.js.map +1 -1
  56. package/dist/components/data-table.d.ts +1 -1
  57. package/dist/components/data-table.js +201 -85
  58. package/dist/components/data-table.js.map +1 -1
  59. package/dist/components/date-picker.d.ts +25 -9
  60. package/dist/components/date-picker.js +232 -94
  61. package/dist/components/date-picker.js.map +1 -1
  62. package/dist/components/dropdown.d.ts +96 -20
  63. package/dist/components/dropdown.js +77 -33
  64. package/dist/components/dropdown.js.map +1 -1
  65. package/dist/components/entity-combobox.d.ts +5 -4
  66. package/dist/components/entity-combobox.js +107 -76
  67. package/dist/components/entity-combobox.js.map +1 -1
  68. package/dist/components/facing-pair.d.ts +36 -0
  69. package/dist/components/facing-pair.js +40 -0
  70. package/dist/components/facing-pair.js.map +1 -0
  71. package/dist/components/field-sync.d.ts +164 -0
  72. package/dist/components/field-sync.js +264 -0
  73. package/dist/components/field-sync.js.map +1 -0
  74. package/dist/components/file-dropzone.d.ts +38 -6
  75. package/dist/components/file-dropzone.js +34 -9
  76. package/dist/components/file-dropzone.js.map +1 -1
  77. package/dist/components/full-bleed-dialog.d.ts +37 -26
  78. package/dist/components/full-bleed-dialog.js +20 -5
  79. package/dist/components/full-bleed-dialog.js.map +1 -1
  80. package/dist/components/grouped-picker.d.ts +21 -10
  81. package/dist/components/grouped-picker.js +66 -52
  82. package/dist/components/grouped-picker.js.map +1 -1
  83. package/dist/components/hover-menu.d.ts +20 -4
  84. package/dist/components/hover-menu.js +15 -4
  85. package/dist/components/hover-menu.js.map +1 -1
  86. package/dist/components/mini-calendar.d.ts +102 -14
  87. package/dist/components/mini-calendar.js +256 -70
  88. package/dist/components/mini-calendar.js.map +1 -1
  89. package/dist/components/modal.d.ts +9 -3
  90. package/dist/components/modal.js +34 -45
  91. package/dist/components/modal.js.map +1 -1
  92. package/dist/components/month-picker.d.ts +79 -0
  93. package/dist/components/month-picker.js +342 -0
  94. package/dist/components/month-picker.js.map +1 -0
  95. package/dist/components/multi-entity-combobox.d.ts +7 -6
  96. package/dist/components/multi-entity-combobox.js +112 -72
  97. package/dist/components/multi-entity-combobox.js.map +1 -1
  98. package/dist/components/multi-select.d.ts +13 -11
  99. package/dist/components/multi-select.js +161 -84
  100. package/dist/components/multi-select.js.map +1 -1
  101. package/dist/components/number-field.d.ts +92 -0
  102. package/dist/components/number-field.js +111 -0
  103. package/dist/components/number-field.js.map +1 -0
  104. package/dist/components/number-input.d.ts +28 -2
  105. package/dist/components/number-input.js +12 -2
  106. package/dist/components/number-input.js.map +1 -1
  107. package/dist/components/numpad-sheet.d.ts +32 -4
  108. package/dist/components/numpad-sheet.js +30 -13
  109. package/dist/components/numpad-sheet.js.map +1 -1
  110. package/dist/components/page-contents.d.ts +90 -0
  111. package/dist/components/page-contents.js +170 -0
  112. package/dist/components/page-contents.js.map +1 -0
  113. package/dist/components/password-strength.d.ts +106 -0
  114. package/dist/components/password-strength.js +132 -0
  115. package/dist/components/password-strength.js.map +1 -0
  116. package/dist/components/picker-sheet.d.ts +23 -15
  117. package/dist/components/picker-sheet.js +34 -14
  118. package/dist/components/picker-sheet.js.map +1 -1
  119. package/dist/components/popover.d.ts +62 -4
  120. package/dist/components/popover.js +52 -2
  121. package/dist/components/popover.js.map +1 -1
  122. package/dist/components/search-field.d.ts +25 -7
  123. package/dist/components/search-field.js +7 -5
  124. package/dist/components/search-field.js.map +1 -1
  125. package/dist/components/series-chart-labels.d.ts +14 -0
  126. package/dist/components/series-chart-labels.js +11 -0
  127. package/dist/components/series-chart-labels.js.map +1 -0
  128. package/dist/components/series-chart.d.ts +207 -0
  129. package/dist/components/series-chart.js +223 -0
  130. package/dist/components/series-chart.js.map +1 -0
  131. package/dist/components/settings-fields.d.ts +14 -4
  132. package/dist/components/settings-fields.js +19 -8
  133. package/dist/components/settings-fields.js.map +1 -1
  134. package/dist/components/signature-pad.d.ts +119 -0
  135. package/dist/components/signature-pad.js +405 -0
  136. package/dist/components/signature-pad.js.map +1 -0
  137. package/dist/components/slider.d.ts +80 -0
  138. package/dist/components/slider.js +155 -0
  139. package/dist/components/slider.js.map +1 -0
  140. package/dist/components/sparkline.d.ts +93 -0
  141. package/dist/components/sparkline.js +205 -0
  142. package/dist/components/sparkline.js.map +1 -0
  143. package/dist/components/stat-tile.d.ts +151 -0
  144. package/dist/components/stat-tile.js +236 -0
  145. package/dist/components/stat-tile.js.map +1 -0
  146. package/dist/components/swipeable-row.d.ts +25 -2
  147. package/dist/components/swipeable-row.js +31 -0
  148. package/dist/components/swipeable-row.js.map +1 -1
  149. package/dist/components/switch.d.ts +60 -0
  150. package/dist/components/switch.js +81 -0
  151. package/dist/components/switch.js.map +1 -0
  152. package/dist/components/time-input.d.ts +58 -0
  153. package/dist/components/time-input.js +50 -0
  154. package/dist/components/time-input.js.map +1 -0
  155. package/dist/components/toggle-group.d.ts +18 -3
  156. package/dist/components/toggle-group.js +10 -6
  157. package/dist/components/toggle-group.js.map +1 -1
  158. package/dist/components/toggle-legend.d.ts +115 -0
  159. package/dist/components/toggle-legend.js +114 -0
  160. package/dist/components/toggle-legend.js.map +1 -0
  161. package/dist/components/tooltip.d.ts +40 -11
  162. package/dist/components/tooltip.js +77 -20
  163. package/dist/components/tooltip.js.map +1 -1
  164. package/dist/components/treemap.d.ts +132 -0
  165. package/dist/components/treemap.js +179 -0
  166. package/dist/components/treemap.js.map +1 -0
  167. package/dist/components/ui.d.ts +159 -46
  168. package/dist/components/ui.js +220 -115
  169. package/dist/components/ui.js.map +1 -1
  170. package/dist/components/use-mobile-reveal.js +1 -0
  171. package/dist/components/use-mobile-reveal.js.map +1 -1
  172. package/dist/components/use-table-state.d.ts +1 -1
  173. package/dist/components/use-table-state.js +38 -17
  174. package/dist/components/use-table-state.js.map +1 -1
  175. package/dist/components/user-avatar.d.ts +15 -4
  176. package/dist/components/user-avatar.js +5 -4
  177. package/dist/components/user-avatar.js.map +1 -1
  178. package/dist/components/wizard-stepper.js +3 -2
  179. package/dist/components/wizard-stepper.js.map +1 -1
  180. package/dist/{data-table-filters-aXyf0Xub.d.ts → data-table-filters-DM1auUfH.d.ts} +27 -5
  181. package/dist/data-table.d.ts +7 -0
  182. package/dist/data-table.js +12 -0
  183. package/dist/data-table.js.map +1 -0
  184. package/dist/feedback/feedback-attachment.js +12 -11
  185. package/dist/feedback/feedback-attachment.js.map +1 -1
  186. package/dist/feedback/feedback-dialog.js +2 -1
  187. package/dist/feedback/feedback-dialog.js.map +1 -1
  188. package/dist/feedback/feedback-inbox.d.ts +33 -7
  189. package/dist/feedback/feedback-inbox.js +49 -37
  190. package/dist/feedback/feedback-inbox.js.map +1 -1
  191. package/dist/feedback.d.ts +5 -0
  192. package/dist/feedback.js +5 -0
  193. package/dist/feedback.js.map +1 -0
  194. package/dist/hooks/use-anchored-panel.js +1 -0
  195. package/dist/hooks/use-anchored-panel.js.map +1 -1
  196. package/dist/hooks/use-anchored-rect.js +1 -0
  197. package/dist/hooks/use-anchored-rect.js.map +1 -1
  198. package/dist/hooks/use-announce.d.ts +46 -0
  199. package/dist/hooks/use-announce.js +37 -0
  200. package/dist/hooks/use-announce.js.map +1 -0
  201. package/dist/hooks/use-body-scroll-lock.js +1 -0
  202. package/dist/hooks/use-body-scroll-lock.js.map +1 -1
  203. package/dist/hooks/use-close-transition.js +1 -0
  204. package/dist/hooks/use-close-transition.js.map +1 -1
  205. package/dist/hooks/use-dismiss.js +1 -0
  206. package/dist/hooks/use-dismiss.js.map +1 -1
  207. package/dist/hooks/use-focus-trap.d.ts +16 -0
  208. package/dist/hooks/use-focus-trap.js +67 -0
  209. package/dist/hooks/use-focus-trap.js.map +1 -0
  210. package/dist/hooks/use-media-query.js +1 -0
  211. package/dist/hooks/use-media-query.js.map +1 -1
  212. package/dist/hooks/use-overlay-history.js +27 -1
  213. package/dist/hooks/use-overlay-history.js.map +1 -1
  214. package/dist/hooks/use-row-swipe.js +1 -0
  215. package/dist/hooks/use-row-swipe.js.map +1 -1
  216. package/dist/i18n/defaults.d.ts +31 -0
  217. package/dist/i18n/defaults.js +63 -0
  218. package/dist/i18n/defaults.js.map +1 -0
  219. package/dist/i18n/kit-labels.d.ts +250 -0
  220. package/dist/i18n/kit-labels.js +184 -0
  221. package/dist/i18n/kit-labels.js.map +1 -0
  222. package/dist/index.d.ts +47 -22
  223. package/dist/index.js +28 -0
  224. package/dist/index.js.map +1 -1
  225. package/dist/lib/calc.js +1 -0
  226. package/dist/lib/calc.js.map +1 -1
  227. package/dist/lib/cn.js +1 -0
  228. package/dist/lib/cn.js.map +1 -1
  229. package/dist/lib/dates.js +1 -0
  230. package/dist/lib/dates.js.map +1 -1
  231. package/dist/lib/logger.js +1 -0
  232. package/dist/lib/logger.js.map +1 -1
  233. package/dist/lib/safe-storage.js +1 -0
  234. package/dist/lib/safe-storage.js.map +1 -1
  235. package/dist/search/command-palette.d.ts +11 -1
  236. package/dist/search/command-palette.js +21 -15
  237. package/dist/search/command-palette.js.map +1 -1
  238. package/dist/search.d.ts +2 -0
  239. package/dist/search.js +3 -0
  240. package/dist/search.js.map +1 -0
  241. package/dist/shell/app-shell.d.ts +33 -4
  242. package/dist/shell/app-shell.js +275 -78
  243. package/dist/shell/app-shell.js.map +1 -1
  244. package/dist/shell/option-switcher-menu.js +3 -2
  245. package/dist/shell/option-switcher-menu.js.map +1 -1
  246. package/dist/shell/role-switcher.d.ts +6 -0
  247. package/dist/shell/role-switcher.js +7 -4
  248. package/dist/shell/role-switcher.js.map +1 -1
  249. package/dist/shell/top-bar.d.ts +15 -7
  250. package/dist/shell/top-bar.js +3 -5
  251. package/dist/shell/top-bar.js.map +1 -1
  252. package/dist/shell/topbar-action-menu.js +3 -2
  253. package/dist/shell/topbar-action-menu.js.map +1 -1
  254. package/dist/shell/topbar-controls.d.ts +5 -2
  255. package/dist/shell/topbar-controls.js +20 -15
  256. package/dist/shell/topbar-controls.js.map +1 -1
  257. package/dist/shell.d.ts +13 -0
  258. package/dist/shell.js +8 -0
  259. package/dist/shell.js.map +1 -0
  260. package/dist/theme/chart-palette.d.ts +19 -12
  261. package/dist/theme/chart-palette.js +24 -30
  262. package/dist/theme/chart-palette.js.map +1 -1
  263. package/dist/theme/color.d.ts +78 -0
  264. package/dist/theme/color.js +148 -0
  265. package/dist/theme/color.js.map +1 -0
  266. package/dist/theme/palette-derive.d.ts +140 -0
  267. package/dist/theme/palette-derive.js +376 -0
  268. package/dist/theme/palette-derive.js.map +1 -0
  269. package/dist/theme/palette-presets.d.ts +27 -5
  270. package/dist/theme/palette-presets.js +231 -33
  271. package/dist/theme/palette-presets.js.map +1 -1
  272. package/dist/theme/palette-store.js +1 -0
  273. package/dist/theme/palette-store.js.map +1 -1
  274. package/dist/theme/theme-store.js +1 -0
  275. package/dist/theme/theme-store.js.map +1 -1
  276. package/dist/tour/tour.d.ts +2 -1
  277. package/dist/tour/tour.js +15 -9
  278. package/dist/tour/tour.js.map +1 -1
  279. package/dist/tour.d.ts +2 -0
  280. package/dist/tour.js +3 -0
  281. package/dist/tour.js.map +1 -0
  282. package/dist/wizard/stepper-nav.js +10 -8
  283. package/dist/wizard/stepper-nav.js.map +1 -1
  284. package/dist/wizard/types.d.ts +18 -2
  285. package/dist/wizard/types.js +4 -1
  286. package/dist/wizard/types.js.map +1 -1
  287. package/dist/wizard/use-wizard.js +12 -4
  288. package/dist/wizard/use-wizard.js.map +1 -1
  289. package/dist/wizard/validation.js +1 -0
  290. package/dist/wizard/validation.js.map +1 -1
  291. package/dist/wizard/wizard-context.js +1 -0
  292. package/dist/wizard/wizard-context.js.map +1 -1
  293. package/dist/wizard/wizard-step.js +1 -0
  294. package/dist/wizard/wizard-step.js.map +1 -1
  295. package/dist/wizard/wizard-summary.js +5 -3
  296. package/dist/wizard/wizard-summary.js.map +1 -1
  297. package/dist/wizard.d.ts +8 -0
  298. package/dist/wizard.js +9 -0
  299. package/dist/wizard.js.map +1 -0
  300. package/package.json +67 -14
  301. package/src/chart.ts +22 -0
  302. package/src/components/account-settings.tsx +275 -0
  303. package/src/components/alert-banner.tsx +75 -0
  304. package/src/components/amount-input.tsx +471 -0
  305. package/src/components/calculator.tsx +252 -0
  306. package/src/components/chart-zoom.tsx +499 -0
  307. package/src/components/chart.tsx +363 -0
  308. package/src/components/checkbox.tsx +222 -0
  309. package/src/components/chip.tsx +570 -0
  310. package/src/components/choice-parts.ts +33 -0
  311. package/src/components/combobox-core.tsx +540 -0
  312. package/src/components/combobox.tsx +1026 -0
  313. package/src/components/currency-select.tsx +269 -0
  314. package/src/components/data-table-filter-popover.tsx +252 -0
  315. package/src/components/data-table-filters.ts +176 -0
  316. package/src/components/data-table-labels.ts +190 -0
  317. package/src/components/data-table-pagination.tsx +126 -0
  318. package/src/components/data-table-sort.ts +70 -0
  319. package/src/components/data-table.tsx +1740 -0
  320. package/src/components/date-picker.tsx +631 -0
  321. package/src/components/dropdown.tsx +341 -0
  322. package/src/components/entity-combobox.tsx +215 -0
  323. package/src/components/facing-pair.ts +86 -0
  324. package/src/components/field-sync.tsx +472 -0
  325. package/src/components/file-dropzone.tsx +181 -0
  326. package/src/components/full-bleed-dialog.tsx +179 -0
  327. package/src/components/grouped-picker.tsx +157 -0
  328. package/src/components/hover-menu.tsx +183 -0
  329. package/src/components/mini-calendar.tsx +554 -0
  330. package/src/components/modal.tsx +287 -0
  331. package/src/components/month-picker.tsx +549 -0
  332. package/src/components/multi-entity-combobox.tsx +226 -0
  333. package/src/components/multi-select.tsx +303 -0
  334. package/src/components/number-field.tsx +225 -0
  335. package/src/components/number-input.tsx +211 -0
  336. package/src/components/numpad-sheet.tsx +264 -0
  337. package/src/components/page-contents.tsx +289 -0
  338. package/src/components/password-strength.tsx +258 -0
  339. package/src/components/picker-sheet.tsx +251 -0
  340. package/src/components/popover.tsx +195 -0
  341. package/src/components/search-field.tsx +146 -0
  342. package/src/components/series-chart-labels.ts +26 -0
  343. package/src/components/series-chart.tsx +481 -0
  344. package/src/components/settings-fields.tsx +68 -0
  345. package/src/components/signature-pad.tsx +630 -0
  346. package/src/components/slider.tsx +313 -0
  347. package/src/components/sparkline.tsx +329 -0
  348. package/src/components/stat-tile.tsx +448 -0
  349. package/src/components/swipeable-row.tsx +271 -0
  350. package/src/components/switch.tsx +170 -0
  351. package/src/components/time-input.tsx +107 -0
  352. package/src/components/toggle-group.tsx +153 -0
  353. package/src/components/toggle-legend.tsx +229 -0
  354. package/src/components/tooltip.tsx +477 -0
  355. package/src/components/treemap.tsx +390 -0
  356. package/src/components/ui.tsx +1284 -0
  357. package/src/components/use-mobile-reveal.ts +54 -0
  358. package/src/components/use-table-state.ts +295 -0
  359. package/src/components/user-avatar.tsx +61 -0
  360. package/src/components/wizard-stepper.tsx +37 -0
  361. package/src/data-table.ts +19 -0
  362. package/src/feedback/feedback-attachment.tsx +246 -0
  363. package/src/feedback/feedback-dialog.tsx +209 -0
  364. package/src/feedback/feedback-inbox.tsx +615 -0
  365. package/src/feedback.ts +8 -0
  366. package/src/hooks/use-anchored-panel.ts +169 -0
  367. package/src/hooks/use-anchored-rect.ts +78 -0
  368. package/src/hooks/use-announce.ts +85 -0
  369. package/src/hooks/use-body-scroll-lock.ts +94 -0
  370. package/src/hooks/use-close-transition.ts +99 -0
  371. package/src/hooks/use-dismiss.ts +77 -0
  372. package/src/hooks/use-focus-trap.ts +160 -0
  373. package/src/hooks/use-media-query.ts +23 -0
  374. package/src/hooks/use-overlay-history.ts +345 -0
  375. package/src/hooks/use-row-swipe.ts +212 -0
  376. package/src/i18n/defaults.ts +68 -0
  377. package/src/i18n/kit-labels.tsx +447 -0
  378. package/src/index.ts +169 -0
  379. package/src/lib/calc.ts +309 -0
  380. package/src/lib/cn.ts +5 -0
  381. package/src/lib/dates.ts +187 -0
  382. package/src/lib/logger.ts +73 -0
  383. package/src/lib/safe-storage.ts +37 -0
  384. package/src/search/command-palette.tsx +342 -0
  385. package/src/search.ts +3 -0
  386. package/src/shell/app-shell.tsx +772 -0
  387. package/src/shell/option-switcher-menu.tsx +72 -0
  388. package/src/shell/role-switcher.tsx +46 -0
  389. package/src/shell/top-bar.tsx +39 -0
  390. package/src/shell/topbar-action-menu.tsx +91 -0
  391. package/src/shell/topbar-controls.tsx +229 -0
  392. package/src/shell.ts +10 -0
  393. package/src/theme/chart-palette.ts +145 -0
  394. package/src/theme/color.ts +266 -0
  395. package/src/theme/palette-derive.ts +681 -0
  396. package/src/theme/palette-presets.ts +760 -0
  397. package/src/theme/palette-store.ts +94 -0
  398. package/src/theme/theme-store.ts +145 -0
  399. package/src/tour/tour.tsx +586 -0
  400. package/src/tour.ts +3 -0
  401. package/src/vite-env.d.ts +26 -0
  402. package/src/wizard/stepper-nav.tsx +221 -0
  403. package/src/wizard/types.ts +178 -0
  404. package/src/wizard/use-wizard.ts +345 -0
  405. package/src/wizard/validation.ts +37 -0
  406. package/src/wizard/wizard-context.tsx +29 -0
  407. package/src/wizard/wizard-step.tsx +14 -0
  408. package/src/wizard/wizard-summary.tsx +69 -0
  409. package/src/wizard.ts +14 -0
  410. package/tokens.css +222 -20
@@ -0,0 +1,481 @@
1
+ // The measurement plot: series against a swept numeric abscissa, any number of y axes,
2
+ // dash patterns, vertical spans, and drag-to-zoom. Lifted out of lenkbank, where every
3
+ // chart on every screen is this one arrangement of the kit's shell — so the arrangement
4
+ // lives here once instead of as thirteen hand-assembled copies drifting apart.
5
+ //
6
+ // Built on `ChartContainer` + `ChartTooltipContent`, so it themes with the rest of the
7
+ // chart kit: series default to the `--chart-N` ramp, axis text and grid take the
8
+ // shell's tokens. It stays domain-free — every unit, title and number format is the
9
+ // caller's, and the few words it can say itself are the `seriesChart` label namespace.
10
+ //
11
+ // **Every series may keep its own scale.** Position runs in millimetres and jerk in
12
+ // hundreds of thousands of them per second cubed; on one axis the position line is
13
+ // flat against the bottom. Normalising each series to its peak makes the y axis mean
14
+ // nothing, so a caller declares an axis per incomparable channel instead, and may hide
15
+ // all but the ones worth a column of numbers.
16
+ //
17
+ // Charts are drawn left-to-right in every writing direction — an abscissa is a number
18
+ // line, not text — so `orientation` below is physical, and only the HTML chrome (the
19
+ // reset button) is placed by logical side.
20
+ import { useMemo } from "react";
21
+ import type { ReactNode } from "react";
22
+ import { CartesianGrid, Label, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts";
23
+ import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "./chart";
24
+ import { DEFAULT_Y_AXIS, withChartZoom, type ZoomBinding } from "./chart-zoom";
25
+ import { STEP_DASH, strokeDash } from "./toggle-legend";
26
+ import { DEFAULT_SERIES_CHART_LABELS, type SeriesChartLabels } from "./series-chart-labels";
27
+ import { paletteFor } from "../theme/chart-palette";
28
+ import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
29
+ import { cn } from "../lib/cn";
30
+
31
+ export interface SeriesChartSeries {
32
+ /** The row key this line reads — and the `--color-<key>` it is painted with, so it
33
+ * must be a CSS identifier. Build keys out of data with {@link seriesKey}. */
34
+ key: string;
35
+ label: ReactNode;
36
+ /** Any CSS colour the chart shell accepts (hex, rgb/hsl, `var(--token)`). Default:
37
+ * the kit's categorical ramp by position, `paletteFor(index)`. */
38
+ color?: string;
39
+ /** Marks the PAIRED line of a pair — the return stroke against the out-stroke, the
40
+ * reference against the measurement. Shorthand for `dash: 1`. */
41
+ dashed?: boolean;
42
+ /** Which of {@link STROKE_PATTERNS} this line takes, for charts where the stroke says
43
+ * WHICH QUANTITY and the colour says WHICH MEASUREMENT. */
44
+ dash?: number;
45
+ /** `stepAfter`, for a whole-number channel that jumps rather than travels — a
46
+ * straight line between index 0 and index 1 draws an index of 0.5, which does not
47
+ * exist. Drawn in the {@link STEP_DASH} pattern, whatever `dash` says. */
48
+ step?: boolean;
49
+ /** Which of `axes` this line is measured on. Default: `"y"`, the single one. */
50
+ axis?: string;
51
+ }
52
+
53
+ interface SeriesChartAxisShape {
54
+ id: string;
55
+ /**
56
+ * Which side of the plot the ticks stand on. Left unless said otherwise — and said
57
+ * otherwise for the right-hand chart of a FACING pair (see `facingAxes`), whose
58
+ * scales go on the outside so neither column of numbers stands between two pictures
59
+ * being compared.
60
+ */
61
+ orientation?: "left" | "right";
62
+ /** Tints the ticks — and the title too, when exactly one series is measured on this
63
+ * axis (see {@link soleSeriesColor}). */
64
+ color?: string;
65
+ /** What the TICKS need, in px. The title's strip is added on top. Default 48. */
66
+ width?: number;
67
+ /**
68
+ * Pin the scale instead of fitting it to this chart's data. For a ROW of charts
69
+ * read against each other: auto-fitted, a 200 N loop and a 2000 N one draw the
70
+ * identical picture, and the comparison the layout promises is the one it fails.
71
+ */
72
+ domain?: [number, number];
73
+ /** A tick, formatted — as a BARE number. The unit is said once, in the title; the
74
+ * tooltip gets it from `valueFormat`. Default: `Intl.NumberFormat` in the kit's
75
+ * locale, up to two fraction digits. */
76
+ format?: (value: number) => string;
77
+ }
78
+
79
+ /**
80
+ * A y axis — and a VISIBLE one says what it measures.
81
+ *
82
+ * A union rather than an optional field, so the rule is the type's: a column of numbers
83
+ * whose quantity and unit are not written down is a column somebody reads as whatever
84
+ * they expected. `title: ""` is the explicit opt-out (the right-hand chart of a facing
85
+ * pair, whose neighbour already said it). A HIDDEN axis scales its lines and draws
86
+ * nothing, so there is nowhere for a title to go.
87
+ */
88
+ export type SeriesChartAxis = SeriesChartAxisShape &
89
+ ({ title: string; hide?: false } | { title?: string; hide: true });
90
+
91
+ export interface SeriesChartX {
92
+ /** Row key of the abscissa. Default `"x"`, which is what {@link mergeSeries} writes. */
93
+ key?: string;
94
+ /** What the abscissa is, with its unit. Drawn under the axis. */
95
+ title?: string;
96
+ /** A tick, formatted — bare, like the y axes'. */
97
+ format?: (value: number) => string;
98
+ /**
99
+ * Whether this chart prints the abscissa's ticks. On by default; off for every chart
100
+ * of a stack sharing one x window but the bottom one, which prints them for all. It
101
+ * costs no alignment: the x mapping is decided by the margins and the y bands.
102
+ */
103
+ ticks?: boolean;
104
+ /** The tooltip's heading. Default: the abscissa value through `format`. */
105
+ label?: (value: number) => ReactNode;
106
+ }
107
+
108
+ /**
109
+ * A vertical segment in data coordinates — two y at one x, which a row-per-abscissa
110
+ * chart structurally cannot draw. For CLOSING a shape the series already drew: the
111
+ * turnarounds of a hysteresis loop, where the abscissa stands still while the value
112
+ * falls from one branch to the other.
113
+ */
114
+ export interface SeriesChartSpan {
115
+ /** Distinct within the chart. */
116
+ key: string;
117
+ /** Which y axis `from`/`to` are measured on. Default `"y"`. */
118
+ axis?: string;
119
+ x: number;
120
+ from: number;
121
+ to: number;
122
+ /** Default: `var(--text-muted)`. */
123
+ color?: string;
124
+ }
125
+
126
+ export interface SeriesChartProps {
127
+ /** One row per abscissa value. A missing key is a hole (see `connectNulls`). */
128
+ rows: Record<string, number>[];
129
+ series: SeriesChartSeries[];
130
+ /** Default: one untitled axis, `"y"`. */
131
+ axes?: SeriesChartAxis[];
132
+ x?: SeriesChartX;
133
+ /** A value in the tooltip, where a number stands on its own and needs its unit.
134
+ * Default: `Intl.NumberFormat` in the kit's locale. */
135
+ valueFormat?: (value: number) => string;
136
+ /** Tailwind height class. Default `h-72`. The empty state takes it too, so a chart
137
+ * losing its last line does not relayout the page under it. */
138
+ height?: string;
139
+ /** Bridge holes left by sources sampled on different grids. See {@link mergeSeries}. */
140
+ connectNulls?: boolean;
141
+ /** Shown, centred at the chart's height, instead of an empty chart. Default: the
142
+ * `seriesChart.empty` label. `null` shows nothing. */
143
+ empty?: ReactNode;
144
+ /**
145
+ * Milliseconds for a line to draw itself in. Off by default: a chart beside a live
146
+ * editor re-renders on every keystroke, and an animation restarted mid-flight reads
147
+ * as the curve twitching. Turn it on where series come and go by choice.
148
+ */
149
+ animationMs?: number;
150
+ spans?: SeriesChartSpan[];
151
+ /** Supplied by `withChartZoom` and by nothing else. */
152
+ zoom?: ZoomBinding;
153
+ /** Per-chart strings over `<UiKitProvider labels={{ seriesChart }}>`. */
154
+ labels?: Partial<SeriesChartLabels>;
155
+ /** BCP 47 tag for the default number formats. Default: the provider's. */
156
+ locale?: string;
157
+ /** Onto the chart's root (`ChartContainer`) — e.g. an app's own grid ink. */
158
+ className?: string;
159
+ }
160
+
161
+ /** How much of an axis band the rotated title takes, in px. Reserved rather than
162
+ * shared with the ticks: a title merely offset inside the band prints on top of a
163
+ * five-figure tick, and which gives way depends on the data. */
164
+ export const AXIS_TITLE_STRIP = 16;
165
+
166
+ /** Where inside its strip the rotated title sits. */
167
+ const AXIS_TITLE_OFFSET = 10;
168
+
169
+ /** The width the ticks of a y axis get when a caller does not say. */
170
+ export const AXIS_TICK_WIDTH = 48;
171
+
172
+ /**
173
+ * How much air a FITTED axis leaves between the data and its frame, as a fraction of
174
+ * the range. Recharts fits `[dataMin, dataMax]`, which draws a peak half a stroke
175
+ * outside the plot and a curve running to its limit indistinguishable from the frame.
176
+ */
177
+ const AUTO_PAD = 0.04;
178
+
179
+ /** The band a fitted axis draws, padded — and a real band for constant data, which is
180
+ * a zero-height domain recharts cannot scale into. `undefined` when nothing finite. */
181
+ export function paddedDomain(values: Iterable<number>): [number, number] | undefined {
182
+ let low = Infinity;
183
+ let high = -Infinity;
184
+ for (const value of values) {
185
+ if (!Number.isFinite(value)) continue;
186
+ if (value < low) low = value;
187
+ if (value > high) high = value;
188
+ }
189
+ return padBand(low, high);
190
+ }
191
+
192
+ /**
193
+ * {@link paddedDomain} for a caller that has already walked its own data — so a row
194
+ * of charts can share one scale without building an array to find two numbers in, and
195
+ * without a second padding rule. `low > high` means "nothing was seen": `undefined`.
196
+ */
197
+ export function padBand(low: number, high: number): [number, number] | undefined {
198
+ if (!(low <= high)) return undefined;
199
+ const pad = (high - low) * AUTO_PAD || Math.abs(high) * AUTO_PAD || 1;
200
+ return [low - pad, high + pad];
201
+ }
202
+
203
+ /** The whole band a y axis occupies: its ticks, and its title when it draws one. For
204
+ * lining a heading up over a PLOT rather than over its column. */
205
+ export function axisBandWidth(tickWidth: number | undefined, titled: boolean): number {
206
+ return (tickWidth ?? AXIS_TICK_WIDTH) + (titled ? AXIS_TITLE_STRIP : 0);
207
+ }
208
+
209
+ /**
210
+ * The colour an axis TITLE takes, or `undefined` for the neutral one.
211
+ *
212
+ * An axis measuring exactly one line is that line's, and saying so in the title lets a
213
+ * reader find a channel's scale without counting axes. An axis measuring several is
214
+ * nobody's — a title in the first one's colour would be claiming it. Counted off the
215
+ * series, so toggling a second line onto an axis gives the colour up in the same render.
216
+ */
217
+ export function soleSeriesColor(
218
+ axis: Pick<SeriesChartAxisShape, "id" | "color">,
219
+ series: readonly Pick<SeriesChartSeries, "axis">[],
220
+ ): string | undefined {
221
+ const mine = series.filter((entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id);
222
+ return mine.length === 1 ? axis.color : undefined;
223
+ }
224
+
225
+ /** The single-axis case, which is most charts. `width` is what the TICKS need;
226
+ * `orientation` is which side they stand on. */
227
+ export function oneAxis(
228
+ format: ((value: number) => string) | undefined,
229
+ title: string,
230
+ width?: number,
231
+ orientation?: "left" | "right",
232
+ ): SeriesChartAxis[] {
233
+ return [{ id: DEFAULT_Y_AXIS, format, title, width, orientation }];
234
+ }
235
+
236
+ /** One source's samples: an abscissa and the channels measured against it. */
237
+ export interface SeriesSource {
238
+ x: number[];
239
+ channels: Record<string, number[]>;
240
+ }
241
+
242
+ /**
243
+ * Merge sources sampled on different grids into rows the chart can draw.
244
+ *
245
+ * Merged ON THE ABSCISSA VALUE ITSELF, never interpolated onto a common grid: a line
246
+ * through invented points looks exactly like one through measured ones. Where a source
247
+ * has no sample the key is absent, and `connectNulls` bridges the hole with the same
248
+ * straight segment an interpolation would have drawn — without claiming a measurement.
249
+ */
250
+ export function mergeSeries(sources: SeriesSource[]): Record<string, number>[] {
251
+ const byAbscissa = new Map<number, Record<string, number>>();
252
+ for (const source of sources) {
253
+ source.x.forEach((value, index) => {
254
+ const row = byAbscissa.get(value) ?? { x: value };
255
+ for (const [key, channel] of Object.entries(source.channels)) row[key] = channel[index];
256
+ byAbscissa.set(value, row);
257
+ });
258
+ }
259
+ return [...byAbscissa.values()].sort((a, b) => a.x - b.x);
260
+ }
261
+
262
+ /**
263
+ * A series key built out of parts — as a CSS identifier.
264
+ *
265
+ * The chart shell paints each series through `--color-<key>`, and skips (with a
266
+ * console warning) any key that is not `[A-Za-z_][A-Za-z0-9_-]*`. A line whose colour
267
+ * was skipped has no stroke at all: an axis, a grid and nothing between them. So a key
268
+ * assembled out of DATA — an id, an enum, a side — goes through here: every other
269
+ * character becomes `_`, the parts are joined with `_`, and a key that would start with
270
+ * a digit or a hyphen gets a leading `_`.
271
+ */
272
+ export function seriesKey(...parts: (string | number)[]): string {
273
+ const key = parts.map((part) => String(part).replace(/[^A-Za-z0-9_-]/g, "_")).join("_");
274
+ return /^[A-Za-z_]/.test(key) ? key : `_${key}`;
275
+ }
276
+
277
+ /** The number format every default falls back to. */
278
+ function useDefaultFormat(localeProp: string | undefined): (value: number) => string {
279
+ const locale = useKitLocale(localeProp);
280
+ return useMemo(() => {
281
+ const format = new Intl.NumberFormat(locale, { maximumFractionDigits: 2 });
282
+ return (value: number) => format.format(value);
283
+ }, [locale]);
284
+ }
285
+
286
+ /** The Y-axis list a chart draws when the caller gave none. */
287
+ const ONE_UNTITLED_AXIS: SeriesChartAxis[] = [{ id: DEFAULT_Y_AXIS, title: "" }];
288
+
289
+ /**
290
+ * {@link SeriesChart} without the zoom: the same picture, no drag layer, no reset
291
+ * button. For a thumbnail, a print view, or a chart the consumer wraps in its own
292
+ * interaction.
293
+ */
294
+ export function StaticSeriesChart({
295
+ rows,
296
+ x = {},
297
+ series,
298
+ axes = ONE_UNTITLED_AXIS,
299
+ valueFormat,
300
+ height = "h-72",
301
+ connectNulls,
302
+ empty,
303
+ animationMs = 0,
304
+ spans,
305
+ zoom,
306
+ labels: labelsProp,
307
+ locale,
308
+ className,
309
+ }: SeriesChartProps) {
310
+ const labels = useKitLabels("seriesChart", DEFAULT_SERIES_CHART_LABELS, labelsProp);
311
+ const number = useDefaultFormat(locale);
312
+
313
+ // The empty state takes the chart's own height and is centred in it: a chart that
314
+ // shrank to its "nothing to draw" sentence moved everything under it up the page
315
+ // the moment a legend entry was switched off.
316
+ if (!rows.length || !series.length) {
317
+ return (
318
+ <div className={cn("flex w-full items-center justify-center px-2 text-center", height)}>
319
+ {empty !== undefined ? (
320
+ empty
321
+ ) : (
322
+ <p className="text-xs text-[var(--text-muted)]">{labels.empty}</p>
323
+ )}
324
+ </div>
325
+ );
326
+ }
327
+
328
+ const xKey = x.key ?? "x";
329
+ const xFormat = x.format ?? number;
330
+ const config: ChartConfig = Object.fromEntries(
331
+ series.map((entry, index) => [
332
+ entry.key,
333
+ { label: entry.label, color: entry.color ?? paletteFor(index) },
334
+ ]),
335
+ );
336
+ const visible = axes.filter((axis) => !axis.hide);
337
+ // The grid hangs its horizontal rules off ONE y axis, and recharts looks for the one
338
+ // whose id is its own default (`0`). Every axis here is named, so without saying
339
+ // which, it finds none and draws no horizontal rules at all. The first visible axis,
340
+ // because those are the ticks a reader puts a ruler on.
341
+ const gridAxis = (visible[0] ?? axes[0])?.id ?? DEFAULT_Y_AXIS;
342
+ const onLeft = visible.some((axis) => (axis.orientation ?? "left") === "left");
343
+ const onRight = visible.some((axis) => axis.orientation === "right");
344
+ // The margins are what is left once the axes have their bands: an axis band IS the
345
+ // gutter on its side, so a margin beside one is a second gutter. What remains is the
346
+ // overhang of the first/last x tick where no y axis covers it.
347
+ const margin = {
348
+ top: 8,
349
+ right: onRight ? 0 : 10,
350
+ left: onLeft ? 0 : 10,
351
+ // Under the ticks, not under the whole axis: whatever comes next owns the gap.
352
+ bottom: x.title ? 18 : 2,
353
+ };
354
+
355
+ // Fitted with air around it, unless the reader has zoomed or the caller pinned it.
356
+ const fittedX = zoom?.xDomain ?? paddedDomain(rows.map((row) => row[xKey]));
357
+ const fittedY = (axis: SeriesChartAxis) =>
358
+ zoom?.yDomains[axis.id] ??
359
+ axis.domain ??
360
+ paddedDomain(
361
+ series
362
+ .filter((entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id)
363
+ .flatMap((entry) => rows.map((row) => row[entry.key])),
364
+ );
365
+
366
+ return (
367
+ <ChartContainer config={config} className={cn("w-full", height, className)}>
368
+ <LineChart data={rows} margin={margin}>
369
+ {/* Both ways: a measurement plot is read by putting a ruler on it, and a
370
+ horizontal-only grid answers half of those questions. */}
371
+ <CartesianGrid yAxisId={gridAxis} />
372
+ <XAxis
373
+ dataKey={xKey}
374
+ // Numeric, not categorical: a measured sweep is unevenly spaced, and a
375
+ // category axis would straighten exactly the curvature the chart is for.
376
+ type="number"
377
+ domain={fittedX ?? ["dataMin", "dataMax"]}
378
+ // Clip the lines to a zoom window instead of recharts widening it back out.
379
+ allowDataOverflow={zoom?.xDomain !== undefined}
380
+ tickLine={false}
381
+ axisLine={false}
382
+ minTickGap={32}
383
+ tick={x.ticks === false ? false : undefined}
384
+ // With neither ticks nor title there is nothing to reserve the band for, and
385
+ // recharts' own 30 px would leave a gap under every chart of a stack.
386
+ height={x.ticks === false && !x.title ? 4 : undefined}
387
+ tickFormatter={xFormat}
388
+ >
389
+ {x.title && (
390
+ <Label
391
+ value={x.title}
392
+ position="insideBottom"
393
+ offset={-12}
394
+ className="fill-[var(--text-muted)] text-[11px]"
395
+ />
396
+ )}
397
+ </XAxis>
398
+ {axes.map((axis) => (
399
+ <YAxis
400
+ key={axis.id}
401
+ yAxisId={axis.id}
402
+ hide={axis.hide}
403
+ domain={fittedY(axis)}
404
+ allowDataOverflow={zoom?.yDomains[axis.id] !== undefined}
405
+ orientation={axis.orientation ?? "left"}
406
+ width={axisBandWidth(axis.width, Boolean(axis.title) && !axis.hide)}
407
+ tickLine={false}
408
+ axisLine={false}
409
+ tickFormatter={axis.format ?? number}
410
+ // With an axis per channel the tint is the only thing saying which
411
+ // numbers belong to which line.
412
+ tick={axis.color ? { fill: axis.color } : undefined}
413
+ >
414
+ {axis.title && !axis.hide && (
415
+ <Label
416
+ value={axis.title}
417
+ // Rotated so both sides read upward from the bottom of their axis.
418
+ angle={axis.orientation === "right" ? 90 : -90}
419
+ position={axis.orientation === "right" ? "insideRight" : "insideLeft"}
420
+ // Inline, so a sole series' colour beats the class's neutral fill —
421
+ // and absent for a shared axis, so the class draws it.
422
+ style={{ textAnchor: "middle", fill: soleSeriesColor(axis, series) }}
423
+ offset={AXIS_TITLE_OFFSET}
424
+ className="fill-[var(--text-muted)] text-[11px]"
425
+ />
426
+ )}
427
+ </YAxis>
428
+ ))}
429
+ <ChartTooltip
430
+ content={
431
+ <ChartTooltipContent
432
+ labelFormatter={(value) => (x.label ?? xFormat)(Number(value))}
433
+ valueFormatter={valueFormat ?? number}
434
+ />
435
+ }
436
+ />
437
+ {series.map((entry) => (
438
+ <Line
439
+ key={entry.key}
440
+ yAxisId={entry.axis ?? DEFAULT_Y_AXIS}
441
+ type={entry.step ? "stepAfter" : "monotone"}
442
+ dataKey={entry.key}
443
+ stroke={`var(--color-${entry.key})`}
444
+ strokeWidth={entry.step ? 1.5 : 2}
445
+ strokeDasharray={strokeDash(
446
+ entry.step ? STEP_DASH : (entry.dash ?? (entry.dashed ? 1 : 0)),
447
+ )}
448
+ dot={false}
449
+ connectNulls={connectNulls}
450
+ isAnimationActive={animationMs > 0}
451
+ animationDuration={animationMs}
452
+ animationEasing="ease-out"
453
+ />
454
+ ))}
455
+ {spans?.map((span) => (
456
+ <ReferenceLine
457
+ key={span.key}
458
+ yAxisId={span.axis ?? DEFAULT_Y_AXIS}
459
+ segment={[
460
+ { x: span.x, y: span.from },
461
+ { x: span.x, y: span.to },
462
+ ]}
463
+ stroke={span.color ?? "var(--text-muted)"}
464
+ strokeWidth={2}
465
+ // Discarded rather than clamped when zoomed past: a span clamped to the
466
+ // window's edge draws a vertical line at an abscissa it never stood at.
467
+ ifOverflow="discard"
468
+ />
469
+ ))}
470
+ {zoom?.layer}
471
+ </LineChart>
472
+ </ChartContainer>
473
+ );
474
+ }
475
+
476
+ /**
477
+ * The series chart, zoomable. Drag across the plot to zoom (the drag's shape picks the
478
+ * axes), double-click or press the reset button to go back; wrap several in
479
+ * `SharedXZoom` to move their x windows together.
480
+ */
481
+ export const SeriesChart = withChartZoom(StaticSeriesChart);
@@ -0,0 +1,68 @@
1
+ import type { ReactNode } from "react";
2
+ import { Select } from "./ui";
3
+ import type { SelectProps } from "./ui";
4
+ import type { ThemePreference } from "../theme/theme-store";
5
+
6
+ /**
7
+ * App-agnostic settings-field components (feedback #333): the language + theme
8
+ * controls that every app's settings page needs, so they live in @hb/ui and the
9
+ * app just supplies the current value, a change handler and translated labels.
10
+ * The app owns WHERE these render (its own settings page) and any app-specific
11
+ * fields around them.
12
+ */
13
+
14
+ /**
15
+ * Everything a {@link Select} takes, minus the two this component owns: the value is a
16
+ * {@link ThemePreference} rather than a string, and `onChange` hands over the preference
17
+ * itself because no caller of this wants the event. The rest — `disabled`, `required`,
18
+ * `invalid`, `error`, a `data-tour` anchor — passes straight through, which is what makes
19
+ * this a settings FIELD rather than a fixed widget.
20
+ */
21
+ export interface ThemeSettingProps
22
+ extends Omit<SelectProps, "value" | "onChange" | "children"> {
23
+ value: ThemePreference;
24
+ onChange: (value: ThemePreference) => void;
25
+ label: ReactNode;
26
+ /** Translated option labels. */
27
+ optionLabels: { system: string; light: string; dark: string };
28
+ id?: string;
29
+ }
30
+
31
+ export function ThemeSetting({ value, onChange, label, optionLabels, id, ...rest }: ThemeSettingProps) {
32
+ return (
33
+ <Select
34
+ {...rest}
35
+ id={id}
36
+ label={label}
37
+ value={value}
38
+ onChange={(e) => onChange(e.target.value as ThemePreference)}
39
+ >
40
+ <option value="system">{optionLabels.system}</option>
41
+ <option value="light">{optionLabels.light}</option>
42
+ <option value="dark">{optionLabels.dark}</option>
43
+ </Select>
44
+ );
45
+ }
46
+
47
+ /** See {@link ThemeSettingProps}: a `Select`'s props with the value and the change
48
+ * handler re-typed to the language CODE the caller stores. */
49
+ export interface LanguageSettingProps
50
+ extends Omit<SelectProps, "value" | "onChange" | "children"> {
51
+ value: string;
52
+ onChange: (code: string) => void;
53
+ label: ReactNode;
54
+ options: { code: string; label: string }[];
55
+ id?: string;
56
+ }
57
+
58
+ export function LanguageSetting({ value, onChange, label, options, id, ...rest }: LanguageSettingProps) {
59
+ return (
60
+ <Select {...rest} id={id} label={label} value={value} onChange={(e) => onChange(e.target.value)}>
61
+ {options.map((o) => (
62
+ <option key={o.code} value={o.code}>
63
+ {o.label}
64
+ </option>
65
+ ))}
66
+ </Select>
67
+ );
68
+ }