@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,772 @@
1
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { NavLink, matchPath, useLocation } from "react-router";
5
+ import { ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react";
6
+ import type { LucideIcon } from "lucide-react";
7
+ import { cn } from "../lib/cn";
8
+ import { readStored, writeStored } from "../lib/safe-storage";
9
+ import { Tooltip } from "../components/tooltip";
10
+ import { useAnchoredRect } from "../hooks/use-anchored-rect";
11
+ import { useEscapeKey } from "../hooks/use-dismiss";
12
+ import { useMediaQuery } from "../hooks/use-media-query";
13
+ import { DEFAULT_APP_SHELL_LABELS, useKitLabels } from "../i18n/kit-labels";
14
+
15
+ export interface AppShellSubItem {
16
+ to: string;
17
+ label: string;
18
+ icon: LucideIcon;
19
+ /** Passed to NavLink's `end` (exact match). Defaults to true. */
20
+ end?: boolean;
21
+ }
22
+
23
+ export interface AppShellNavItem {
24
+ to: string;
25
+ label: string;
26
+ /** Optional shorter label for the MOBILE BOTTOM BAR only (the sidebar, its
27
+ * tooltips and any palette built on this list keep `label`).
28
+ *
29
+ * The bar divides the viewport into `nav.length` equal cells, so a label's room
30
+ * shrinks with every entry added — at six entries on a 406 px phone that is ~67 px,
31
+ * and "Import & Export" wrapped to two lines and pushed the row taller than its
32
+ * neighbours (keksdose feedback live #210). A per-item override is better than
33
+ * shortening the real label, which the sidebar has ample room for and which is the
34
+ * name the rest of the product uses. */
35
+ shortLabel?: string;
36
+ icon: LucideIcon;
37
+ /** Passed to NavLink's `end` (exact match). Defaults to true. */
38
+ end?: boolean;
39
+ /** Optional `data-tour` value rendered on this item's link (both the desktop
40
+ * sidebar and the mobile bottom bar), so a guided tour can spotlight ONE nav
41
+ * entry precisely instead of matching by href (feedback #338). */
42
+ dataTour?: string;
43
+ /** Optional sub-items. When present, hovering (or focusing) the item opens a
44
+ * flyout to the right of the sidebar with a header (this item's label) and the
45
+ * sub-items as icon + title links. Works collapsed or expanded; the mobile
46
+ * bottom bar ignores sub-items and links to `to`. */
47
+ items?: AppShellSubItem[];
48
+ }
49
+
50
+ /**
51
+ * Exported and `<div>`-shaped. This is the outermost element of every consuming app, so
52
+ * there is nothing above it to hang an id, a landmark label or a `data-tour` anchor on —
53
+ * and nothing a consumer can wrap it in either, since the root owns the `h-dvh` and
54
+ * `overflow-hidden` the whole layout is built on (audit §api-design).
55
+ */
56
+ export interface AppShellProps extends ComponentPropsWithoutRef<"div"> {
57
+ /** Navigation entries — shared by the desktop sidebar and mobile bottom bar. */
58
+ nav: AppShellNavItem[];
59
+ /** The composed top bar (e.g. the shared TopBar with app-owned actions). */
60
+ topBar: ReactNode;
61
+ /** Main content — typically the router `<Outlet />`. */
62
+ children: ReactNode;
63
+ /** Desktop-only footer rendered below the content (hidden on mobile). */
64
+ footer?: ReactNode;
65
+ /** Extra sidebar content above the collapse toggle (e.g. a version link).
66
+ * Receives the collapsed state so it can render compact vs. full. */
67
+ sidebarFooter?: (collapsed: boolean) => ReactNode;
68
+ /** localStorage key for the persisted collapse state. */
69
+ collapseStorageKey?: string;
70
+ /** Default: `appShell.collapse` from the {@link UiKitProvider}, else English. */
71
+ collapseLabel?: string;
72
+ /** Default: `appShell.expand` from the {@link UiKitProvider}, else English. */
73
+ expandLabel?: string;
74
+ /** How an entry's `items` are revealed in the EXPANDED desktop sidebar.
75
+ *
76
+ * - `"flyout"` (default) — a panel beside the sidebar on hover/focus. Compact: the
77
+ * sidebar stays one row per group however many pages a group holds.
78
+ * - `"inline"` — a disclosure under the entry, the pattern of MUI's nested List and
79
+ * most documentation sites. Every page is visible at once, and the group holding
80
+ * the current page opens on its own, so the reader can see where they are without
81
+ * hovering anything.
82
+ *
83
+ * The icon-only (collapsed) sidebar always uses the flyout: an indented list has no
84
+ * room there. The mobile bottom bar ignores `items` in both modes. */
85
+ subNav?: "flyout" | "inline";
86
+ /** Accessible name of the inline disclosure button, given the group's label.
87
+ * Only used with `subNav="inline"`. Its expanded state is carried by
88
+ * `aria-expanded`, so one wording serves both directions. Default:
89
+ * `appShell.toggleGroup` from the {@link UiKitProvider}, else English. */
90
+ toggleGroupLabel?: (groupLabel: string) => string;
91
+ /** Below `md`: show the current group's `items` as a scrollable row above the
92
+ * bottom bar — the phone's counterpart of the sidebar's second level. Default true;
93
+ * `false` keeps the single bar, where a group entry only links to its own `to`. */
94
+ mobileSubNav?: boolean;
95
+ }
96
+
97
+ /**
98
+ * The bottom nav's own height, published as `--app-nav-h` so anything that has to sit
99
+ * ON it can stop guessing.
100
+ *
101
+ * Keksdose live #314 is what this is for. Its rework — *"Stick it to the bottom
102
+ * touching the bottom icon bar with budget, accounts, … Currently there is a small gap
103
+ * which confuses"* — was a receipt footer pinned at `bottom-20`, because `<main>`
104
+ * reserves `pb-20` for this nav and 20 looked like the nav's height. It is not: the
105
+ * padding is generous CLEARANCE, and the nav is content-sized (icon + label + `py-2`,
106
+ * about 56px), so the footer floated ~24px above the bar.
107
+ *
108
+ * Measured rather than named, because the height is not a constant anyone owns: it is
109
+ * an icon, a translated label that can wrap or truncate, and whatever the platform does
110
+ * with the safe-area inset. A hard-coded `bottom-14` would be the same guess one number
111
+ * lower, and it would be wrong again the first time a label needed two lines.
112
+ *
113
+ * `0px` when the nav is not rendered — above `md` it is `display:none`, which observes
114
+ * as a zero box — so a consumer can write `bottom-[var(--app-nav-h,0px)]` once and get
115
+ * the right answer at both widths without a breakpoint of its own.
116
+ */
117
+ function useNavHeightVar(ref: React.RefObject<HTMLElement | null>): void {
118
+ // Re-run when the breakpoint flips. A ResizeObserver SKIPS an element that is not
119
+ // being rendered, so the nav going `display:none` above `md` fires no callback and
120
+ // would leave the last phone height published — a desktop footer would then sit 56px
121
+ // off the bottom after a live resize. The query is the one the nav's own `md:hidden`
122
+ // compiles to, so the two cannot disagree about where the boundary is.
123
+ const isMdUp = useMediaQuery("(min-width: 768px)", false);
124
+ useEffect(() => {
125
+ const node = ref.current;
126
+ const root = document.documentElement;
127
+ // `getBoundingClientRect().height`, rounded DOWN — not `offsetHeight`, and not
128
+ // ceil. Measured on the running app at 406x816: the bar is **55.5px** tall, and
129
+ // both `offsetHeight` and `Math.ceil` answer 56. That is why live #314 round four
130
+ // was *"No change. Still a pixel inbetween"* — the previous fix was arithmetically
131
+ // a no-op on the very value it was meant to correct.
132
+ //
133
+ // 56 is the wrong side. A sticky element with `bottom: X` puts its bottom edge X
134
+ // above the viewport bottom, and the bar's TOP edge is at its own height: so
135
+ // X = 56 lands the footer at y=760.0 against a bar starting at y=760.5, i.e. half
136
+ // a CSS pixel of page showing through — about 1.4 device pixels on his phone,
137
+ // which is exactly the hairline in the screenshot.
138
+ //
139
+ // The two errors are not symmetric, and I had them backwards the first time.
140
+ // Too SMALL tucks the sticky element under a bar that is opaque and painted above
141
+ // it (`z-30` against `z-10`): invisible. Too LARGE opens the seam. So floor, and
142
+ // consumers subtract a further pixel (see `invoice-line-totals.tsx`) for the case
143
+ // where the height lands on a whole pixel and floor leaves no overlap at all.
144
+ const publish = () =>
145
+ root.style.setProperty(
146
+ "--app-nav-h",
147
+ `${node ? Math.floor(node.getBoundingClientRect().height) : 0}px`,
148
+ );
149
+ publish();
150
+ // The observer is the refinement, not the mechanism: `publish()` above is already
151
+ // right for a static nav, and jsdom has no ResizeObserver unless a test stubs one.
152
+ // ONE cleanup for every branch, though — an early `return` on the no-observer path
153
+ // left the variable behind on unmount, which a stale `bottom:` offset on whatever
154
+ // rendered next would have inherited.
155
+ const ro = node && typeof ResizeObserver !== "undefined" ? new ResizeObserver(publish) : null;
156
+ if (ro && node) ro.observe(node);
157
+ return () => {
158
+ ro?.disconnect();
159
+ root.style.removeProperty("--app-nav-h");
160
+ };
161
+ }, [ref, isMdUp]);
162
+ }
163
+
164
+ /**
165
+ * The responsive application shell: a top bar, a collapsible desktop sidebar
166
+ * (icon-only when collapsed, with tooltips), a mobile bottom nav bar, the main
167
+ * content area, and an optional desktop footer. Router-aware via react-router
168
+ * `NavLink`; domain-free — nav items, brand, footer and the top bar's actions
169
+ * are all supplied by the app.
170
+ */
171
+ export function AppShell({
172
+ nav,
173
+ topBar,
174
+ children,
175
+ footer,
176
+ sidebarFooter,
177
+ collapseStorageKey = "appLayout.sidebarCollapsed",
178
+ collapseLabel,
179
+ expandLabel,
180
+ subNav = "flyout",
181
+ mobileSubNav = true,
182
+ toggleGroupLabel,
183
+ className,
184
+ ...rest
185
+ }: AppShellProps) {
186
+ // The three props are per-shell overrides of the provider's `appShell`; one left
187
+ // undefined falls through to the provider rather than to English.
188
+ const labels = useKitLabels("appShell", DEFAULT_APP_SHELL_LABELS, {
189
+ collapse: collapseLabel,
190
+ expand: expandLabel,
191
+ toggleGroup: toggleGroupLabel,
192
+ });
193
+ // Through the guarded helpers, not `window.localStorage` directly. The read runs
194
+ // inside a `useState` initialiser — i.e. during render of the top-level shell — and
195
+ // `localStorage` THROWS where site data is blocked (Safari private browsing, a
196
+ // partitioned webview). Unguarded, that was not a lost sidebar preference: nothing
197
+ // in the application mounted at all.
198
+ const [collapsed, setCollapsed] = useState(() => readStored(collapseStorageKey) === "1");
199
+ // The inline sidebar is an ACCORDION: one group open at a time, keyed by its `to`.
200
+ // Held here rather than per group so opening one can close the other — two lists
201
+ // open at once pushed every entry below them off a laptop screen.
202
+ const [openGroup, setOpenGroup] = useState<string | null>(null);
203
+ // Publishes `--app-nav-h` for anything that has to sit on the bottom bar.
204
+ const navRef = useRef<HTMLDivElement>(null);
205
+ useNavHeightVar(navRef);
206
+ useEffect(() => {
207
+ writeStored(collapseStorageKey, collapsed ? "1" : "0");
208
+ }, [collapsed, collapseStorageKey]);
209
+
210
+ return (
211
+ // The caller's attributes go on the shell's own root, never on the `data-tour="nav"`
212
+ // wrappers below — those are the kit's anchors for its own guided tour (#313/#322),
213
+ // and a second element answering to the same selector is a tour that highlights the
214
+ // wrong thing or nothing at all.
215
+ <div {...rest} className={cn("relative flex flex-col min-h-screen md:h-dvh md:overflow-hidden", className)}>
216
+ {topBar}
217
+ <div className="flex flex-1 min-h-0">
218
+ <aside
219
+ className={`hidden md:flex md:flex-col md:sticky md:top-12 md:self-start md:h-[calc(100vh-3rem)] border-r border-[var(--border)] bg-[var(--bg-surface)] transition-[width] duration-200 ease-out overflow-hidden ${
220
+ collapsed ? "md:w-14" : "md:w-60"
221
+ }`}
222
+ >
223
+ {/* The `<nav>` is flex-1 so it fills the sidebar's height (with empty
224
+ space below the items); the data-tour marker goes on the INNER,
225
+ fit-content wrapper so the guided-tour spotlight hugs the actual nav
226
+ items instead of the whole tall column (feedback #322). The marker
227
+ also tags the mobile bottom bar below; the tour targets
228
+ `[data-tour="nav"]` and resolves to whichever is visible (#313). */}
229
+ <nav className="flex-1 px-2 py-3 overflow-y-auto overflow-x-hidden">
230
+ <div data-tour="nav" className="space-y-1">
231
+ {nav.map((item) => (
232
+ <SidebarNavItem
233
+ key={item.to}
234
+ item={item}
235
+ collapsed={collapsed}
236
+ inline={subNav === "inline"}
237
+ toggleGroupLabel={labels.toggleGroup}
238
+ openGroup={openGroup}
239
+ setOpenGroup={setOpenGroup}
240
+ />
241
+ ))}
242
+ </div>
243
+ </nav>
244
+ {sidebarFooter?.(collapsed)}
245
+ <div className="border-t border-[var(--border)] p-2">
246
+ {collapsed ? (
247
+ <Tooltip label={labels.expand} side="right" portal className="block">
248
+ <button
249
+ type="button"
250
+ onClick={() => setCollapsed(false)}
251
+ aria-label={labels.expand}
252
+ className="flex w-full items-center justify-center min-h-9 rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
253
+ >
254
+ <PanelLeftOpen className="size-4 shrink-0" />
255
+ </button>
256
+ </Tooltip>
257
+ ) : (
258
+ <button
259
+ type="button"
260
+ onClick={() => setCollapsed(true)}
261
+ aria-label={labels.collapse}
262
+ className="flex w-full items-center gap-3 min-h-9 px-3 py-2 rounded-md text-sm text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
263
+ >
264
+ <PanelLeftClose className="size-4 shrink-0" />
265
+ <span className="truncate">{labels.collapse}</span>
266
+ </button>
267
+ )}
268
+ </div>
269
+ </aside>
270
+
271
+ {/* Content column beside the sidebar. main grows to fill so the footer
272
+ sits at the bottom — at the viewport edge when content is short,
273
+ beside the sidebar rather than under it. */}
274
+ <div className="flex flex-1 flex-col min-w-0 md:min-h-0">
275
+ {/* `scrollbar-gutter: stable` reserves the scrollbar's track whether or
276
+ not it is currently needed. Without it this element — the app's only
277
+ scroll container from md up — narrows its own client box by the
278
+ scrollbar width the moment a page's content outgrows it, and widens
279
+ it again when the next page fits. Every centred `mx-auto` container
280
+ inside then jumps sideways by half a scrollbar, and any fluid-width
281
+ content changes width outright. Keksdose feedback #403 caught it as
282
+ "slight width increase/decrease" when switching to the notifications
283
+ settings section and back: that section is the only one tall enough
284
+ to scroll. Platform-dependent, which is why it is easy to miss —
285
+ overlay scrollbars (macOS, most Linux builds) take no layout space,
286
+ classic ones (Windows) take ~15px.
287
+
288
+ `both-edges`, not the bare `stable`: a one-sided reservation keeps the
289
+ width stable but moves the middle. On a page short enough not to
290
+ scroll, nothing is painted into the reserved track, so every centred
291
+ `mx-auto` container sits half a scrollbar left of the optical centre
292
+ and every full-bleed child stops ~15px short on the right. Keksdose
293
+ feedback #491 (/settings#data) and #498 (/budgets) both reported it as
294
+ "the left boundary looks smaller / cut off compared to the right" on
295
+ the one element per page whose frame is a saturated colour — the rose
296
+ destructive-action cards. Reserving the track on both edges keeps the
297
+ content centred whether or not the scrollbar is showing. */}
298
+ {/* `relative` makes <main> the containing block for everything absolutely
299
+ positioned inside it. Tailwind's `sr-only` is `position:absolute`; with no
300
+ positioned ancestor it resolves against the INITIAL containing block, is
301
+ laid out at its offset from the top of the DOCUMENT, and grows
302
+ `<html>`'s scroll height past the viewport — a second scrollbar beside this
303
+ one that scrolls into empty space. Guarding it per component (see
304
+ sr-only-containment.test) could never be complete: any page's own
305
+ `sr-only` span re-opened it. Here it is closed for every child at once. */}
306
+ <main className="relative flex-1 max-w-full overflow-x-clip pb-[calc(var(--app-nav-h,4rem)+1.5rem)] md:pb-0 md:min-h-0 md:overflow-y-auto md:[scrollbar-gutter:stable_both-edges]">
307
+ {children}
308
+ </main>
309
+ {footer}
310
+ </div>
311
+ </div>
312
+
313
+ {/* The phone navigation: the group bar, and above it — when the current page
314
+ belongs to a group with pages of its own — the row of those pages. The pair
315
+ is the phone's version of the sidebar's two levels; without the upper row a
316
+ group's `items` were simply unreachable below `md`. ONE measured box, so
317
+ `--app-nav-h` covers both rows and whatever docks on the bar sits on the
318
+ whole of it. */}
319
+ <div
320
+ ref={navRef}
321
+ className="md:hidden fixed bottom-0 inset-x-0 z-30 border-t border-[var(--border)] bg-[var(--bg-surface)]"
322
+ >
323
+ {mobileSubNav && <MobileSubNav nav={nav} />}
324
+ <nav
325
+ data-tour="nav"
326
+ className="grid"
327
+ style={{ gridTemplateColumns: `repeat(${nav.length}, minmax(0, 1fr))` }}
328
+ >
329
+ {nav.map((item) => (
330
+ <MobileNavItem key={item.to} item={item} />
331
+ ))}
332
+ </nav>
333
+ </div>
334
+ </div>
335
+ );
336
+ }
337
+
338
+ /** One cell of the phone's group bar. Marked while the group's own page OR any of its
339
+ * pages is current — NavLink alone only knows its own `to`, which left the bar with
340
+ * nothing marked on every sub-page (the same defect the sidebar had). */
341
+ function MobileNavItem({ item }: { item: AppShellNavItem }) {
342
+ const subActive = useSubItemActive(item);
343
+ return (
344
+ <NavLink
345
+ to={item.to}
346
+ end={item.end ?? true}
347
+ data-tour={item.dataTour}
348
+ className={({ isActive }) =>
349
+ // The active page needs a clear marker, not just a subtle text
350
+ // shade (feedback #327): brand-accent icon + label, bolder weight,
351
+ // and a top accent bar spanning the cell so it reads at a glance.
352
+ cn(
353
+ "relative flex flex-col items-center justify-center py-2 text-[11px] gap-0.5 transition-colors",
354
+ "before:absolute before:content-[''] before:inset-x-4 before:top-0 before:h-0.5 before:rounded-full before:transition-colors",
355
+ isActive || subActive
356
+ ? "font-semibold text-[var(--brand)] before:bg-[var(--brand)]"
357
+ : "text-[var(--text-muted)] before:bg-transparent",
358
+ )
359
+ }
360
+ >
361
+ <span className="relative">
362
+ <item.icon className="size-5" />
363
+ </span>
364
+ {/* One line, always. `truncate` is the backstop for the case a
365
+ `shortLabel` was not supplied (or a translation is longer than its
366
+ author expected): an ellipsis in one cell is a far smaller problem
367
+ than a bar whose rows are different heights. */}
368
+ <span className="max-w-full truncate px-0.5">{item.shortLabel ?? item.label}</span>
369
+ </NavLink>
370
+ );
371
+ }
372
+
373
+ /**
374
+ * The upper row of the phone navigation: the pages of the group the reader is in, as
375
+ * a sideways-scrolling row of pills. Rendered only while such a group is current —
376
+ * on a page outside every group the bar stays one row, rather than showing an empty
377
+ * shelf or guessing a group.
378
+ *
379
+ * It slides in over 200ms (fade only under reduced motion) and wraps onto more rows
380
+ * rather than scrolling, so every page of the group is in view at once.
381
+ */
382
+ function MobileSubNav({ nav }: { nav: AppShellNavItem[] }) {
383
+ const { pathname } = useLocation();
384
+ const group = nav.find(
385
+ (item) =>
386
+ !!item.items?.length &&
387
+ (matchPath({ path: item.to, end: item.end ?? true }, pathname) ||
388
+ item.items.some((sub) => matchPath({ path: sub.to, end: sub.end ?? true }, pathname))),
389
+ );
390
+
391
+ if (!group) return null;
392
+ return (
393
+ <nav
394
+ aria-label={group.label}
395
+ // Keyed by group so switching groups replays the entrance.
396
+ key={group.to}
397
+ className="animate-subnav border-b border-[var(--border)]"
398
+ >
399
+ <ul
400
+ // Wraps onto as many rows as the group needs rather than scrolling sideways: a
401
+ // sideways row hides pages past the edge, and every page of the group in view
402
+ // is the point of the row. The bar grows with it — `--app-nav-h` measures it.
403
+ className="flex flex-wrap gap-1.5 px-3 py-2"
404
+ >
405
+ {group.items!.map((sub) => (
406
+ <li key={sub.to} className="shrink-0">
407
+ <NavLink
408
+ to={sub.to}
409
+ end={sub.end ?? true}
410
+ className={({ isActive }) =>
411
+ cn(
412
+ "flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors",
413
+ isActive
414
+ ? "border-[var(--brand)] bg-[var(--brand)] font-medium text-[var(--brand-contrast)]"
415
+ : "border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
416
+ )
417
+ }
418
+ >
419
+ <sub.icon className="size-3.5 shrink-0" aria-hidden />
420
+ {sub.label}
421
+ </NavLink>
422
+ </li>
423
+ ))}
424
+ </ul>
425
+ </nav>
426
+ );
427
+ }
428
+
429
+ /** `strong` is the current page; `trail` is the group that CONTAINS the current page
430
+ * while the page itself is shown elsewhere (the inline list below it). */
431
+ const navLinkClass = (collapsed: boolean, forceActive: boolean, trail = false) =>
432
+ ({ isActive }: { isActive: boolean }) =>
433
+ `flex items-center gap-3 min-h-9 ${collapsed ? "justify-center px-2" : "px-3"} py-2 rounded-md text-sm transition-colors duration-150 ${
434
+ isActive || (forceActive && !trail)
435
+ ? "bg-[var(--bg-inverse)] text-[var(--text-inverse)]"
436
+ : forceActive
437
+ ? "font-medium text-[var(--text-primary)] hover:bg-[var(--bg-hover)]"
438
+ : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
439
+ }`;
440
+
441
+ /** Whether any of an entry's sub-items is the current route. NavLink only knows about
442
+ * its OWN `to`, so without this a group whose pages live at sibling paths
443
+ * (`/fields`, `/numbers`, …) lost its highlight the moment the reader left the one
444
+ * page the group linked to — the sidebar then marked nothing at all, and the only
445
+ * "you are here" left was inside a flyout that is closed unless hovered. */
446
+ function useSubItemActive(item: AppShellNavItem): boolean {
447
+ const { pathname } = useLocation();
448
+ return !!item.items?.some((sub) => matchPath({ path: sub.to, end: sub.end ?? true }, pathname));
449
+ }
450
+
451
+ /** A single desktop sidebar entry. Plain link (with a tooltip when collapsed) —
452
+ * unless it has sub-items, in which case hovering/focusing opens a flyout, or (with
453
+ * `inline`) a disclosure lists them underneath. */
454
+ function SidebarNavItem({
455
+ item,
456
+ collapsed,
457
+ inline,
458
+ toggleGroupLabel,
459
+ openGroup,
460
+ setOpenGroup,
461
+ }: {
462
+ item: AppShellNavItem;
463
+ collapsed: boolean;
464
+ inline: boolean;
465
+ toggleGroupLabel: (groupLabel: string) => string;
466
+ openGroup: string | null;
467
+ setOpenGroup: (to: string | null) => void;
468
+ }) {
469
+ const hasSub = !!item.items?.length;
470
+ const subActive = useSubItemActive(item);
471
+
472
+ // In the EXPANDED sidebar a group is one component in both styles, so switching
473
+ // between them is a transition of the same element (the page list folding away or
474
+ // unfolding) rather than one tree replaced by another. The icon-only sidebar keeps
475
+ // the plain flyout below: an indented list has no room there.
476
+ if (hasSub && !collapsed) {
477
+ return (
478
+ <SidebarGroup
479
+ item={item}
480
+ subActive={subActive}
481
+ inline={inline}
482
+ toggleGroupLabel={toggleGroupLabel}
483
+ open={openGroup === item.to}
484
+ setOpenGroup={setOpenGroup}
485
+ />
486
+ );
487
+ }
488
+
489
+ const link = (
490
+ <NavLink
491
+ to={item.to}
492
+ end={item.end ?? true}
493
+ data-tour={item.dataTour}
494
+ className={navLinkClass(collapsed, subActive)}
495
+ aria-haspopup={hasSub ? "menu" : undefined}
496
+ >
497
+ <span className="relative shrink-0">
498
+ <item.icon className="size-4" />
499
+ </span>
500
+ {/* Wraps rather than truncates: in the sidebar a label cut to "Checkbox, switch
501
+ & sl…" hides the one word that tells two pages apart, and there is height
502
+ to spare. (The phone bar keeps `truncate` — its cells must stay one row.) */}
503
+ {!collapsed && <span className="min-w-0 flex-1 break-words leading-snug">{item.label}</span>}
504
+ {!collapsed && hasSub && <ChevronRight className="size-3.5 shrink-0 opacity-60" />}
505
+ </NavLink>
506
+ );
507
+
508
+ if (hasSub) return <SidebarFlyout item={item}>{link}</SidebarFlyout>;
509
+ return collapsed ? (
510
+ <Tooltip label={item.label} side="right" portal className="block">
511
+ {link}
512
+ </Tooltip>
513
+ ) : (
514
+ link
515
+ );
516
+ }
517
+
518
+ /**
519
+ * An entry with sub-items in the expanded sidebar.
520
+ *
521
+ * - `inline`: the entry's own link (its overview page) plus a chevron button that
522
+ * shows or hides the sub-items indented beneath it. Two controls rather than one
523
+ * because they do two different things — a row that both navigated AND toggled
524
+ * would make "open the overview" and "peek at the pages" the same click.
525
+ * - flyout: the link alone, with the sub-items in a panel beside the sidebar on
526
+ * hover/focus. The inline list is still rendered, folded shut, so that switching
527
+ * styles animates it rather than swapping it.
528
+ *
529
+ * THE FOLD is a `grid-template-rows` transition from `0fr` to `1fr` — the one way to
530
+ * animate to a content-sized height without measuring it, so a translated label that
531
+ * wraps to two lines unfolds exactly as far as it needs. `visibility` rides the same
532
+ * transition so a shut list drops out of the accessibility tree only once it has
533
+ * finished closing, and `inert` takes its links out of the tab order immediately.
534
+ */
535
+ function SidebarGroup({
536
+ item,
537
+ subActive,
538
+ inline,
539
+ toggleGroupLabel,
540
+ open,
541
+ setOpenGroup,
542
+ }: {
543
+ item: AppShellNavItem;
544
+ subActive: boolean;
545
+ inline: boolean;
546
+ toggleGroupLabel: (groupLabel: string) => string;
547
+ /** Whether this is the sidebar's one open group (see AppShell's `openGroup`). */
548
+ open: boolean;
549
+ setOpenGroup: (to: string | null) => void;
550
+ }) {
551
+ const listId = useId();
552
+ const { pathname } = useLocation();
553
+ const onOwnPage = !!matchPath({ path: item.to, end: item.end ?? true }, pathname);
554
+ // Opening follows the reader: arriving on this group's overview or one of its pages
555
+ // (by link, by the pager, by Back) opens it — and, the sidebar holding ONE open
556
+ // group, closes whichever was open before. Both folds run at once, so the eye sees
557
+ // one list hand over to the other. Run in an effect after mount, so the current
558
+ // group UNFOLDS into view on load rather than appearing already open.
559
+ const here = subActive || onOwnPage;
560
+ useEffect(() => {
561
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- follows the route, see above
562
+ if (here) setOpenGroup(item.to);
563
+ }, [here, item.to, setOpenGroup]);
564
+ const expanded = inline && open;
565
+
566
+ const link = (
567
+ <NavLink
568
+ to={item.to}
569
+ end={item.end ?? true}
570
+ data-tour={item.dataTour}
571
+ aria-haspopup={inline ? undefined : "menu"}
572
+ // `trail` (inline only): while a page of the group is current, the group row is
573
+ // the breadcrumb, not the destination — the page's own row below carries the
574
+ // strong marker, and two inverse rows would say "you are here" twice. In the
575
+ // flyout style the pages are out of sight, so the group row IS the marker.
576
+ className={(state) => cn(navLinkClass(false, subActive, inline)(state), "min-w-0 flex-1")}
577
+ >
578
+ <span className="relative shrink-0">
579
+ <item.icon className="size-4" />
580
+ </span>
581
+ <span className="min-w-0 flex-1 break-words leading-snug">{item.label}</span>
582
+ {!inline && <ChevronRight className="size-3.5 shrink-0 opacity-60 rtl:-scale-x-100" />}
583
+ </NavLink>
584
+ );
585
+
586
+ return (
587
+ <div>
588
+ {inline ? (
589
+ <div className="flex items-center gap-0.5">
590
+ {link}
591
+ <button
592
+ type="button"
593
+ aria-expanded={open}
594
+ aria-controls={listId}
595
+ aria-label={toggleGroupLabel(item.label)}
596
+ onClick={() => setOpenGroup(open ? null : item.to)}
597
+ className="flex size-9 shrink-0 items-center justify-center rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
598
+ >
599
+ <ChevronRight
600
+ aria-hidden
601
+ className={cn(
602
+ "size-3.5 transition-transform duration-200 ease-out motion-reduce:transition-none rtl:-scale-x-100",
603
+ open && "rotate-90 rtl:rotate-90",
604
+ )}
605
+ />
606
+ </button>
607
+ </div>
608
+ ) : (
609
+ <SidebarFlyout item={item}>{link}</SidebarFlyout>
610
+ )}
611
+ <div
612
+ id={listId}
613
+ inert={!expanded}
614
+ className="grid transition-[grid-template-rows,visibility] duration-200 ease-out motion-reduce:transition-none"
615
+ style={{
616
+ gridTemplateRows: expanded ? "1fr" : "0fr",
617
+ visibility: expanded ? "visible" : "hidden",
618
+ }}
619
+ >
620
+ <div
621
+ className={cn(
622
+ "min-h-0 overflow-hidden transition-opacity duration-200 ease-out motion-reduce:transition-none",
623
+ expanded ? "opacity-100" : "opacity-0",
624
+ )}
625
+ >
626
+ {/* The rule and indent live on the list INSIDE the clipping box: margin or
627
+ padding on the element whose row is collapsing to 0fr would hold it open
628
+ by that much. */}
629
+ <ul className="ms-[1.3rem] mt-0.5 space-y-0.5 border-s border-[var(--border)] ps-2">
630
+ {item.items!.map((sub) => (
631
+ <li key={sub.to}>
632
+ <NavLink
633
+ to={sub.to}
634
+ end={sub.end ?? true}
635
+ className={({ isActive }) =>
636
+ cn(
637
+ "flex min-h-8 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors",
638
+ isActive
639
+ ? "bg-[var(--bg-inverse)] text-[var(--text-inverse)]"
640
+ : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
641
+ )
642
+ }
643
+ >
644
+ <sub.icon className="size-4 shrink-0 opacity-70" aria-hidden />
645
+ <span className="min-w-0 break-words leading-snug">{sub.label}</span>
646
+ </NavLink>
647
+ </li>
648
+ ))}
649
+ </ul>
650
+ </div>
651
+ </div>
652
+ </div>
653
+ );
654
+ }
655
+
656
+ // Only ONE sidebar flyout should be on screen at a time. Each open flyout parks its
657
+ // "close now" here so the next item to open can dismiss it immediately, instead of
658
+ // leaving the previous panel up for the 140ms travel delay while the cursor moves
659
+ // down the nav list — which showed two panels at once.
660
+ let activeFlyoutClose: (() => void) | null = null;
661
+
662
+ /**
663
+ * Wraps a nav item that has sub-items: on hover or keyboard focus it opens a
664
+ * portalled flyout to the right of the sidebar (portalled so the sidebar's
665
+ * `overflow-hidden` can't clip it) with the item's label as a header and each
666
+ * sub-item as an icon + title link. A short close delay lets the cursor travel
667
+ * from the item onto the panel; Escape / outside-move close it.
668
+ */
669
+ function SidebarFlyout({ item, children }: { item: AppShellNavItem; children: ReactNode }) {
670
+ const wrapperRef = useRef<HTMLDivElement>(null);
671
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
672
+ const [open, setOpen] = useState(false);
673
+
674
+ // Anchor the flyout to the item's right edge; the hook re-measures on
675
+ // scroll/resize (the nav list can scroll).
676
+ const rect = useAnchoredRect(wrapperRef, open);
677
+ const pos = rect ? { top: rect.top, left: rect.right } : null;
678
+
679
+ const cancelClose = useCallback(() => {
680
+ if (closeTimer.current) {
681
+ clearTimeout(closeTimer.current);
682
+ closeTimer.current = null;
683
+ }
684
+ }, []);
685
+ // Stable identity: the coordinator above holds this so a sibling item can close
686
+ // THIS flyout without waiting out its travel delay.
687
+ const closeNow = useCallback(() => {
688
+ cancelClose();
689
+ setOpen(false);
690
+ }, [cancelClose]);
691
+ const openMenu = useCallback(() => {
692
+ cancelClose();
693
+ if (activeFlyoutClose && activeFlyoutClose !== closeNow) activeFlyoutClose();
694
+ activeFlyoutClose = closeNow;
695
+ setOpen(true);
696
+ }, [cancelClose, closeNow]);
697
+ const scheduleClose = useCallback(() => {
698
+ cancelClose();
699
+ closeTimer.current = setTimeout(() => {
700
+ setOpen(false);
701
+ if (activeFlyoutClose === closeNow) activeFlyoutClose = null;
702
+ }, 140);
703
+ }, [cancelClose, closeNow]);
704
+
705
+ // Close on Escape while open.
706
+ useEscapeKey(() => setOpen(false), open);
707
+
708
+ useEffect(
709
+ () => () => {
710
+ cancelClose();
711
+ if (activeFlyoutClose === closeNow) activeFlyoutClose = null;
712
+ },
713
+ [cancelClose, closeNow],
714
+ );
715
+
716
+ return (
717
+ <div
718
+ ref={wrapperRef}
719
+ className="relative"
720
+ onMouseEnter={openMenu}
721
+ onMouseLeave={scheduleClose}
722
+ onFocus={openMenu}
723
+ onBlur={scheduleClose}
724
+ >
725
+ {children}
726
+ {open &&
727
+ pos &&
728
+ createPortal(
729
+ <div
730
+ style={{ position: "fixed", top: pos.top, left: pos.left }}
731
+ className="z-40 pl-1"
732
+ onMouseEnter={cancelClose}
733
+ onMouseLeave={scheduleClose}
734
+ >
735
+ <div
736
+ role="menu"
737
+ aria-label={item.label}
738
+ className="animate-flyout min-w-52 overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg"
739
+ >
740
+ <div className="border-b border-[var(--border)] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)]">
741
+ {item.label}
742
+ </div>
743
+ <ul className="py-1">
744
+ {item.items!.map((sub) => (
745
+ <li key={sub.to}>
746
+ <NavLink
747
+ to={sub.to}
748
+ end={sub.end ?? true}
749
+ role="menuitem"
750
+ onClick={() => setOpen(false)}
751
+ className={({ isActive }) =>
752
+ cn(
753
+ "flex items-center gap-2.5 px-3 py-2 text-sm",
754
+ isActive
755
+ ? "bg-[var(--bg-active)] font-medium text-[var(--text-primary)]"
756
+ : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
757
+ )
758
+ }
759
+ >
760
+ <sub.icon className="size-4 shrink-0 text-[var(--text-placeholder)]" />
761
+ <span className="min-w-0 break-words leading-snug">{sub.label}</span>
762
+ </NavLink>
763
+ </li>
764
+ ))}
765
+ </ul>
766
+ </div>
767
+ </div>,
768
+ document.body,
769
+ )}
770
+ </div>
771
+ );
772
+ }