@eifi1/ui-kit 0.4.1 → 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 +89 -19
  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 +74 -19
  63. package/dist/components/dropdown.js +72 -22
  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 +21 -7
  75. package/dist/components/file-dropzone.js +28 -7
  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 +21 -2
  105. package/dist/components/number-input.js +2 -1
  106. package/dist/components/number-input.js.map +1 -1
  107. package/dist/components/numpad-sheet.d.ts +25 -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 +5 -3
  176. package/dist/components/user-avatar.js +4 -2
  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 +14 -1
  270. package/dist/theme/palette-presets.js +231 -3
  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 +66 -13
  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,271 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { useRowSwipe, type SwipeStage } from "../hooks/use-row-swipe";
5
+ import { DEFAULT_SWIPEABLE_ROW_LABELS, useKitLabels } from "../i18n/kit-labels";
6
+
7
+ /**
8
+ * One armed swipe action: what it does, and how the reveal panel presents it.
9
+ *
10
+ * The two background classes are the idle and armed states. Before the first
11
+ * threshold is crossed the panel previews the nearest action in its IDLE colour, so
12
+ * the user can see what a little more drag will do; crossing the threshold flips it to
13
+ * `armedClassName`. Passing the same value for both simply removes that feedback.
14
+ */
15
+ export interface SwipeAction {
16
+ onCommit: () => void;
17
+ label: string;
18
+ /** Rendered inside the panel. Icons stay the consumer's choice — this package
19
+ * ships no opinion about which icon set an app uses. */
20
+ icon?: ReactNode;
21
+ /** Background utility class while the action is previewed but not yet armed. */
22
+ className: string;
23
+ /** Background utility class once the drag has passed this action's threshold. */
24
+ armedClassName: string;
25
+ }
26
+
27
+ export interface SwipeableRowProps {
28
+ /** Actions committed by dragging RIGHT, nearest threshold first. */
29
+ right?: SwipeAction[];
30
+ /** Actions committed by dragging LEFT, nearest threshold first. */
31
+ left?: SwipeAction[];
32
+ /** Turn the gesture off — an open editor, a pending mutation, a locked row. This
33
+ * also withdraws the keyboard buttons below: a row that must not be acted on must
34
+ * not be actionable by ANY input. */
35
+ enabled?: boolean;
36
+ /** Names the revealed action buttons as a set ("Row actions"), for a reader that
37
+ * reaches the group before it reaches any one button. Default:
38
+ * `swipeableRow.actions` from the {@link UiKitProvider}, else English. */
39
+ actionsLabel?: string;
40
+ className?: string;
41
+ children: ReactNode;
42
+ }
43
+
44
+ /** How much of the row stays on screen at full drag. A row that can be pulled
45
+ * entirely off its own track reads as already deleted, and there is nothing left to
46
+ * drag back; a sliver keeps the gesture reversible and the row identifiable. */
47
+ const PEEK_PX = 40;
48
+
49
+ /**
50
+ * A row whose horizontal drag reveals actions underneath it.
51
+ *
52
+ * The reveal panel spans the FULL row width (Keksdose feedback #174). It used to be
53
+ * two half-width blocks side by side, which capped the drag at half the row so the
54
+ * opposite panel could never be exposed — and on a row bound to three actions that
55
+ * left ~55px between stages on a phone, close enough that the wrong one committed.
56
+ * Only the side being dragged toward is painted now, so full width costs nothing:
57
+ * at rest the content covers both anyway, and a drag only ever reveals one side.
58
+ *
59
+ * Thresholds are still not fixed pixel values — they are spread evenly across the
60
+ * available drag, which is now the row width less a {@link PEEK_PX} sliver. A fixed
61
+ * 72/150/228px ladder overshoots a narrow phone; a proportional one keeps every
62
+ * action reachable and roughly doubles the room between them.
63
+ *
64
+ * **Whether the action will fire is stated three ways, not one** (feedback #174): the
65
+ * panel goes from dimmed to solid, the icon grows and gains a filled disc, and the
66
+ * label turns bold — plus the existing haptic tick per newly armed stage. A colour
67
+ * shift alone is easy to miss mid-gesture, and it is invisible to anyone who cannot
68
+ * distinguish the two tones.
69
+ *
70
+ * Clicks that follow a drag are swallowed in the capture phase: without that, a swipe
71
+ * would also fire whatever click handler the row content carries (expanding it, opening
72
+ * a dialog) on top of the action it just committed.
73
+ *
74
+ * ## The keyboard path is buttons, and deliberately not a typed gesture
75
+ *
76
+ * Every action here used to live exclusively inside a pointer drag — `onCommit` had no
77
+ * other caller — so with a keyboard, with a screen reader, or on any input that cannot
78
+ * express 120px of horizontal travel, the row's actions did not exist at all. That is
79
+ * not a rough edge on a phone control: {@link DataTable} mounts this for every row of
80
+ * its mobile layout, and the actions an app puts here are its destructive ones.
81
+ *
82
+ * The rejected fix was to map the gesture onto keys — arrows nudging `dx`, a chord that
83
+ * "swipes". That invents a private idiom with nothing to discover it by, and it makes
84
+ * the keyboard imitate a touchscreen instead of doing the job the touchscreen is doing.
85
+ * So each action is ALSO a real button, `sr-only` until it takes focus and then shown at
86
+ * the row's trailing edge, firing the identical `onCommit`: one definition of what an
87
+ * action is, two ways to reach it, and nothing to drift apart. `sr-only` hides from
88
+ * the eye and not from the accessibility tree, so the row also ANNOUNCES what can be
89
+ * done to it rather than keeping it behind a gesture nobody can hear.
90
+ */
91
+ export function SwipeableRow({
92
+ right,
93
+ left,
94
+ enabled = true,
95
+ actionsLabel,
96
+ className,
97
+ children,
98
+ }: SwipeableRowProps) {
99
+ const labels = useKitLabels("swipeableRow", DEFAULT_SWIPEABLE_ROW_LABELS, {
100
+ actions: actionsLabel,
101
+ });
102
+ const rowRef = useRef<HTMLDivElement>(null);
103
+ const [rowWidth, setRowWidth] = useState(0);
104
+ useEffect(() => {
105
+ const el = rowRef.current;
106
+ if (!el) return;
107
+ setRowWidth(el.clientWidth);
108
+ if (typeof ResizeObserver === "undefined") return;
109
+ const ro = new ResizeObserver((entries) => {
110
+ for (const e of entries) setRowWidth(e.contentRect.width);
111
+ });
112
+ ro.observe(el);
113
+ return () => ro.disconnect();
114
+ }, []);
115
+
116
+ const rightActions = right ?? [];
117
+ const leftActions = left ?? [];
118
+ const active = enabled && (rightActions.length > 0 || leftActions.length > 0);
119
+ // The whole row less a sliver, not half of it (feedback #174). `Math.max` keeps a
120
+ // very narrow row (or one measured before layout) from producing a zero or
121
+ // negative drag, which would arm every stage at once.
122
+ const maxDrag = rowWidth > 0 ? Math.max(96, Math.round(rowWidth - PEEK_PX)) : 200;
123
+ const spread = (actions: SwipeAction[]): SwipeStage[] =>
124
+ actions.map((a, i) => ({
125
+ threshold: Math.round((maxDrag * (i + 1)) / (actions.length + 1)),
126
+ onCommit: a.onCommit,
127
+ }));
128
+
129
+ const swipe = useRowSwipe({
130
+ enabled: active,
131
+ rightStages: spread(rightActions),
132
+ leftStages: spread(leftActions),
133
+ maxDrag,
134
+ });
135
+
136
+ // A haptic tick per newly armed threshold, so each stage is felt and not only seen.
137
+ const armedNow = swipe.dx >= 0 ? swipe.armedRightIndex : swipe.armedLeftIndex;
138
+ const prevArmed = useRef(-1);
139
+ useEffect(() => {
140
+ if (armedNow > prevArmed.current && armedNow >= 0) navigator.vibrate?.(12);
141
+ prevArmed.current = armedNow;
142
+ }, [armedNow]);
143
+
144
+ // Index -1 (nothing armed yet) still previews the nearest action, idle-coloured.
145
+ const dx = swipe.dx;
146
+ // Only the side being dragged toward is painted, which is what makes a full-width
147
+ // panel safe.
148
+ //
149
+ // The side has to SURVIVE the release, though, and that is what this ref is for
150
+ // (Keksdose feedback dev#467): *"Swiping a category to the left with no option — if
151
+ // letting it go it gets, for the way back, the background color of the opposite
152
+ // swipe direction where there should be no color change at all."* On release dx
153
+ // snaps to 0 while the row itself slides back over 150ms, so a plain `dx < 0` read
154
+ // "right" for exactly the frames where the left side was still uncovered — and
155
+ // painted the RIGHT action's colour into the gap. With no left action at all that
156
+ // colour was the only thing the gesture ever showed.
157
+ //
158
+ // Written during render rather than in an effect: an effect would repaint a frame
159
+ // later (visibly, at 150ms) and `setState` in an effect body is an eslint error in
160
+ // the consuming app. The value is derived from this render's own dx, so a
161
+ // concurrent re-render recomputes it identically.
162
+ const lastSide = useRef<"left" | "right">("right");
163
+ if (dx !== 0) lastSide.current = dx < 0 ? "left" : "right";
164
+ const draggingLeft = dx !== 0 ? dx < 0 : lastSide.current === "left";
165
+ const shown = draggingLeft
166
+ ? leftActions[Math.max(0, swipe.armedLeftIndex)]
167
+ : rightActions[Math.max(0, swipe.armedRightIndex)];
168
+ const armed = draggingLeft ? swipe.armedLeftIndex >= 0 : swipe.armedRightIndex >= 0;
169
+
170
+ return (
171
+ <div ref={rowRef} className={cn("relative overflow-hidden", className)}>
172
+ {active && shown && (
173
+ <div
174
+ className={cn(
175
+ "pointer-events-none absolute inset-0 flex items-center gap-2 px-4",
176
+ "text-xs font-medium text-white transition-all",
177
+ // Dimmed until the drag has actually passed a threshold: "nothing will
178
+ // happen yet" has to look different from "let go and this fires", and on
179
+ // a moving row a one-step colour change was too quiet to notice.
180
+ armed ? "opacity-100" : "opacity-60",
181
+ armed ? shown.armedClassName : shown.className,
182
+ // The panel is anchored to the edge the row is uncovering, so the label
183
+ // sits where the eye already is rather than across the screen.
184
+ draggingLeft && "flex-row-reverse",
185
+ )}
186
+ >
187
+ {shown.icon && (
188
+ <span
189
+ className={cn(
190
+ "flex shrink-0 items-center justify-center rounded-full transition-all",
191
+ // Armed, the icon gets a disc of its own and grows — a second,
192
+ // non-colour signal, and the one that stays readable at a glance.
193
+ armed ? "size-8 scale-110 bg-white/25" : "size-7 bg-white/10",
194
+ )}
195
+ >
196
+ {shown.icon}
197
+ </span>
198
+ )}
199
+ <span className={cn("truncate transition-all", armed && "text-sm font-semibold")}>
200
+ {shown.label}
201
+ </span>
202
+ </div>
203
+ )}
204
+ <div
205
+ // Capture phase: the row's own click handler lives on a DESCENDANT, so a
206
+ // bubble-phase listener here would run after it — too late to suppress.
207
+ onClickCapture={(e) => {
208
+ if (!swipe.consumeClick()) return;
209
+ e.preventDefault();
210
+ e.stopPropagation();
211
+ }}
212
+ className={cn(
213
+ "relative bg-[var(--bg-surface)]",
214
+ // A mouse drag is a supported way to swipe (use-row-swipe gates only on
215
+ // the button, not the pointer type), and a horizontal mouse drag over
216
+ // text also SELECTS that text — so committing an action left the row
217
+ // highlighted blue. Suppressed only while dragging, so text in a row at
218
+ // rest stays selectable.
219
+ swipe.dragging ? "select-none" : "transition-transform duration-150",
220
+ )}
221
+ style={{
222
+ transform: dx !== 0 ? `translate3d(${dx}px,0,0)` : undefined,
223
+ touchAction: active ? "pan-y" : undefined,
224
+ }}
225
+ {...(active ? swipe.handlers : {})}
226
+ >
227
+ {children}
228
+ </div>
229
+ {active && (
230
+ <div
231
+ role="group"
232
+ aria-label={labels.actions}
233
+ // OUTSIDE the sliding div on purpose: an action that travels with the row it
234
+ // acts on is a moving target, and the drag transform would carry it off the
235
+ // screen mid-gesture.
236
+ //
237
+ // `pointer-events-none` here, restored on a button only once it HAS focus: a
238
+ // sr-only button is clipped to nothing but is still a hit target, and this
239
+ // strip sits at the row's right edge — exactly where a leftward drag begins.
240
+ // A gesture that silently failed to start because it landed on an invisible
241
+ // button is not a bug anyone would find by looking at the screen.
242
+ className="pointer-events-none absolute inset-y-0 right-0 z-10 flex items-center gap-1 pr-2"
243
+ >
244
+ {/* Both sides, each still nearest-threshold first, so the tab order reads in
245
+ the order the gesture arms them. */}
246
+ {[...rightActions, ...leftActions].map((action, i) => (
247
+ <button
248
+ key={`${action.label}-${i}`}
249
+ type="button"
250
+ onClick={action.onCommit}
251
+ className={cn(
252
+ "sr-only whitespace-nowrap rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 text-xs font-medium text-[var(--text-primary)] shadow-sm",
253
+ "focus:not-sr-only focus:pointer-events-auto focus:inline-flex focus:items-center focus:gap-1.5",
254
+ "focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
255
+ )}
256
+ >
257
+ {/* The label is the button's accessible name, so the caller's icon is
258
+ decoration on top of it rather than a second reading of it. */}
259
+ {action.icon && (
260
+ <span aria-hidden="true" className="[&_svg]:size-4">
261
+ {action.icon}
262
+ </span>
263
+ )}
264
+ {action.label}
265
+ </button>
266
+ ))}
267
+ </div>
268
+ )}
269
+ </div>
270
+ );
271
+ }
@@ -0,0 +1,170 @@
1
+ import { forwardRef, useId } from "react";
2
+ import type { ChangeEvent, InputHTMLAttributes, ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { hasMessage, mergeDescribedBy } from "./choice-parts";
5
+
6
+ /**
7
+ * An on/off switch that is still `<input type="checkbox">`, with `role="switch"`.
8
+ *
9
+ * Kastlan's was a Radix `<button>`; this keeps the native element for the reasons
10
+ * `Checkbox` gives (form submission, `required`, reset, `<label for>`), and adds the
11
+ * one thing a checkbox is not: `role="switch"`, which screen readers announce as
12
+ * "on/off" rather than "checked/not checked". Use it for a setting that takes effect
13
+ * the moment it is flipped; a choice that waits for a Save button is a checkbox.
14
+ */
15
+
16
+ /** Geometry per size. The thumb sits `2px` in from each end of the track, so its
17
+ * checked offset is `track width − thumb − 2px`. `start-*`, never `left-*` or a
18
+ * `translate-x`: an inline-start offset follows the writing direction, so in a
19
+ * right-to-left form the thumb travels right-to-left with no `rtl:` override to
20
+ * keep in step. */
21
+ const SIZES = {
22
+ // 28×16 track, 12px thumb: the dense setting rows in Kastlan's tables.
23
+ sm: { track: "h-4 w-7", thumb: "size-3 peer-checked:start-[14px]" },
24
+ // 36×20 track, 16px thumb.
25
+ md: { track: "h-5 w-9", thumb: "size-4 peer-checked:start-[18px]" },
26
+ } as const;
27
+
28
+ export type SwitchSize = keyof typeof SIZES;
29
+
30
+ // The track IS the input — `appearance-none` and a pill — so its box is the pointer
31
+ // target and the focus ring lands around the whole control, not the thumb alone.
32
+ //
33
+ // Unchecked it takes `--border-strong`, not `--border`: a hairline-coloured track on
34
+ // a surface-coloured card is a switch you have to look for. Checked it takes the
35
+ // brand fill, which is the only difference in COLOUR; the thumb's position is the
36
+ // difference in SHAPE, so the state never rests on colour alone.
37
+ const TRACK =
38
+ "peer block shrink-0 cursor-pointer appearance-none rounded-full bg-[var(--border-strong)] transition-colors " +
39
+ "checked:bg-[var(--brand)] " +
40
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-surface)] " +
41
+ "disabled:cursor-not-allowed";
42
+
43
+ // The thumb, laid over the input and ignoring the pointer so every click still reaches
44
+ // the input. It swaps to `--brand-contrast` on the brand fill: the surface colour would
45
+ // match the fill in some presets, and the contrast token is the one guaranteed to read
46
+ // against --brand.
47
+ const THUMB =
48
+ "pointer-events-none absolute start-0.5 top-1/2 -translate-y-1/2 rounded-full bg-[var(--bg-surface)] shadow-sm " +
49
+ "transition-[inset-inline-start,background-color] motion-reduce:transition-none " +
50
+ "peer-checked:bg-[var(--brand-contrast)]";
51
+
52
+ export interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "size" | "role"> {
53
+ /** `"md"` (36×20, default) or `"sm"` (28×16) — Kastlan's `default` and `sm`. */
54
+ size?: SwitchSize;
55
+ /** The words beside the switch, as a `<label for>` — see {@link Checkbox}'s
56
+ * `label` for why it does not wrap the input. Without it, give the switch an
57
+ * `aria-label`. */
58
+ label?: ReactNode;
59
+ /** Secondary text under the label, attached with `aria-describedby` (merged with
60
+ * any the caller passed). */
61
+ description?: ReactNode;
62
+ /** Which end of the row the switch sits at when there is a label. `"end"` (the
63
+ * default) is the settings-list shape Kastlan's profile page uses — the words at
64
+ * the start, the control at the far end; `"start"` puts it before the words, like
65
+ * a checkbox. Logical, so it follows the writing direction. */
66
+ switchPosition?: "start" | "end";
67
+ /** The new state as a boolean — Radix's `onCheckedChange`, so Kastlan's call sites
68
+ * move without unwrapping the event. Fires alongside `onChange`. */
69
+ onCheckedChange?: (checked: boolean) => void;
70
+ /** Classes for the `<input>` (the track). `className` styles the outermost element:
71
+ * the row when there is a label, the switch's own wrapper when there is not. */
72
+ inputClassName?: string;
73
+ }
74
+
75
+ /**
76
+ * A native checkbox drawn as a track and thumb, announced as a switch.
77
+ *
78
+ * Every other prop reaches the `<input>`, so it works controlled (`checked` +
79
+ * `onChange`/`onCheckedChange`), uncontrolled (`defaultChecked`) and in a plain
80
+ * `<form>` (`name`, `value`).
81
+ */
82
+ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(function Switch(
83
+ {
84
+ size = "md",
85
+ label,
86
+ description,
87
+ switchPosition = "end",
88
+ onCheckedChange,
89
+ onChange,
90
+ className,
91
+ inputClassName,
92
+ id,
93
+ disabled,
94
+ ...rest
95
+ },
96
+ ref,
97
+ ) {
98
+ const generated = useId();
99
+ const inputId = id ?? generated;
100
+ const descriptionId = `${inputId}-description`;
101
+ const showDescription = hasMessage(description);
102
+ const bare = label === undefined && !showDescription;
103
+ const geometry = SIZES[size];
104
+
105
+ const control = (
106
+ <span className={cn("relative inline-flex shrink-0", bare && className)}>
107
+ <input
108
+ ref={ref}
109
+ id={bare ? id : inputId}
110
+ disabled={disabled}
111
+ {...rest}
112
+ // After the spread: a caller's props object must not be able to turn this
113
+ // back into a plain checkbox, or into a text field.
114
+ type="checkbox"
115
+ role="switch"
116
+ aria-describedby={mergeDescribedBy(rest["aria-describedby"], showDescription && descriptionId)}
117
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
118
+ onChange?.(e);
119
+ onCheckedChange?.(e.target.checked);
120
+ }}
121
+ className={cn(TRACK, geometry.track, inputClassName)}
122
+ />
123
+ <span aria-hidden className={cn(THUMB, geometry.thumb)} />
124
+ </span>
125
+ );
126
+
127
+ if (bare) return control;
128
+
129
+ // The switch's cell is one label line tall (text-sm, 20px) so it centres on the
130
+ // FIRST line of a label that wraps, and a description underneath does not drag it
131
+ // down to the middle of the paragraph.
132
+ const cell = <span className="flex h-5 shrink-0 items-center">{control}</span>;
133
+ const words = (
134
+ <span className="min-w-0 flex-1">
135
+ {label !== undefined && (
136
+ <label
137
+ htmlFor={inputId}
138
+ className={cn(
139
+ "block text-sm leading-5 text-[var(--text-primary)]",
140
+ disabled ? "cursor-not-allowed" : "cursor-pointer select-none",
141
+ )}
142
+ >
143
+ {label}
144
+ </label>
145
+ )}
146
+ {showDescription && (
147
+ <span id={descriptionId} className="mt-0.5 block text-xs text-[var(--text-muted)]">
148
+ {description}
149
+ </span>
150
+ )}
151
+ </span>
152
+ );
153
+
154
+ return (
155
+ <div className={cn("flex items-start gap-3", disabled && "opacity-60", className)}>
156
+ {switchPosition === "end" ? (
157
+ <>
158
+ {words}
159
+ {cell}
160
+ </>
161
+ ) : (
162
+ <>
163
+ {cell}
164
+ {words}
165
+ </>
166
+ )}
167
+ </div>
168
+ );
169
+ });
170
+ Switch.displayName = "Switch";
@@ -0,0 +1,107 @@
1
+ import { forwardRef } from "react";
2
+ import type { ChangeEvent } from "react";
3
+ import { Input, type InputProps } from "./ui";
4
+ import { cn } from "../lib/cn";
5
+
6
+ /**
7
+ * A time of day, as a `"HH:mm"` string (`"HH:mm:ss"` once `step` asks for seconds).
8
+ *
9
+ * NATIVE `<input type="time">`, deliberately, rather than a pair of custom spinners.
10
+ * What the browser's control gives for free is the part a hand-rolled one gets wrong:
11
+ * 12- versus 24-hour display follows the user's OWN locale settings (not the page's —
12
+ * which is the right authority for "how do I read a clock"), a phone opens its
13
+ * platform wheel or dial, each segment is a spin button a screen reader already knows,
14
+ * and typing "1430" fills both segments. The price is that the display cannot be
15
+ * forced to 24h from here and the look of the segments is the browser's; neither is a
16
+ * reason for three apps to ship an unaudited widget. So this is {@link Input} with
17
+ * `type="time"` and a string contract, and everything else — the floating label, the
18
+ * `invalid`/`error` wiring, the click-anywhere `showPicker()` (feedback #224) — is
19
+ * Input's own, inherited rather than copied.
20
+ *
21
+ * Two things it adds over `<Input type="time">`:
22
+ *
23
+ * - **A normalised value.** A time input's value is any valid time string, so a
24
+ * browser may leave off a zero seconds segment with `step={1}` or report
25
+ * milliseconds, and a stored value may carry seconds the field does not show.
26
+ * `onValueChange` always hands back exactly the shape `step` implies, so the value
27
+ * a form stores compares and sorts as a plain string.
28
+ * - **Out-of-range painted, not just flagged.** A native time input outside
29
+ * `min`/`max` matches `:invalid` and nothing styles that selector (see Input's
30
+ * `invalid`), so an out-of-window time looked fine. Here it wears
31
+ * {@link FIELD_INVALID} and `aria-invalid`. A reversed window (`min="22:00"
32
+ * max="06:00"`, a night shift, quiet hours) is read the way the HTML spec reads it:
33
+ * as wrapping past midnight.
34
+ */
35
+ export interface TimeInputProps
36
+ extends Omit<InputProps, "type" | "value" | "defaultValue" | "min" | "max" | "step" | "variant"> {
37
+ /** `"HH:mm"` (or `"HH:mm:ss"` with a sub-minute `step`); `""` is "no time". */
38
+ value: string;
39
+ /** The normalised value on every change the browser reports — which for a time
40
+ * input is only once every segment holds a digit, or when the field is cleared
41
+ * (`""`). Plain `onChange` still fires alongside it with the raw event. */
42
+ onValueChange?: (value: string) => void;
43
+ /** Earliest time, `"HH:mm"`. May be later than `max` for a window that wraps
44
+ * midnight. */
45
+ min?: string;
46
+ /** Latest time, `"HH:mm"`. */
47
+ max?: string;
48
+ /** Granularity in SECONDS, as on the native input: 60 (the default) is minutes,
49
+ * 900 is quarter hours, anything not a whole minute (e.g. 1) shows seconds. */
50
+ step?: number;
51
+ }
52
+
53
+ /** Seconds shown when `step` is not a whole number of minutes — the browser's own rule
54
+ * for whether the seconds segment appears. */
55
+ function withSeconds(step: number | undefined): boolean {
56
+ return step !== undefined && step % 60 !== 0;
57
+ }
58
+
59
+ /** Any `HH:mm[:ss[.sss]]` the browser reports → exactly the shape `step` implies.
60
+ * Anything else (including `""`) comes back as it went in. */
61
+ export function normalizeTime(raw: string, seconds: boolean): string {
62
+ const m = /^(\d{2}):(\d{2})(?::(\d{2}))?/.exec(raw);
63
+ if (!m) return raw;
64
+ return seconds ? `${m[1]}:${m[2]}:${m[3] ?? "00"}` : `${m[1]}:${m[2]}`;
65
+ }
66
+
67
+ /** Whether `value` lies inside `[min, max]`, wrapping midnight when `min > max`.
68
+ * Zero-padded times compare as strings; both sides are padded to seconds first so
69
+ * "14:30" and "14:30:00" are the same instant. */
70
+ export function isTimeInRange(value: string, min?: string, max?: string): boolean {
71
+ if (value === "") return true;
72
+ const v = normalizeTime(value, true);
73
+ const lo = min ? normalizeTime(min, true) : undefined;
74
+ const hi = max ? normalizeTime(max, true) : undefined;
75
+ if (lo !== undefined && hi !== undefined && lo > hi) return v >= lo || v <= hi;
76
+ return (lo === undefined || v >= lo) && (hi === undefined || v <= hi);
77
+ }
78
+
79
+ export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>(function TimeInput(
80
+ { value, onValueChange, onChange, min, max, step, invalid, inputClassName, ...rest },
81
+ ref,
82
+ ) {
83
+ const seconds = withSeconds(step);
84
+ const outOfRange = !isTimeInRange(value, min, max);
85
+ return (
86
+ <Input
87
+ ref={ref}
88
+ {...rest}
89
+ type="time"
90
+ // Normalised on the way IN as well: a stored "14:30:00" handed to a minutes-only
91
+ // field would otherwise make the browser show a seconds segment nobody asked for.
92
+ value={normalizeTime(value, seconds)}
93
+ min={min}
94
+ max={max}
95
+ step={step}
96
+ invalid={invalid || outOfRange}
97
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
98
+ onChange?.(e);
99
+ onValueChange?.(normalizeTime(e.target.value, seconds));
100
+ }}
101
+ // The segments are digits that change under the caret as you spin them;
102
+ // proportional figures make the whole field jitter sideways on every step.
103
+ inputClassName={cn("tabular-nums", inputClassName)}
104
+ />
105
+ );
106
+ });
107
+ TimeInput.displayName = "TimeInput";