@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,264 @@
1
+ import { createPortal } from "react-dom";
2
+ import { Delete } from "lucide-react";
3
+ import { useMemo } from "react";
4
+ import type { ReactNode } from "react";
5
+ import { cn } from "../lib/cn";
6
+ import { useEscapeKey } from "../hooks/use-dismiss";
7
+ import { evaluateExpression, formatResult, sanitizeLive } from "../lib/calc";
8
+ import {
9
+ DEFAULT_CALCULATOR_LABELS,
10
+ useKitLabels,
11
+ type CalculatorLabels,
12
+ } from "../i18n/kit-labels";
13
+
14
+ /**
15
+ * A full calculator keypad rendered as a bottom sheet — the mobile counterpart to
16
+ * the desktop {@link CalculatorButton} popover, and the replacement for the old
17
+ * under-the-field operator bar (`MathKeys`, feedback #334).
18
+ *
19
+ * A native app (YNAB) can swap the system keyboard for its own keypad; a PWA
20
+ * cannot — but it CAN suppress the OS keyboard with `inputMode="none"` on the
21
+ * host field and paint its own keypad in the freed space. So the host input keeps
22
+ * real focus/caret/selection + the existing value/commit contract, while this
23
+ * sheet drives it: digits + operators + a live `= …` preview, evaluated through
24
+ * the shared {@link evaluateExpression} engine. Desktop is untouched (it keeps the
25
+ * native keyboard and the calculator popover); the host gates this to mobile.
26
+ *
27
+ * Buttons commit through the host's `onChange`/blur, so the sheet is stateless —
28
+ * the field text is the single source of truth. `onPointerDown` preventDefault on
29
+ * the root keeps the host input focused (caret stays, keyboard stays hidden) as
30
+ * the user taps keys; the buttons' `onClick` still fires.
31
+ */
32
+ /** Names for the pad itself and its four non-digit keys. The first four are
33
+ * screen-reader names; `done` is the one string here that is READ OFF THE SCREEN —
34
+ * the brand-filled primary button — and it was a hardcoded English literal while
35
+ * its three neighbours each took an override, so a German phone showed "Done"
36
+ * among German labels with no prop able to change it. */
37
+ export interface NumberPadSheetLabels extends Partial<CalculatorLabels> {
38
+ /** The pad's own name — the `calculator.panel` key under its older name here. */
39
+ pad?: string;
40
+ backspace?: string;
41
+ clear?: string;
42
+ equals?: string;
43
+ /** Visible text on the primary key, not an `aria-label`. */
44
+ done?: string;
45
+ }
46
+
47
+ type PadKey =
48
+ | { kind: "ins"; label: string; ins: string; accent?: boolean; name?: PadKeyName }
49
+ | { kind: "back" };
50
+
51
+ /** The {@link CalculatorLabels} key naming a non-digit key; digits are their glyph. */
52
+ type PadKeyName = "plus" | "minus" | "times" | "divide" | "decimal";
53
+
54
+ // Insert chars that survive `sanitizeLive` (it keeps `× ÷` and ASCII `+ - * /`
55
+ // but strips the unicode minus `−`, so minus inserts an ASCII "-" while showing
56
+ // the nicer glyph). The evaluator normalises `× ÷` back to `* /`.
57
+ const PAD_KEYS: PadKey[] = [
58
+ { kind: "ins", label: "7", ins: "7" },
59
+ { kind: "ins", label: "8", ins: "8" },
60
+ { kind: "ins", label: "9", ins: "9" },
61
+ { kind: "ins", label: "÷", ins: "÷", accent: true, name: "divide" },
62
+ { kind: "ins", label: "4", ins: "4" },
63
+ { kind: "ins", label: "5", ins: "5" },
64
+ { kind: "ins", label: "6", ins: "6" },
65
+ { kind: "ins", label: "×", ins: "×", accent: true, name: "times" },
66
+ { kind: "ins", label: "1", ins: "1" },
67
+ { kind: "ins", label: "2", ins: "2" },
68
+ { kind: "ins", label: "3", ins: "3" },
69
+ { kind: "ins", label: "−", ins: "-", accent: true, name: "minus" },
70
+ { kind: "ins", label: ".", ins: ".", name: "decimal" },
71
+ { kind: "ins", label: "0", ins: "0" },
72
+ { kind: "back" },
73
+ { kind: "ins", label: "+", ins: "+", accent: true, name: "plus" },
74
+ ];
75
+
76
+ /** Every key's shape — including the thing this pad used to delete without replacing.
77
+ *
78
+ * `focus:outline-none` alone is not a style choice, it is the removal of the only
79
+ * signal a keyboard user has; on a 4×4 grid of identical tiles it leaves no way at all
80
+ * to tell which key Enter is about to press (the audit's §a11y, and the same defect as
81
+ * the dropdown search box).
82
+ *
83
+ * `focus-visible` and `ring-inset`, for the two reasons {@link ToggleGroup} already
84
+ * writes down. This is a touch control first, and a plain `focus:` ring paints itself
85
+ * on every TAP, because a tap focuses the button. And the keys sit 6px apart, which is
86
+ * thinner than an outward ring plus the neighbour it would spill onto — inset keeps the
87
+ * ring inside the key it describes.
88
+ *
89
+ * --brand rather than --border-strong: the operator keys are filled with --border, and
90
+ * a ring in the colour of the thing it surrounds is not an indicator. */
91
+ const PAD_BTN =
92
+ "flex h-14 items-center justify-center rounded-lg text-lg font-medium transition-transform select-none focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)] active:scale-[0.97]";
93
+ const PAD_DIGIT = "bg-[var(--bg-surface-2)] text-[var(--text-primary)] active:bg-[var(--border)]";
94
+ const PAD_ACCENT = "bg-[var(--border)] text-[var(--text-primary)] active:bg-[var(--bg-surface-2)]";
95
+
96
+ export function NumberPadSheet({
97
+ value,
98
+ onChange,
99
+ onDone,
100
+ label,
101
+ labels,
102
+ }: {
103
+ value: string;
104
+ /** Fired with the raw (sanitised) field text on every key — same contract as
105
+ * the host field's own `onChange`. */
106
+ onChange: (value: string) => void;
107
+ /** Fired on "Done": the host blurs the input, which commits (evaluates) and
108
+ * unmounts the sheet via its existing blur handler. */
109
+ onDone: () => void;
110
+ /** Optional field label, echoed in the sheet header so the user still knows
111
+ * which field they're editing when the sheet covers it. */
112
+ label?: ReactNode;
113
+ /** Names for the pad itself and its non-digit keys — screen-reader names, except
114
+ * `done`, which is the visible text on the primary key. Merged over `calculator`
115
+ * from `<UiKitProvider labels>`: this is the same keypad as `CalculatorButton`'s,
116
+ * on a phone, and one translation covers both. Digits need no name — their glyph
117
+ * IS the name; the operators do (see {@link CalculatorLabels.plus}). */
118
+ labels?: NumberPadSheetLabels;
119
+ }) {
120
+ // `pad` is this sheet's older spelling of `panel`, so it is folded in as that.
121
+ const fromProps = useMemo(() => {
122
+ if (!labels) return undefined;
123
+ const { pad, ...rest } = labels;
124
+ return pad === undefined ? rest : { ...rest, panel: pad };
125
+ }, [labels]);
126
+ const text = useKitLabels("calculator", DEFAULT_CALCULATOR_LABELS, fromProps);
127
+
128
+ // NO `useBodyScrollLock`, and that is the point of the control (Keksdose live
129
+ // #317: *"Background not scrollable when the amount input calculator field is
130
+ // open"*).
131
+ //
132
+ // This sheet is a KEYBOARD, not a dialog. It is opened by focusing a field and
133
+ // exists only because a PWA cannot swap the system keyboard for its own — so it
134
+ // suppresses the OS keyboard with `inputMode="none"` and paints itself in the
135
+ // freed space. An OS keyboard does not freeze the page behind it; it takes the
136
+ // bottom of the screen and leaves you free to scroll what is left, which is how
137
+ // you reach the field you are typing into when the keys cover it.
138
+ //
139
+ // Locking here made the page unreachable at exactly the moment it matters most:
140
+ // correcting a receipt total against the running line sum, where the figure you
141
+ // are comparing against sits below the keypad. The other four holders of that
142
+ // hook are all modal — `Modal`, `PickerSheet`, `FullBleedDialog`, the DataTable
143
+ // row dialog — and they stay locked, because for them the page behind is not
144
+ // part of the task.
145
+ //
146
+ // The pad still does not scroll ITSELF away under a stray drag: the root's
147
+ // `onPointerDown` preventDefault (below) keeps the host input focused and eats
148
+ // the gesture on the sheet, so a touch that starts on a key is not a page scroll.
149
+
150
+ // ── NO focus trap either, and for a harder reason than the scroll lock ────────
151
+ //
152
+ // Wave 3 put `useFocusTrap` on every overlay in this package. This one is the
153
+ // exception, and it is not an oversight to be tidied up later: a trap here would
154
+ // DELETE THE COMPONENT IT WAS PROTECTING. Both hosts render the pad with
155
+ // `showNumpad = isMobile && !disabled && focused`, where `focused` is the host
156
+ // input's own focus state. Moving focus into the pad blurs that input, the host
157
+ // sets `focused` false, and the pad unmounts — on mount, before a single key can be
158
+ // pressed. The blur also COMMITS, so the trap would evaluate a half-typed
159
+ // expression on the way out.
160
+ //
161
+ // That is the contract, not an accident of it. This sheet exists because a PWA
162
+ // cannot swap the system keyboard for its own: the host keeps real focus, caret and
163
+ // selection while `inputMode="none"` suppresses the OS keys, and the root's
164
+ // `onPointerDown` preventDefault below is there for the same reason. The pad is
165
+ // `role="group"`, not a dialog. A keyboard does not trap focus; it is what you type
166
+ // WITH.
167
+ //
168
+ // What was genuinely missing is a way OUT that is not a tap. `useEscapeKey` and not
169
+ // a handler on the sheet, because the sheet never has the focus a keydown would
170
+ // bubble from — the host does, and the host is not this component. Escape does what
171
+ // the Done key does rather than cancelling, because committing on blur is the
172
+ // host's contract and a cancel would have to be the host's to offer.
173
+ //
174
+ // The limit, since it is better written down than discovered: inside a `Modal` the
175
+ // dialog's own Escape handler sits on the focus path and stops the event before a
176
+ // document listener sees it, so there the press closes the dialog — which it did
177
+ // before this line existed too, and which takes the pad with it either way.
178
+ useEscapeKey(onDone);
179
+
180
+ const result = evaluateExpression(value);
181
+ const preview = result !== null && formatResult(result) !== value.trim() ? `= ${formatResult(result)}` : "";
182
+
183
+ const insert = (ch: string) => onChange(sanitizeLive(value + ch));
184
+ const backspace = () => onChange(value.slice(0, -1));
185
+ const clearAll = () => onChange("");
186
+ const equals = () => {
187
+ if (result !== null) onChange(formatResult(result));
188
+ };
189
+
190
+ const sheet = (
191
+ <div
192
+ role="group"
193
+ aria-label={text.panel}
194
+ // Keep the host input focused when tapping the pad: preventDefault on
195
+ // pointerdown blocks the focus/blur, while the buttons' click still fires.
196
+ onPointerDown={(e) => e.preventDefault()}
197
+ className="fixed inset-x-0 bottom-0 z-50 border-t border-[var(--border)] bg-[var(--bg-surface)] px-2 pt-2 shadow-[0_-4px_24px_rgba(0,0,0,0.12)]"
198
+ style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
199
+ >
200
+ {/* Header display: which field + the running expression and live result, so
201
+ the value stays visible even when the sheet covers the field. */}
202
+ <div className="flex items-end justify-between gap-3 px-2 pb-2">
203
+ {label != null && (
204
+ <span className="truncate pb-1 text-xs font-medium text-[var(--money-neutral)]">{label}</span>
205
+ )}
206
+ <div className="ml-auto min-w-0 text-right">
207
+ <div className="truncate font-mono text-lg leading-tight text-[var(--text-primary)]">{value || "0"}</div>
208
+ <div className="h-4 font-mono text-xs text-[var(--money-neutral)]">{preview}</div>
209
+ </div>
210
+ </div>
211
+
212
+ <div className="grid grid-cols-4 gap-1.5">
213
+ {PAD_KEYS.map((key, i) =>
214
+ key.kind === "back" ? (
215
+ <button
216
+ key="back"
217
+ type="button"
218
+ aria-label={text.backspace}
219
+ onClick={backspace}
220
+ className={cn(PAD_BTN, PAD_ACCENT)}
221
+ >
222
+ <Delete className="size-5" />
223
+ </button>
224
+ ) : (
225
+ <button
226
+ key={`${key.ins}-${i}`}
227
+ type="button"
228
+ aria-label={key.name ? text[key.name] : key.label}
229
+ onClick={() => insert(key.ins)}
230
+ className={cn(PAD_BTN, key.accent ? PAD_ACCENT : PAD_DIGIT)}
231
+ >
232
+ {key.label}
233
+ </button>
234
+ ),
235
+ )}
236
+ </div>
237
+
238
+ <div className="mt-1.5 grid grid-cols-4 gap-1.5">
239
+ <button type="button" aria-label={text.clear} onClick={clearAll} className={cn(PAD_BTN, PAD_ACCENT)}>
240
+ C
241
+ </button>
242
+ <button type="button" aria-label={text.equals} onClick={equals} className={cn(PAD_BTN, PAD_ACCENT)}>
243
+ =
244
+ </button>
245
+ <button
246
+ type="button"
247
+ onClick={onDone}
248
+ // The one key whose own fill is --brand, so it rings in the colour that fill
249
+ // was chosen to be legible against instead (tailwind-merge keeps the later
250
+ // ring colour). A --brand ring here would be present in the DOM and invisible
251
+ // on screen, which is the defect this change exists to fix.
252
+ className={cn(
253
+ PAD_BTN,
254
+ "col-span-2 bg-[var(--brand)] text-[var(--brand-contrast)] focus-visible:ring-[var(--brand-contrast)] active:bg-[var(--brand-hover)]",
255
+ )}
256
+ >
257
+ {text.done}
258
+ </button>
259
+ </div>
260
+ </div>
261
+ );
262
+
263
+ return createPortal(sheet, document.body);
264
+ }
@@ -0,0 +1,289 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
+ import { ChevronDown } from "lucide-react";
4
+ import { cn } from "../lib/cn";
5
+ import { useKitLabels } from "../i18n/kit-labels";
6
+
7
+ /**
8
+ * "On this page": a page's own table of contents, with the section being read marked.
9
+ *
10
+ * Three parts, because an app needs them separately:
11
+ *
12
+ * - {@link useScrollSpy} — which section is current, from the headings' positions in
13
+ * a scroll container. No markup; usable for anything that follows the reader.
14
+ * - {@link PageContents} — the list. `variant="rail"` for beside the page,
15
+ * `variant="disclosure"` for a collapsed block under the title on narrow screens.
16
+ * - {@link PageContentsLayout} — the page with the rail sticky on one side of it.
17
+ *
18
+ * Domain-free: the app supplies the entries and how an entry links (`hrefFor`), so it
19
+ * works with any router, including a hash router where `#id` alone would be read as a
20
+ * route.
21
+ */
22
+
23
+ export interface PageContentsItem {
24
+ /** The id of the heading the entry jumps to. */
25
+ id: string;
26
+ label: ReactNode;
27
+ /** 2 indents the entry under the one before it. */
28
+ level?: 1 | 2;
29
+ }
30
+
31
+ export interface PageContentsLabels {
32
+ /** The heading above the list, and the landmark's accessible name. */
33
+ title: string;
34
+ }
35
+
36
+ export const DEFAULT_PAGE_CONTENTS_LABELS: PageContentsLabels = {
37
+ title: "On this page",
38
+ };
39
+
40
+ export interface UseScrollSpyOptions {
41
+ /** The element that scrolls. Defaults to the viewport. In `AppShell`, pass its
42
+ * `<main>` — it is the page's scroll container from `md` up. */
43
+ root?: Element | null;
44
+ /**
45
+ * How far down the scroller the "reading line" sits, as a fraction of its height.
46
+ * A section becomes current once its heading passes above it, and stays current
47
+ * until the next heading does — so a long section keeps its entry marked while it
48
+ * is being read, not only while its heading is on screen. Default 0.15: close to
49
+ * the top, so a short section followed by another does not hand its mark to the
50
+ * next one the moment it is scrolled to.
51
+ */
52
+ line?: number;
53
+ }
54
+
55
+ /**
56
+ * The id of the section being read: the last of `ids` whose element has scrolled
57
+ * above the reading line — or the first, before any has.
58
+ */
59
+ export function useScrollSpy(ids: readonly string[], options: UseScrollSpyOptions = {}): string | null {
60
+ const { root = null, line = 0.15 } = options;
61
+ const [current, setCurrent] = useState<string | null>(null);
62
+ const key = ids.join("\u0000");
63
+
64
+ useEffect(() => {
65
+ if (ids.length === 0) return;
66
+ const scroller: Element | Window = root ?? window;
67
+ const measure = () => {
68
+ const top = root ? root.getBoundingClientRect().top : 0;
69
+ const height = root ? root.clientHeight : window.innerHeight;
70
+ const reading = top + height * line;
71
+ let next = ids[0];
72
+ for (const id of ids) {
73
+ const el = document.getElementById(id);
74
+ if (el && el.getBoundingClientRect().top <= reading) next = id;
75
+ }
76
+ // At the very bottom the last sections may never reach the line; the reader
77
+ // who scrolled to the end is reading the end.
78
+ const atEnd = root
79
+ ? root.scrollTop + root.clientHeight >= root.scrollHeight - 2
80
+ : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
81
+ const scrolled = root ? root.scrollTop : window.scrollY;
82
+ if (atEnd && scrolled > 0) next = ids[ids.length - 1];
83
+ setCurrent(next);
84
+ };
85
+ let frame = 0;
86
+ const onScroll = () => {
87
+ cancelAnimationFrame(frame);
88
+ frame = requestAnimationFrame(measure);
89
+ };
90
+ measure();
91
+ scroller.addEventListener("scroll", onScroll, { passive: true });
92
+ window.addEventListener("resize", onScroll);
93
+ return () => {
94
+ cancelAnimationFrame(frame);
95
+ scroller.removeEventListener("scroll", onScroll);
96
+ window.removeEventListener("resize", onScroll);
97
+ };
98
+ // `key` stands for `ids`: a new array with the same entries is the same list.
99
+ // eslint-disable-next-line react-hooks/exhaustive-deps
100
+ }, [key, root, line]);
101
+
102
+ return current;
103
+ }
104
+
105
+ export interface PageContentsProps extends Omit<ComponentPropsWithoutRef<"nav">, "children"> {
106
+ items: PageContentsItem[];
107
+ /** The current entry — typically `useScrollSpy(items.map(i => i.id), …)`. */
108
+ activeId?: string | null;
109
+ /** The link for an entry. Default `#id`; under a hash router build it with the
110
+ * router (react-router's `useHref({ hash: id })` gives `#/page#id`). */
111
+ hrefFor?: (id: string) => string;
112
+ /** `rail` — a list beside the page. `disclosure` — collapsed under a summary line,
113
+ * for narrow screens where a rail has no room. */
114
+ variant?: "rail" | "disclosure";
115
+ labels?: Partial<PageContentsLabels>;
116
+ }
117
+
118
+ /**
119
+ * The list itself. The current entry gets a brand bar on the list's rule, the brand
120
+ * colour, and `aria-current="location"` — the marker documentation sites use, and the
121
+ * one a screen reader announces.
122
+ */
123
+ export function PageContents({
124
+ items,
125
+ activeId,
126
+ hrefFor = (id) => `#${id}`,
127
+ variant = "rail",
128
+ labels,
129
+ className,
130
+ ...rest
131
+ }: PageContentsProps) {
132
+ const l = useKitLabels("pageContents", DEFAULT_PAGE_CONTENTS_LABELS, labels);
133
+ const listRef = useRef<HTMLUListElement>(null);
134
+ // The entry just clicked is the current one until the reader scrolls on their own.
135
+ // Without this, jumping to a short section near the end of a page marks whichever
136
+ // later section the jump also carried past the reading line — you click one entry
137
+ // and another lights up.
138
+ const [clicked, setClicked] = useState<string | null>(null);
139
+ useEffect(() => {
140
+ if (!clicked) return;
141
+ const release = () => setClicked(null);
142
+ // A user scroll — wheel, touch, keys — releases it; the programmatic scroll the
143
+ // jump itself causes does not fire these.
144
+ window.addEventListener("wheel", release, { passive: true, once: true });
145
+ window.addEventListener("touchmove", release, { passive: true, once: true });
146
+ window.addEventListener("keydown", release, { once: true });
147
+ return () => {
148
+ window.removeEventListener("wheel", release);
149
+ window.removeEventListener("touchmove", release);
150
+ window.removeEventListener("keydown", release);
151
+ };
152
+ }, [clicked]);
153
+ const shown = clicked ?? activeId;
154
+
155
+ // Keep the current entry in view when the list is taller than the window.
156
+ useEffect(() => {
157
+ if (!shown || variant !== "rail") return;
158
+ listRef.current
159
+ ?.querySelector<HTMLElement>(`[data-entry="${CSS.escape(shown)}"]`)
160
+ ?.scrollIntoView?.({ block: "nearest" });
161
+ }, [shown, variant]);
162
+
163
+ if (items.length === 0) return null;
164
+
165
+ const list = (
166
+ <ul
167
+ ref={listRef}
168
+ className={cn(
169
+ "space-y-0.5 border-s border-[var(--border)]",
170
+ variant === "rail" && "max-h-[calc(100dvh-10rem)] overflow-y-auto [scrollbar-width:thin]",
171
+ )}
172
+ >
173
+ {items.map((item) => {
174
+ const current = item.id === shown;
175
+ return (
176
+ <li key={item.id} data-entry={item.id}>
177
+ <a
178
+ href={hrefFor(item.id)}
179
+ // One line per entry in the rail; past 22rem the ellipsis takes over and
180
+ // the full text is on hover. The disclosure has the page's width and wraps.
181
+ title={variant === "rail" && typeof item.label === "string" ? item.label : undefined}
182
+ aria-current={current ? "location" : undefined}
183
+ onClick={() => setClicked(item.id)}
184
+ className={cn(
185
+ "-ms-px block border-s-2 py-1 pe-1 text-[13px] leading-snug transition-colors duration-150",
186
+ variant === "rail" && "truncate",
187
+ item.level === 2 ? "ps-6" : "ps-3",
188
+ current
189
+ ? "border-[var(--brand)] font-medium text-[var(--brand)]"
190
+ : "border-transparent text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]",
191
+ )}
192
+ >
193
+ {item.label}
194
+ </a>
195
+ </li>
196
+ );
197
+ })}
198
+ </ul>
199
+ );
200
+
201
+ if (variant === "disclosure") {
202
+ return (
203
+ <details
204
+ className={cn(
205
+ "group rounded-md border border-[var(--border)] bg-[var(--bg-surface)]",
206
+ className,
207
+ )}
208
+ >
209
+ <summary className="flex cursor-pointer list-none items-center justify-between px-3 py-2 text-sm font-medium text-[var(--text-secondary)] [&::-webkit-details-marker]:hidden">
210
+ {l.title}
211
+ <ChevronDown
212
+ aria-hidden
213
+ className="size-4 transition-transform duration-200 group-open:rotate-180 motion-reduce:transition-none"
214
+ />
215
+ </summary>
216
+ <nav {...rest} aria-label={l.title} className="border-t border-[var(--border)] px-2 py-2">
217
+ {list}
218
+ </nav>
219
+ </details>
220
+ );
221
+ }
222
+
223
+ return (
224
+ <nav {...rest} aria-label={l.title} className={className}>
225
+ <p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)]">
226
+ {l.title}
227
+ </p>
228
+ {list}
229
+ </nav>
230
+ );
231
+ }
232
+
233
+ export interface PageContentsLayoutProps extends ComponentPropsWithoutRef<"div"> {
234
+ /** What goes in the rail — typically a `<PageContents variant="rail">`. */
235
+ contents: ReactNode;
236
+ /**
237
+ * Which side of the page the rail sits on: `start` is the left in a left-to-right
238
+ * language (next to the app's own sidebar, so both navigations are in one place),
239
+ * `end` the right (the documentation-site convention, clear of the sidebar). Logical,
240
+ * so both flip in a right-to-left UI.
241
+ */
242
+ position?: "start" | "end";
243
+ children: ReactNode;
244
+ }
245
+
246
+ /**
247
+ * The page with a sticky contents rail beside it, from `xl` (1280px) up. Below that
248
+ * the rail would squeeze the content, so it is not rendered there — pair it with a
249
+ * `<PageContents variant="disclosure" className="xl:hidden">` in the page.
250
+ *
251
+ * The rail is `sticky top-6`, which sticks inside the nearest scroll container — the
252
+ * viewport, or `AppShell`'s `<main>`.
253
+ */
254
+ export function PageContentsLayout({
255
+ contents,
256
+ position = "start",
257
+ children,
258
+ className,
259
+ ...rest
260
+ }: PageContentsLayoutProps) {
261
+ // In DOM order on its side, not moved there with `order`: Tab and a screen reader
262
+ // follow the source, and a rail painted on the left but read after the page would
263
+ // put the page's index at its end.
264
+ const rail = (
265
+ <aside className="hidden xl:block">
266
+ <div className="sticky top-6">{contents}</div>
267
+ </aside>
268
+ );
269
+ return (
270
+ <div
271
+ {...rest}
272
+ className={cn(
273
+ "xl:grid xl:gap-10",
274
+ // `fit-content(22rem)`: the rail is as wide as its longest entry, up to 22rem.
275
+ // A fixed 13rem broke "Select — unlabelled, labelled, invalid, disabled" over
276
+ // three lines beside a page with room to spare; an index you scan should read
277
+ // one entry per line.
278
+ position === "start"
279
+ ? "xl:grid-cols-[fit-content(22rem)_minmax(0,1fr)]"
280
+ : "xl:grid-cols-[minmax(0,1fr)_fit-content(22rem)]",
281
+ className,
282
+ )}
283
+ >
284
+ {position === "start" && rail}
285
+ <div className="min-w-0">{children}</div>
286
+ {position === "end" && rail}
287
+ </div>
288
+ );
289
+ }