@hoardodile/ui 0.0.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 (356) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +70 -0
  3. package/dist/components/alert.d.ts +13 -0
  4. package/dist/components/alert.js +97 -0
  5. package/dist/components/alert.js.map +1 -0
  6. package/dist/components/app-dialog.d.ts +62 -0
  7. package/dist/components/app-dialog.js +656 -0
  8. package/dist/components/app-dialog.js.map +1 -0
  9. package/dist/components/badge.d.ts +11 -0
  10. package/dist/components/badge.js +70 -0
  11. package/dist/components/badge.js.map +1 -0
  12. package/dist/components/breadcrumb.d.ts +12 -0
  13. package/dist/components/breadcrumb.js +617 -0
  14. package/dist/components/breadcrumb.js.map +1 -0
  15. package/dist/components/button.d.ts +17 -0
  16. package/dist/components/button.js +78 -0
  17. package/dist/components/button.js.map +1 -0
  18. package/dist/components/caption-bar.d.ts +44 -0
  19. package/dist/components/caption-bar.js +827 -0
  20. package/dist/components/caption-bar.js.map +1 -0
  21. package/dist/components/chart-card.d.ts +15 -0
  22. package/dist/components/chart-card.js +27 -0
  23. package/dist/components/chart-card.js.map +1 -0
  24. package/dist/components/checkbox.d.ts +6 -0
  25. package/dist/components/checkbox.js +76 -0
  26. package/dist/components/checkbox.js.map +1 -0
  27. package/dist/components/chip-button.d.ts +28 -0
  28. package/dist/components/chip-button.js +87 -0
  29. package/dist/components/chip-button.js.map +1 -0
  30. package/dist/components/close-confirm-dialog.d.ts +19 -0
  31. package/dist/components/close-confirm-dialog.js +743 -0
  32. package/dist/components/close-confirm-dialog.js.map +1 -0
  33. package/dist/components/color-picker.d.ts +32 -0
  34. package/dist/components/color-picker.js +667 -0
  35. package/dist/components/color-picker.js.map +1 -0
  36. package/dist/components/combobox.d.ts +32 -0
  37. package/dist/components/combobox.js +1211 -0
  38. package/dist/components/combobox.js.map +1 -0
  39. package/dist/components/confirm-by-typing-dialog.d.ts +48 -0
  40. package/dist/components/confirm-by-typing-dialog.js +759 -0
  41. package/dist/components/confirm-by-typing-dialog.js.map +1 -0
  42. package/dist/components/confirm-dialog.d.ts +34 -0
  43. package/dist/components/confirm-dialog.js +710 -0
  44. package/dist/components/confirm-dialog.js.map +1 -0
  45. package/dist/components/count-badge.d.ts +13 -0
  46. package/dist/components/count-badge.js +39 -0
  47. package/dist/components/count-badge.js.map +1 -0
  48. package/dist/components/dialog.d.ts +40 -0
  49. package/dist/components/dialog.js +571 -0
  50. package/dist/components/dialog.js.map +1 -0
  51. package/dist/components/dropdown-menu.d.ts +45 -0
  52. package/dist/components/dropdown-menu.js +1003 -0
  53. package/dist/components/dropdown-menu.js.map +1 -0
  54. package/dist/components/dropdown-select.d.ts +29 -0
  55. package/dist/components/dropdown-select.js +1036 -0
  56. package/dist/components/dropdown-select.js.map +1 -0
  57. package/dist/components/empty.d.ts +15 -0
  58. package/dist/components/empty.js +119 -0
  59. package/dist/components/empty.js.map +1 -0
  60. package/dist/components/filter-rail.d.ts +48 -0
  61. package/dist/components/filter-rail.js +231 -0
  62. package/dist/components/filter-rail.js.map +1 -0
  63. package/dist/components/fixed-action-bar.d.ts +24 -0
  64. package/dist/components/fixed-action-bar.js +48 -0
  65. package/dist/components/fixed-action-bar.js.map +1 -0
  66. package/dist/components/font-picker.d.ts +45 -0
  67. package/dist/components/font-picker.js +759 -0
  68. package/dist/components/font-picker.js.map +1 -0
  69. package/dist/components/form.d.ts +17 -0
  70. package/dist/components/form.js +150 -0
  71. package/dist/components/form.js.map +1 -0
  72. package/dist/components/group-label.d.ts +12 -0
  73. package/dist/components/group-label.js +10 -0
  74. package/dist/components/group-label.js.map +1 -0
  75. package/dist/components/icon-tile.d.ts +16 -0
  76. package/dist/components/icon-tile.js +67 -0
  77. package/dist/components/icon-tile.js.map +1 -0
  78. package/dist/components/icon-toggle.d.ts +24 -0
  79. package/dist/components/icon-toggle.js +81 -0
  80. package/dist/components/icon-toggle.js.map +1 -0
  81. package/dist/components/icon.d.ts +2 -0
  82. package/dist/components/icon.js +49 -0
  83. package/dist/components/icon.js.map +1 -0
  84. package/dist/components/image-crop-panel.d.ts +80 -0
  85. package/dist/components/image-crop-panel.js +1104 -0
  86. package/dist/components/image-crop-panel.js.map +1 -0
  87. package/dist/components/image-cropper.d.ts +63 -0
  88. package/dist/components/image-cropper.js +166 -0
  89. package/dist/components/image-cropper.js.map +1 -0
  90. package/dist/components/input-group.d.ts +22 -0
  91. package/dist/components/input-group.js +270 -0
  92. package/dist/components/input-group.js.map +1 -0
  93. package/dist/components/input.d.ts +16 -0
  94. package/dist/components/input.js +60 -0
  95. package/dist/components/input.js.map +1 -0
  96. package/dist/components/label.d.ts +5 -0
  97. package/dist/components/label.js +40 -0
  98. package/dist/components/label.js.map +1 -0
  99. package/dist/components/list-empty-row.d.ts +16 -0
  100. package/dist/components/list-empty-row.js +40 -0
  101. package/dist/components/list-empty-row.js.map +1 -0
  102. package/dist/components/management-empty.d.ts +14 -0
  103. package/dist/components/management-empty.js +40 -0
  104. package/dist/components/management-empty.js.map +1 -0
  105. package/dist/components/management-skeleton.d.ts +11 -0
  106. package/dist/components/management-skeleton.js +72 -0
  107. package/dist/components/management-skeleton.js.map +1 -0
  108. package/dist/components/meta-chip.d.ts +17 -0
  109. package/dist/components/meta-chip.js +49 -0
  110. package/dist/components/meta-chip.js.map +1 -0
  111. package/dist/components/mobile-drawer.d.ts +29 -0
  112. package/dist/components/mobile-drawer.js +331 -0
  113. package/dist/components/mobile-drawer.js.map +1 -0
  114. package/dist/components/page-header.d.ts +20 -0
  115. package/dist/components/page-header.js +44 -0
  116. package/dist/components/page-header.js.map +1 -0
  117. package/dist/components/page-scaffold.d.ts +34 -0
  118. package/dist/components/page-scaffold.js +157 -0
  119. package/dist/components/page-scaffold.js.map +1 -0
  120. package/dist/components/pagination-bar.d.ts +22 -0
  121. package/dist/components/pagination-bar.js +703 -0
  122. package/dist/components/pagination-bar.js.map +1 -0
  123. package/dist/components/pagination.d.ts +22 -0
  124. package/dist/components/pagination.js +680 -0
  125. package/dist/components/pagination.js.map +1 -0
  126. package/dist/components/panel-toolbar.d.ts +37 -0
  127. package/dist/components/panel-toolbar.js +781 -0
  128. package/dist/components/panel-toolbar.js.map +1 -0
  129. package/dist/components/pill-tabs.d.ts +33 -0
  130. package/dist/components/pill-tabs.js +66 -0
  131. package/dist/components/pill-tabs.js.map +1 -0
  132. package/dist/components/plugin-download-consent.d.ts +52 -0
  133. package/dist/components/plugin-download-consent.js +780 -0
  134. package/dist/components/plugin-download-consent.js.map +1 -0
  135. package/dist/components/popover.d.ts +26 -0
  136. package/dist/components/popover.js +343 -0
  137. package/dist/components/popover.js.map +1 -0
  138. package/dist/components/progress.d.ts +10 -0
  139. package/dist/components/progress.js +103 -0
  140. package/dist/components/progress.js.map +1 -0
  141. package/dist/components/query-state-view.d.ts +36 -0
  142. package/dist/components/query-state-view.js +17 -0
  143. package/dist/components/query-state-view.js.map +1 -0
  144. package/dist/components/radio-group.d.ts +8 -0
  145. package/dist/components/radio-group.js +60 -0
  146. package/dist/components/radio-group.js.map +1 -0
  147. package/dist/components/scroll-area.d.ts +7 -0
  148. package/dist/components/scroll-area.js +80 -0
  149. package/dist/components/scroll-area.js.map +1 -0
  150. package/dist/components/search-field.d.ts +49 -0
  151. package/dist/components/search-field.js +586 -0
  152. package/dist/components/search-field.js.map +1 -0
  153. package/dist/components/secondary-stat.d.ts +13 -0
  154. package/dist/components/secondary-stat.js +56 -0
  155. package/dist/components/secondary-stat.js.map +1 -0
  156. package/dist/components/section-header.d.ts +23 -0
  157. package/dist/components/section-header.js +76 -0
  158. package/dist/components/section-header.js.map +1 -0
  159. package/dist/components/section-label.d.ts +20 -0
  160. package/dist/components/section-label.js +46 -0
  161. package/dist/components/section-label.js.map +1 -0
  162. package/dist/components/section-tabs.d.ts +47 -0
  163. package/dist/components/section-tabs.js +145 -0
  164. package/dist/components/section-tabs.js.map +1 -0
  165. package/dist/components/separator.d.ts +14 -0
  166. package/dist/components/separator.js +49 -0
  167. package/dist/components/separator.js.map +1 -0
  168. package/dist/components/sheet.d.ts +22 -0
  169. package/dist/components/sheet.js +469 -0
  170. package/dist/components/sheet.js.map +1 -0
  171. package/dist/components/sidebar.d.ts +69 -0
  172. package/dist/components/sidebar.js +1645 -0
  173. package/dist/components/sidebar.js.map +1 -0
  174. package/dist/components/skeleton.d.ts +8 -0
  175. package/dist/components/skeleton.js +41 -0
  176. package/dist/components/skeleton.js.map +1 -0
  177. package/dist/components/slider.d.ts +6 -0
  178. package/dist/components/slider.js +75 -0
  179. package/dist/components/slider.js.map +1 -0
  180. package/dist/components/sortable-chip-list.d.ts +26 -0
  181. package/dist/components/sortable-chip-list.js +59 -0
  182. package/dist/components/sortable-chip-list.js.map +1 -0
  183. package/dist/components/spinner.d.ts +5 -0
  184. package/dist/components/spinner.js +65 -0
  185. package/dist/components/spinner.js.map +1 -0
  186. package/dist/components/surface.d.ts +13 -0
  187. package/dist/components/surface.js +51 -0
  188. package/dist/components/surface.js.map +1 -0
  189. package/dist/components/switch.d.ts +8 -0
  190. package/dist/components/switch.js +53 -0
  191. package/dist/components/switch.js.map +1 -0
  192. package/dist/components/table.d.ts +12 -0
  193. package/dist/components/table.js +129 -0
  194. package/dist/components/table.js.map +1 -0
  195. package/dist/components/textarea.d.ts +15 -0
  196. package/dist/components/textarea.js +58 -0
  197. package/dist/components/textarea.js.map +1 -0
  198. package/dist/components/toast.d.ts +19 -0
  199. package/dist/components/toast.js +805 -0
  200. package/dist/components/toast.js.map +1 -0
  201. package/dist/components/toggle.d.ts +12 -0
  202. package/dist/components/toggle.js +63 -0
  203. package/dist/components/toggle.js.map +1 -0
  204. package/dist/components/tooltip.d.ts +9 -0
  205. package/dist/components/tooltip.js +82 -0
  206. package/dist/components/tooltip.js.map +1 -0
  207. package/dist/hooks/use-dialog-footer-actions.d.ts +18 -0
  208. package/dist/hooks/use-dialog-footer-actions.js +11 -0
  209. package/dist/hooks/use-dialog-footer-actions.js.map +1 -0
  210. package/dist/hooks/use-form-field.d.ts +33 -0
  211. package/dist/hooks/use-form-field.js +36 -0
  212. package/dist/hooks/use-form-field.js.map +1 -0
  213. package/dist/hooks/use-mobile.d.ts +34 -0
  214. package/dist/hooks/use-mobile.js +29 -0
  215. package/dist/hooks/use-mobile.js.map +1 -0
  216. package/dist/hooks/use-pinch-pan.d.ts +56 -0
  217. package/dist/hooks/use-pinch-pan.js +223 -0
  218. package/dist/hooks/use-pinch-pan.js.map +1 -0
  219. package/dist/hooks/useMobileBackToClose.d.ts +61 -0
  220. package/dist/hooks/useMobileBackToClose.js +234 -0
  221. package/dist/hooks/useMobileBackToClose.js.map +1 -0
  222. package/dist/icon-BOgL5bpL.d.ts +85 -0
  223. package/dist/icons/actions.d.ts +4 -0
  224. package/dist/icons/actions.js +520 -0
  225. package/dist/icons/actions.js.map +1 -0
  226. package/dist/icons/icon-style.d.ts +2 -0
  227. package/dist/icons/icon-style.js +51 -0
  228. package/dist/icons/icon-style.js.map +1 -0
  229. package/dist/icons/marks.d.ts +15 -0
  230. package/dist/icons/marks.js +48 -0
  231. package/dist/icons/marks.js.map +1 -0
  232. package/dist/icons/registry.d.ts +177 -0
  233. package/dist/icons/registry.js +1180 -0
  234. package/dist/icons/registry.js.map +1 -0
  235. package/dist/index.d.ts +6 -0
  236. package/dist/index.js +653 -0
  237. package/dist/index.js.map +1 -0
  238. package/dist/lib/merge-config.d.ts +15 -0
  239. package/dist/lib/merge-config.js +20 -0
  240. package/dist/lib/merge-config.js.map +1 -0
  241. package/dist/lib/pagination.d.ts +19 -0
  242. package/dist/lib/pagination.js +26 -0
  243. package/dist/lib/pagination.js.map +1 -0
  244. package/dist/lib/utils.d.ts +5 -0
  245. package/dist/lib/utils.js +26 -0
  246. package/dist/lib/utils.js.map +1 -0
  247. package/dist/styles/theme.css +480 -0
  248. package/dist/styles/theme.css.map +1 -0
  249. package/dist/styles/theme.d.ts +2 -0
  250. package/dist/viewport.d.ts +64 -0
  251. package/dist/viewport.js +13 -0
  252. package/dist/viewport.js.map +1 -0
  253. package/package.json +122 -0
  254. package/src/components/alert.tsx +76 -0
  255. package/src/components/app-dialog.tsx +187 -0
  256. package/src/components/badge.tsx +52 -0
  257. package/src/components/breadcrumb.tsx +129 -0
  258. package/src/components/button.tsx +70 -0
  259. package/src/components/caption-bar.test.tsx +151 -0
  260. package/src/components/caption-bar.tsx +392 -0
  261. package/src/components/chart-card.tsx +31 -0
  262. package/src/components/checkbox.tsx +28 -0
  263. package/src/components/chip-button.tsx +69 -0
  264. package/src/components/close-confirm-dialog.tsx +99 -0
  265. package/src/components/color-picker.tsx +186 -0
  266. package/src/components/combobox.tsx +335 -0
  267. package/src/components/confirm-by-typing-dialog.tsx +130 -0
  268. package/src/components/confirm-dialog.test.tsx +39 -0
  269. package/src/components/confirm-dialog.tsx +98 -0
  270. package/src/components/count-badge.tsx +23 -0
  271. package/src/components/dialog.tsx +315 -0
  272. package/src/components/dropdown-menu.tsx +311 -0
  273. package/src/components/dropdown-select.tsx +211 -0
  274. package/src/components/empty.tsx +104 -0
  275. package/src/components/filter-rail.tsx +139 -0
  276. package/src/components/fixed-action-bar.tsx +46 -0
  277. package/src/components/font-picker.tsx +257 -0
  278. package/src/components/form.tsx +127 -0
  279. package/src/components/group-label.tsx +13 -0
  280. package/src/components/icon-tile.tsx +35 -0
  281. package/src/components/icon-toggle.tsx +57 -0
  282. package/src/components/icon.tsx +66 -0
  283. package/src/components/image-crop-panel.test.tsx +167 -0
  284. package/src/components/image-crop-panel.tsx +465 -0
  285. package/src/components/image-cropper.test.tsx +144 -0
  286. package/src/components/image-cropper.tsx +265 -0
  287. package/src/components/input-group.tsx +156 -0
  288. package/src/components/input.tsx +45 -0
  289. package/src/components/label.tsx +20 -0
  290. package/src/components/list-empty-row.tsx +27 -0
  291. package/src/components/management-empty.tsx +25 -0
  292. package/src/components/management-skeleton.tsx +23 -0
  293. package/src/components/meta-chip.tsx +40 -0
  294. package/src/components/mobile-drawer.test.tsx +52 -0
  295. package/src/components/mobile-drawer.tsx +98 -0
  296. package/src/components/page-header.tsx +47 -0
  297. package/src/components/page-scaffold.test.tsx +24 -0
  298. package/src/components/page-scaffold.tsx +131 -0
  299. package/src/components/pagination-bar.test.tsx +87 -0
  300. package/src/components/pagination-bar.tsx +139 -0
  301. package/src/components/pagination.tsx +139 -0
  302. package/src/components/panel-toolbar.tsx +99 -0
  303. package/src/components/pill-tabs.test.tsx +84 -0
  304. package/src/components/pill-tabs.tsx +77 -0
  305. package/src/components/plugin-download-consent.test.tsx +90 -0
  306. package/src/components/plugin-download-consent.tsx +183 -0
  307. package/src/components/popover.tsx +133 -0
  308. package/src/components/progress.tsx +88 -0
  309. package/src/components/query-state-view.tsx +47 -0
  310. package/src/components/radio-group.tsx +36 -0
  311. package/src/components/scroll-area.tsx +52 -0
  312. package/src/components/search-field.test.tsx +86 -0
  313. package/src/components/search-field.tsx +119 -0
  314. package/src/components/secondary-stat.tsx +24 -0
  315. package/src/components/section-header.tsx +54 -0
  316. package/src/components/section-label.tsx +40 -0
  317. package/src/components/section-tabs.test.tsx +154 -0
  318. package/src/components/section-tabs.tsx +190 -0
  319. package/src/components/separator.tsx +38 -0
  320. package/src/components/sheet.tsx +170 -0
  321. package/src/components/sidebar.tsx +725 -0
  322. package/src/components/skeleton.tsx +18 -0
  323. package/src/components/slider.tsx +52 -0
  324. package/src/components/sortable-chip-list.tsx +56 -0
  325. package/src/components/spinner.tsx +35 -0
  326. package/src/components/surface.tsx +37 -0
  327. package/src/components/switch.tsx +30 -0
  328. package/src/components/table.tsx +116 -0
  329. package/src/components/textarea.tsx +41 -0
  330. package/src/components/toast.tsx +239 -0
  331. package/src/components/toggle.tsx +45 -0
  332. package/src/components/tooltip.tsx +64 -0
  333. package/src/hooks/use-dialog-footer-actions.ts +23 -0
  334. package/src/hooks/use-form-field.ts +62 -0
  335. package/src/hooks/use-mobile.ts +54 -0
  336. package/src/hooks/use-pinch-pan.test.tsx +42 -0
  337. package/src/hooks/use-pinch-pan.ts +340 -0
  338. package/src/hooks/useMobileBackToClose.test.tsx +140 -0
  339. package/src/hooks/useMobileBackToClose.ts +508 -0
  340. package/src/icons/actions.ts +10 -0
  341. package/src/icons/icon-style.test.tsx +69 -0
  342. package/src/icons/icon-style.ts +104 -0
  343. package/src/icons/marks.tsx +72 -0
  344. package/src/icons/registry.test.ts +65 -0
  345. package/src/icons/registry.ts +1163 -0
  346. package/src/index.ts +22 -0
  347. package/src/lib/merge-config.test.ts +109 -0
  348. package/src/lib/merge-config.ts +22 -0
  349. package/src/lib/pagination.test.ts +55 -0
  350. package/src/lib/pagination.ts +42 -0
  351. package/src/lib/utils.ts +6 -0
  352. package/src/styles/scan-contract.test.ts +74 -0
  353. package/src/styles/theme.css +798 -0
  354. package/src/test/i18n.tsx +22 -0
  355. package/src/test/setup.ts +104 -0
  356. package/src/viewport.ts +69 -0
@@ -0,0 +1,140 @@
1
+ import { act, cleanup, render } from "@testing-library/react"
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
3
+ import {
4
+ setNavigationResolver,
5
+ useMobileBackToClose,
6
+ } from "./useMobileBackToClose"
7
+
8
+ const HISTORY_KEY = "__appMobileOverlay"
9
+
10
+ function Overlay({
11
+ open,
12
+ onOpenChange,
13
+ }: {
14
+ open: boolean
15
+ onOpenChange: (open: boolean) => void
16
+ }) {
17
+ useMobileBackToClose(open, onOpenChange)
18
+ return null
19
+ }
20
+
21
+ function dispatchPop(state: unknown): void {
22
+ window.dispatchEvent(new PopStateEvent("popstate", { state }))
23
+ }
24
+
25
+ describe("useMobileBackToClose", () => {
26
+ beforeEach(() => {
27
+ vi.stubGlobal("matchMedia", () => ({ matches: true }))
28
+ // Simulate the production resolver: it only fires on actual route
29
+ // transitions, so UI-only closes must fall back to rAF cleanup.
30
+ setNavigationResolver(() => () => {})
31
+ })
32
+
33
+ afterEach(() => {
34
+ vi.unstubAllGlobals()
35
+ cleanup()
36
+ // Reset history.state between tests so each test starts from a
37
+ // clean base entry.
38
+ window.history.replaceState(null, "")
39
+ })
40
+
41
+ it("closes a single overlay when the back gesture returns to the base state", () => {
42
+ const onClose = vi.fn()
43
+ render(<Overlay open onOpenChange={onClose} />)
44
+
45
+ expect(window.history.state).toHaveProperty(HISTORY_KEY)
46
+
47
+ act(() => {
48
+ dispatchPop(null)
49
+ })
50
+
51
+ expect(onClose).toHaveBeenCalledWith(false)
52
+ })
53
+
54
+ it("closes nested overlays from top to bottom", () => {
55
+ const sheetClose = vi.fn()
56
+ const dialogClose = vi.fn()
57
+
58
+ const { rerender } = render(
59
+ <>
60
+ <Overlay open onOpenChange={sheetClose} />
61
+ <Overlay open={false} onOpenChange={dialogClose} />
62
+ </>,
63
+ )
64
+
65
+ const sheetState = window.history.state
66
+ expect(sheetState).toHaveProperty(HISTORY_KEY)
67
+
68
+ rerender(
69
+ <>
70
+ <Overlay open onOpenChange={sheetClose} />
71
+ <Overlay open onOpenChange={dialogClose} />
72
+ </>,
73
+ )
74
+
75
+ const dialogState = window.history.state
76
+ expect(dialogState).toHaveProperty(HISTORY_KEY)
77
+ expect(dialogState[HISTORY_KEY]).not.toBe(sheetState[HISTORY_KEY])
78
+
79
+ // Back from dialog lands on the sheet's synthetic entry.
80
+ act(() => {
81
+ dispatchPop(sheetState)
82
+ })
83
+ expect(dialogClose).toHaveBeenCalledWith(false)
84
+ expect(sheetClose).not.toHaveBeenCalled()
85
+
86
+ // Back from sheet lands on the base route state.
87
+ act(() => {
88
+ dispatchPop(null)
89
+ })
90
+ expect(sheetClose).toHaveBeenCalledWith(false)
91
+ })
92
+
93
+ it("does not let a closing lower overlay unwind history on top of a newly opened one", async () => {
94
+ const menuClose = vi.fn()
95
+ const dialogClose = vi.fn()
96
+
97
+ const { rerender } = render(<Overlay open onOpenChange={menuClose} />)
98
+
99
+ const menuState = window.history.state
100
+ expect(menuState).toHaveProperty(HISTORY_KEY)
101
+
102
+ // Close menu and open dialog in the same commit.
103
+ rerender(
104
+ <>
105
+ <Overlay open={false} onOpenChange={menuClose} />
106
+ <Overlay open onOpenChange={dialogClose} />
107
+ </>,
108
+ )
109
+
110
+ // Let the menu's deferred cleanup check run.
111
+ await act(async () => {
112
+ await new Promise((resolve) => requestAnimationFrame(resolve))
113
+ })
114
+
115
+ // The dialog's synthetic entry must still be current; the menu
116
+ // cleanup should NOT have called history.back() and replaced the
117
+ // state with the menu's base state.
118
+ const state = window.history.state
119
+ expect(state).toHaveProperty(HISTORY_KEY)
120
+ expect(state[HISTORY_KEY]).not.toBe(menuState[HISTORY_KEY])
121
+ })
122
+
123
+ it("cleans up the synthetic entry on UI close even when the navigation resolver never fires", async () => {
124
+ const onClose = vi.fn()
125
+ const { rerender } = render(<Overlay open onOpenChange={onClose} />)
126
+
127
+ expect(window.history.state).toHaveProperty(HISTORY_KEY)
128
+
129
+ rerender(<Overlay open={false} onOpenChange={onClose} />)
130
+
131
+ // Wait for the rAF check, then for the asynchronous popstate that
132
+ // fires after history.back() completes.
133
+ await act(async () => {
134
+ await new Promise((resolve) => requestAnimationFrame(resolve))
135
+ await new Promise((resolve) => setTimeout(resolve, 100))
136
+ })
137
+
138
+ expect(HISTORY_KEY in (window.history.state ?? {})).toBe(false)
139
+ })
140
+ })
@@ -0,0 +1,508 @@
1
+ import { isBelowMd } from "@hoardodile/ui/hooks/use-mobile"
2
+ import { useEffect, useRef } from "react"
3
+
4
+ const HISTORY_KEY = "__appMobileOverlay"
5
+
6
+ /**
7
+ * LIFO stack of currently-open overlays registered via
8
+ * {@link useMobileBackToClose}. Each entry knows how to close its own
9
+ * overlay. The browser back gesture only closes the top entry, so
10
+ * stacking dialogs/popovers/sheets unwind in reverse opening order
11
+ * instead of all collapsing on the first back press.
12
+ *
13
+ * State is module-level so multiple component instances share a
14
+ * single popstate listener and a single ordering source of truth.
15
+ */
16
+ type OverlayEntry = {
17
+ readonly id: number
18
+ /** Original history state captured at push time for later restore. */
19
+ readonly baseState: unknown
20
+ close: (() => void) | undefined
21
+ }
22
+
23
+ const overlayStack: OverlayEntry[] = []
24
+ let popListenerInstalled = false
25
+ let historyPatched = false
26
+ let nextOverlayId = 0
27
+ // Counts consecutive popstate events to suppress. When we call
28
+ // `history.back()` N times (e.g. to unwind an active entry plus
29
+ // parked entries), the next N popstate events must be swallowed so
30
+ // they don't cascade-close deeper overlays.
31
+ let suppressPopCount = 0
32
+ /**
33
+ * Tracks every synthetic entry ID ever pushed. Used by the
34
+ * capture-phase popstate listener to identify "phantom" entries —
35
+ * synthetic history slots that remain in forward/back history after
36
+ * the overlay was closed via route navigation. The listener auto-skips
37
+ * these to prevent dead back-presses and router confusion.
38
+ */
39
+ const seenSyntheticIds = new Set<number>()
40
+
41
+ /**
42
+ * Optional navigation-resolver registration injected by the host app.
43
+ *
44
+ * When set (via {@link setNavigationResolver}), `scheduleStateRestore`
45
+ * uses it to receive a deterministic "navigation complete" signal from
46
+ * the router instead of guessing timing via `requestAnimationFrame`.
47
+ *
48
+ * The registered function accepts a one-shot callback and returns an
49
+ * unsubscribe function. The hook calls it, inspects `history.state`
50
+ * inside the callback, then unsubscribes.
51
+ */
52
+ type NavigationResolver = (onResolved: () => void) => () => void
53
+ let navigationResolver: NavigationResolver | undefined
54
+
55
+ /**
56
+ * Registers a navigation-resolver factory so that
57
+ * {@link useMobileBackToClose} can wait for the router to finish
58
+ * flushing its history mutations before inspecting `history.state`.
59
+ *
60
+ * Call this once at app startup, after the router is created:
61
+ *
62
+ * ```ts
63
+ * import { setNavigationResolver } from "@hoardodile/ui"
64
+ * import { router } from "./router"
65
+ *
66
+ * setNavigationResolver((fn) => router.subscribe("onResolved", fn))
67
+ * ```
68
+ *
69
+ * When no resolver is registered, the hook falls back to
70
+ * `requestAnimationFrame` (correct for microtask-based routers but
71
+ * less deterministic).
72
+ */
73
+ export function setNavigationResolver(resolver: NavigationResolver): void {
74
+ navigationResolver = resolver
75
+ }
76
+
77
+ /**
78
+ * Patches `history.pushState` once (idempotent) so that every call
79
+ * from a router (or any other non-overlay code) is intercepted.
80
+ *
81
+ * When a pushState is detected that does NOT carry our
82
+ * {@link HISTORY_KEY} marker, all open overlay entries are closed
83
+ * and the stack is cleared. The synthetic entry's marker is
84
+ * intentionally NOT stripped — the capture-phase popstate listener
85
+ * uses it to identify and auto-skip the stranded entry when the
86
+ * user later navigates past it.
87
+ */
88
+ function patchHistory(): void {
89
+ if (historyPatched) return
90
+ if (typeof window === "undefined") return
91
+ historyPatched = true
92
+ const origPush = window.history.pushState.bind(window.history)
93
+ window.history.pushState = (
94
+ data: unknown,
95
+ unused: string,
96
+ url?: string | URL | null,
97
+ ): void => {
98
+ const state = data as Record<string, unknown> | null
99
+ if (state !== null && typeof state === "object" && HISTORY_KEY in state) {
100
+ // Our own synthetic push — pass through.
101
+ origPush(data, unused, url)
102
+ return
103
+ }
104
+ // Real navigation — close all overlays so the stack is
105
+ // cleared synchronously (before React's async effects).
106
+ // The synthetic entry remains in history with its marker
107
+ // intact; the capture-phase popstate listener will
108
+ // auto-skip it later.
109
+ if (overlayStack.length > 0) {
110
+ closeAllOverlays()
111
+ }
112
+ origPush(data, unused, url)
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Closes every active overlay in the stack (top-down) and clears
118
+ * the stack. Called when a real navigation is detected so that
119
+ * overlay state doesn't leak across route boundaries.
120
+ */
121
+ function closeAllOverlays(): void {
122
+ while (overlayStack.length > 0) {
123
+ const entry = overlayStack.pop()
124
+ if (entry?.close !== undefined) {
125
+ const fn = entry.close
126
+ entry.close = undefined
127
+ fn()
128
+ }
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Installs popstate listeners and patches history.pushState.
134
+ *
135
+ * Two listeners are installed:
136
+ *
137
+ * 1. **Capture-phase** — runs BEFORE any bubbling-phase listeners
138
+ * (including TanStack Router's popstate handler). Inspects the
139
+ * state at the history pointer:
140
+ *
141
+ * - **Active entry** (id found in overlayStack): the back
142
+ * gesture landed on a synthetic entry that belongs to an
143
+ * overlay still below the top of the stack. Pop and close the
144
+ * top overlay (the one the user is actually dismissing) and
145
+ * block the event from reaching the router via
146
+ * `stopImmediatePropagation()`.
147
+ *
148
+ * - **Phantom entry** (id in `seenSyntheticIds` but NOT in
149
+ * overlayStack): a synthetic history slot that remains after
150
+ * the overlay was closed via route navigation. Blocks the
151
+ * event AND auto-skips by calling `history.back()` to advance
152
+ * the pointer past the phantom, so the router never sees its
153
+ * stale URL.
154
+ *
155
+ * 2. **Bubbling-phase** — closes the top overlay when the back
156
+ * gesture returns to the base route state (no synthetic marker),
157
+ * which happens for the last open overlay.
158
+ */
159
+ function ensurePopListener(): void {
160
+ if (popListenerInstalled) return
161
+ if (typeof window === "undefined") return
162
+ popListenerInstalled = true
163
+
164
+ // Capture-phase gate: block synthetic popstate from reaching
165
+ // the router (or any other bubbling-phase listener on window).
166
+ window.addEventListener(
167
+ "popstate",
168
+ (e: PopStateEvent) => {
169
+ if (suppressPopCount > 0) return // let the counter handle it
170
+ const state = e.state as Record<string, unknown> | null
171
+ if (state !== null && typeof state === "object" && HISTORY_KEY in state) {
172
+ const id = state[HISTORY_KEY]
173
+ // Active entry still in the stack → the back gesture
174
+ // landed on a synthetic entry of an overlay below the
175
+ // top. Close the top overlay and hide the event from
176
+ // the router.
177
+ if (
178
+ typeof id === "number" &&
179
+ overlayStack.some((entry) => entry.id === id)
180
+ ) {
181
+ closeTopOverlay()
182
+ e.stopImmediatePropagation()
183
+ return
184
+ }
185
+ // Phantom entry: a synthetic history slot that
186
+ // remains after the overlay was closed via
187
+ // route navigation. Block the event from the
188
+ // router AND auto-skip by calling history.back()
189
+ // to move the pointer past the phantom.
190
+ e.stopImmediatePropagation()
191
+ if (typeof id === "number" && seenSyntheticIds.has(id)) {
192
+ window.history.back()
193
+ }
194
+ }
195
+ },
196
+ true, // capture phase
197
+ )
198
+
199
+ window.addEventListener("popstate", handlePop)
200
+ patchHistory()
201
+ }
202
+
203
+ function closeTopOverlay(): void {
204
+ const top = overlayStack.pop()
205
+ if (top === undefined) return
206
+ // `close` may be cleared if this entry was dismissed via UI from a
207
+ // non-top position; the popstate then just unwinds a stale
208
+ // synthetic history slot and there is nothing to close.
209
+ if (top.close === undefined) return
210
+ // Clear close before calling it so the resulting effect sees
211
+ // idRef as undefined (back-gesture close) and skips the
212
+ // back() + replaceState() UI-close path.
213
+ const closeFn = top.close
214
+ top.close = undefined
215
+ closeFn()
216
+ }
217
+
218
+ function handlePop(): void {
219
+ if (suppressPopCount > 0) {
220
+ suppressPopCount--
221
+ return
222
+ }
223
+ closeTopOverlay()
224
+ }
225
+
226
+ /**
227
+ * Removes the entry with the given `id` from the overlay stack.
228
+ *
229
+ * When the entry is **not** the top of the stack, it is "parked"
230
+ * (left in place with its close callback cleared) so the synthetic
231
+ * history slot survives until swept.
232
+ *
233
+ * @returns An object describing what was removed:
234
+ * - `removed`: whether the entry was found.
235
+ * - `parkedBelow`: how many parked (close=undefined) entries were
236
+ * found below the removed entry. These must be unwound alongside
237
+ * the active entry to prevent dead back-presses.
238
+ */
239
+ function removeFromStack(id: number): {
240
+ readonly removed: boolean
241
+ readonly parkedBelow: number
242
+ } {
243
+ const idx = overlayStack.findIndex((e) => e.id === id)
244
+ if (idx === -1) return { removed: false, parkedBelow: 0 }
245
+ const wasTop = idx === overlayStack.length - 1
246
+ if (!wasTop) {
247
+ // Park: keep the history slot but neutralise the callback.
248
+ const entry = overlayStack[idx]
249
+ if (entry !== undefined) entry.close = undefined
250
+ return { removed: true, parkedBelow: 0 }
251
+ }
252
+ // Top entry — remove it and count ALL parked entries below so
253
+ // their orphaned synthetic history slots are unwound in the same
254
+ // batch as the active entry's slot.
255
+ overlayStack.pop()
256
+ const remaining = overlayStack.splice(0, overlayStack.length)
257
+ const active = remaining.filter((e) => e.close !== undefined)
258
+ const parkedBelow = remaining.length - active.length
259
+ overlayStack.push(...active)
260
+ return { removed: true, parkedBelow }
261
+ }
262
+
263
+ /**
264
+ * Cleans up the synthetic history entry after an overlay closes via
265
+ * UI (not back gesture, not route navigation).
266
+ *
267
+ * `history.back()` is asynchronous — it queues a history traversal
268
+ * task. The popstate event fires only after the traversal completes
269
+ * (pointer has moved to the target entry). By replacing the state
270
+ * inside the popstate handler, we ensure:
271
+ *
272
+ * 1. The pointer is at the base entry (not the synthetic one).
273
+ * 2. `replaceState` truncates forward history, eliminating the
274
+ * phantom synthetic entry that `back()` alone would leave behind.
275
+ *
276
+ * **Route-change safety**: When a navigation-resolver is registered
277
+ * (via {@link setNavigationResolver}), `scheduleStateRestore` uses
278
+ * the router's deterministic "navigation resolved" signal to know
279
+ * when `history.state` reflects the new route. When no resolver is
280
+ * available (e.g. tests), it falls back to `requestAnimationFrame`,
281
+ * which runs after the microtask queue where the router flushes its
282
+ * pending `pushState` calls.
283
+ *
284
+ * - If `history.state` still carries our marker → normal UI close,
285
+ * proceed with the `back()` + `replaceState()` cleanup.
286
+ * - If `history.state` no longer carries our marker → a route
287
+ * change has pushed a real entry on top. Skip the `back()` call
288
+ * to avoid racing with the router's push. The capture-phase
289
+ * popstate listener will auto-skip the stranded phantom entry.
290
+ */
291
+ function scheduleStateRestore(
292
+ id: number,
293
+ baseState: unknown,
294
+ parkedBelow: number,
295
+ ): void {
296
+ function check(): void {
297
+ const currentState = window.history.state as Record<string, unknown> | null
298
+
299
+ // The history pointer is no longer on this overlay's own
300
+ // synthetic entry (route change, or another overlay has
301
+ // already pushed/popped on top). Do NOT call history.back()
302
+ // — it would race with the current top entry and navigate
303
+ // to the wrong place. Just sweep parked entries from the
304
+ // stack but do NOT prune `seenSyntheticIds`; any stranded
305
+ // synthetic entries will be auto-skipped by the capture-phase
306
+ // listener when the user navigates past them.
307
+ if (
308
+ currentState === null ||
309
+ typeof currentState !== "object" ||
310
+ !(HISTORY_KEY in currentState) ||
311
+ currentState[HISTORY_KEY] !== id
312
+ ) {
313
+ sweepParkedEntries()
314
+ return
315
+ }
316
+
317
+ const totalBacks = 1 + parkedBelow
318
+ let done = false
319
+
320
+ function restore(): void {
321
+ if (done) return
322
+ done = true
323
+ window.removeEventListener("popstate", onPop)
324
+ window.history.replaceState(baseState, "")
325
+ // replaceState truncates forward history, eliminating
326
+ // phantom entries. Safe to prune their IDs from
327
+ // `seenSyntheticIds` (excluding IDs still in the
328
+ // active stack).
329
+ const sweptIds = sweepParkedEntries()
330
+ cleanupSeenIds(sweptIds)
331
+ }
332
+
333
+ function onPop(): void {
334
+ if (suppressPopCount > 0) {
335
+ suppressPopCount--
336
+ return
337
+ }
338
+ restore()
339
+ }
340
+
341
+ window.addEventListener("popstate", onPop)
342
+
343
+ suppressPopCount += totalBacks
344
+ for (let i = 0; i < totalBacks; i++) {
345
+ window.history.back()
346
+ }
347
+ }
348
+
349
+ // Use both the router's deterministic "navigation resolved" signal
350
+ // and a requestAnimationFrame fallback. In the common UI-close case
351
+ // (e.g. clicking outside a dropdown) there is no route change, so
352
+ // the resolver never fires. Without the fallback the synthetic
353
+ // history entry would be left behind as a phantom.
354
+ let disposed = false
355
+ let rafId: number | undefined
356
+ let unsub: (() => void) | undefined
357
+
358
+ function run(): void {
359
+ if (disposed) return
360
+ disposed = true
361
+ unsub?.()
362
+ if (rafId !== undefined) {
363
+ cancelAnimationFrame(rafId)
364
+ }
365
+ check()
366
+ }
367
+
368
+ if (navigationResolver !== undefined) {
369
+ unsub = navigationResolver(run)
370
+ }
371
+
372
+ rafId = requestAnimationFrame(run)
373
+ }
374
+
375
+ /**
376
+ * Sweeps all parked entries (those with no close callback) from the
377
+ * overlay stack. Called after `replaceState` truncates forward
378
+ * history, since parked entries no longer have corresponding history
379
+ * entries and would cause dead back-presses.
380
+ *
381
+ * @returns The IDs of entries that were removed, used by the caller
382
+ * to prune `seenSyntheticIds`.
383
+ */
384
+ function sweepParkedEntries(): readonly number[] {
385
+ const sweptIds: number[] = []
386
+ for (let i = overlayStack.length - 1; i >= 0; i--) {
387
+ const entry = overlayStack[i]
388
+ if (entry !== undefined && entry.close === undefined) {
389
+ sweptIds.push(entry.id)
390
+ overlayStack.splice(i, 1)
391
+ }
392
+ }
393
+ return sweptIds
394
+ }
395
+
396
+ /**
397
+ * Removes swept IDs from `seenSyntheticIds` to prevent unbounded
398
+ * growth over the lifetime of the SPA session.
399
+ *
400
+ * After `replaceState` truncates forward history, phantom entries
401
+ * for swept parked overlays are eliminated. Their IDs can safely
402
+ * be removed from `seenSyntheticIds` — unless the same ID belongs
403
+ * to an entry still active in the stack (which happens when a
404
+ * lower-ID entry is parked but a higher-ID entry above it is still
405
+ * open and not swept).
406
+ */
407
+ function cleanupSeenIds(sweptIds: readonly number[]): void {
408
+ const activeIds = new Set(overlayStack.map((e) => e.id))
409
+ for (const id of sweptIds) {
410
+ if (!activeIds.has(id)) {
411
+ seenSyntheticIds.delete(id)
412
+ }
413
+ }
414
+ }
415
+
416
+ /**
417
+ * On mobile (< sm), when an overlay (dialog/sheet/popover) opens we push a
418
+ * synthetic history entry so the device back gesture closes it instead of
419
+ * navigating away from the page.
420
+ *
421
+ * **Closing via UI** calls `history.back()` to navigate past the
422
+ * synthetic entry, then uses `replaceState` (inside the resulting
423
+ * popstate handler) to restore the original history state.
424
+ *
425
+ * **Closing via back gesture** is handled by the popstate listener,
426
+ * which pops the stack entry and calls the close callback.
427
+ *
428
+ * **Closing via route navigation**: the `pushState` interceptor
429
+ * closes all overlays synchronously but does NOT strip the synthetic
430
+ * marker. The React effect's `scheduleStateRestore` waits for the
431
+ * router's navigation-resolved signal (or falls back to
432
+ * `requestAnimationFrame`); by then the router's push has completed
433
+ * and the marker is gone from `history.state`, so `back()` is
434
+ * skipped. The stranded synthetic entry is later auto-skipped by the
435
+ * capture-phase popstate listener when the user navigates past it.
436
+ *
437
+ * Multiple overlays open at once stack: pressing back closes the most
438
+ * recently opened one first, matching the visual stacking order.
439
+ *
440
+ * No-op on non-mobile viewports and when no `onOpenChange` is provided
441
+ * (uncontrolled overlays cannot be closed externally).
442
+ */
443
+ export function useMobileBackToClose(
444
+ open: boolean | undefined,
445
+ onOpenChange: ((open: boolean) => void) | undefined,
446
+ ): void {
447
+ const idRef = useRef<number | undefined>(undefined)
448
+ const onChangeRef = useRef(onOpenChange)
449
+ onChangeRef.current = onOpenChange
450
+
451
+ useEffect(() => {
452
+ if (typeof window === "undefined") return
453
+ if (onChangeRef.current === undefined) return
454
+ if (!isBelowMd()) return
455
+ ensurePopListener()
456
+
457
+ if (open === true && idRef.current === undefined) {
458
+ const id = ++nextOverlayId
459
+ idRef.current = id
460
+ seenSyntheticIds.add(id)
461
+ // Capture the base state BEFORE pushing so we can restore
462
+ // it on close (via replaceState) to eliminate phantoms.
463
+ const baseState = window.history.state ?? null
464
+ window.history.pushState(
465
+ {
466
+ ...((baseState as Record<string, unknown>) ?? {}),
467
+ [HISTORY_KEY]: id,
468
+ },
469
+ "",
470
+ )
471
+ overlayStack.push({
472
+ id,
473
+ baseState,
474
+ close() {
475
+ idRef.current = undefined
476
+ onChangeRef.current?.(false)
477
+ },
478
+ })
479
+ return
480
+ }
481
+
482
+ if (open !== true && idRef.current !== undefined) {
483
+ const id = idRef.current
484
+ idRef.current = undefined
485
+ const entryIdx = overlayStack.findIndex((e) => e.id === id)
486
+ const entry = entryIdx !== -1 ? overlayStack[entryIdx] : undefined
487
+ const { removed, parkedBelow } = removeFromStack(id)
488
+ if (removed && entry !== undefined) {
489
+ scheduleStateRestore(id, entry.baseState, parkedBelow)
490
+ }
491
+ }
492
+ }, [open])
493
+
494
+ useEffect(() => {
495
+ return () => {
496
+ if (idRef.current !== undefined) {
497
+ const id = idRef.current
498
+ idRef.current = undefined
499
+ const entryIdx = overlayStack.findIndex((e) => e.id === id)
500
+ const entry = entryIdx !== -1 ? overlayStack[entryIdx] : undefined
501
+ const { removed, parkedBelow } = removeFromStack(id)
502
+ if (removed && entry !== undefined) {
503
+ scheduleStateRestore(id, entry.baseState, parkedBelow)
504
+ }
505
+ }
506
+ }
507
+ }, [])
508
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Action vocabulary — the canonical binding of recurring chrome actions to
3
+ * glyphs. Import action icons from here, never by glyph name: two places
4
+ * that both mean "add" both render `Add`, so auditing an action is a
5
+ * single grep. Check stays under its own name — there is exactly one
6
+ * check, it cannot be confused.
7
+ */
8
+
9
+ export { Check, Cross as Remove, Plus as Add } from "./marks.tsx"
10
+ export { MenuDots as More } from "./registry"