@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,154 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react"
2
+ import { describe, expect, it, vi } from "vitest"
3
+ import { SectionTabs } from "./section-tabs"
4
+
5
+ const ITEMS = [
6
+ { value: "a", label: "Alpha" },
7
+ { value: "b", label: "Beta" },
8
+ { value: "c", label: "Gamma" },
9
+ ]
10
+
11
+ describe("SectionTabs", () => {
12
+ it("renders a tablist with one tab per item and the active state", () => {
13
+ render(<SectionTabs value="b" items={ITEMS} />)
14
+ expect(screen.getByRole("tablist")).toBeInTheDocument()
15
+ expect(screen.getByRole("tab", { name: "Alpha" })).toHaveAttribute(
16
+ "aria-selected",
17
+ "false",
18
+ )
19
+ const beta = screen.getByRole("tab", { name: "Beta" })
20
+ expect(beta).toHaveAttribute("aria-selected", "true")
21
+ expect(beta).toHaveAttribute("tabindex", "0")
22
+ expect(screen.getByRole("tab", { name: "Alpha" })).toHaveAttribute(
23
+ "tabindex",
24
+ "-1",
25
+ )
26
+ })
27
+
28
+ it("reports the picked value through onChange", () => {
29
+ const onChange = vi.fn()
30
+ render(<SectionTabs value="a" items={ITEMS} onChange={onChange} />)
31
+ fireEvent.click(screen.getByRole("tab", { name: "Gamma" }))
32
+ expect(onChange).toHaveBeenCalledWith("c")
33
+ })
34
+
35
+ it("moves activation and focus with the arrow keys", () => {
36
+ const onChange = vi.fn()
37
+ const { rerender } = render(
38
+ <SectionTabs value="a" items={ITEMS} onChange={onChange} />,
39
+ )
40
+ const alpha = () => screen.getByRole("tab", { name: "Alpha" })
41
+ const beta = () => screen.getByRole("tab", { name: "Beta" })
42
+ const gamma = () => screen.getByRole("tab", { name: "Gamma" })
43
+
44
+ fireEvent.keyDown(alpha(), { key: "ArrowRight" })
45
+ expect(onChange).toHaveBeenLastCalledWith("b")
46
+ expect(beta()).toHaveFocus()
47
+ rerender(<SectionTabs value="b" items={ITEMS} onChange={onChange} />)
48
+
49
+ fireEvent.keyDown(beta(), { key: "ArrowLeft" })
50
+ expect(onChange).toHaveBeenLastCalledWith("a")
51
+ expect(alpha()).toHaveFocus()
52
+ rerender(<SectionTabs value="a" items={ITEMS} onChange={onChange} />)
53
+
54
+ fireEvent.keyDown(alpha(), { key: "ArrowLeft" })
55
+ expect(onChange).toHaveBeenLastCalledWith("c")
56
+ rerender(<SectionTabs value="c" items={ITEMS} onChange={onChange} />)
57
+
58
+ fireEvent.keyDown(gamma(), { key: "Home" })
59
+ expect(onChange).toHaveBeenLastCalledWith("a")
60
+ rerender(<SectionTabs value="a" items={ITEMS} onChange={onChange} />)
61
+
62
+ fireEvent.keyDown(alpha(), { key: "End" })
63
+ expect(onChange).toHaveBeenLastCalledWith("c")
64
+ })
65
+
66
+ it("mounts only the active panel and links it to the active tab", () => {
67
+ const { rerender } = render(
68
+ <SectionTabs
69
+ value="a"
70
+ items={[
71
+ { ...ITEMS[0]!, panel: <p>Panel Alpha</p> },
72
+ { ...ITEMS[1]!, panel: <p>Panel Beta</p> },
73
+ ]}
74
+ />,
75
+ )
76
+ const panel = screen.getByRole("tabpanel")
77
+ expect(panel).toHaveTextContent("Panel Alpha")
78
+ expect(panel).toHaveAttribute(
79
+ "aria-labelledby",
80
+ screen.getByRole("tab", { name: "Alpha" }).id,
81
+ )
82
+ expect(screen.queryByText("Panel Beta")).not.toBeInTheDocument()
83
+
84
+ rerender(
85
+ <SectionTabs
86
+ value="b"
87
+ items={[
88
+ { ...ITEMS[0]!, panel: <p>Panel Alpha</p> },
89
+ { ...ITEMS[1]!, panel: <p>Panel Beta</p> },
90
+ ]}
91
+ />,
92
+ )
93
+ expect(screen.getByRole("tabpanel")).toHaveTextContent("Panel Beta")
94
+ expect(screen.queryByText("Panel Alpha")).not.toBeInTheDocument()
95
+ })
96
+
97
+ it("replaces the default panel spacing per item", () => {
98
+ render(
99
+ <SectionTabs
100
+ value="a"
101
+ items={[
102
+ {
103
+ ...ITEMS[0]!,
104
+ panelClassName: "pt-3",
105
+ panel: <p>Panel Alpha</p>,
106
+ },
107
+ ]}
108
+ />,
109
+ )
110
+ expect(screen.getByRole("tabpanel").className).toContain("pt-3")
111
+ expect(screen.getByRole("tabpanel").className).not.toContain("mt-4")
112
+ })
113
+
114
+ it("lets a render slot replace the trigger with ARIA props to spread", () => {
115
+ render(
116
+ <SectionTabs
117
+ value="a"
118
+ items={[
119
+ {
120
+ ...ITEMS[0]!,
121
+ testId: "link-tab",
122
+ render: (active, className, trigger) => (
123
+ <a
124
+ href="/alpha"
125
+ {...trigger}
126
+ className={className}
127
+ data-active={active}
128
+ >
129
+ Alpha link
130
+ </a>
131
+ ),
132
+ },
133
+ { ...ITEMS[1]!, label: "Beta" },
134
+ ]}
135
+ />,
136
+ )
137
+ const link = screen.getByTestId("link-tab")
138
+ expect(link).toHaveAttribute("role", "tab")
139
+ expect(link).toHaveAttribute("aria-selected", "true")
140
+ expect(link.className).toContain("uppercase")
141
+ expect(screen.getByRole("tab", { name: "Beta" })).toBeInTheDocument()
142
+ })
143
+
144
+ it("renders the right-hand controls in the tab row", () => {
145
+ render(
146
+ <SectionTabs
147
+ value="a"
148
+ items={ITEMS}
149
+ controls={<span data-testid="row-control">Sort</span>}
150
+ />,
151
+ )
152
+ expect(screen.getByTestId("row-control")).toBeInTheDocument()
153
+ })
154
+ })
@@ -0,0 +1,190 @@
1
+ import { cn } from "@hoardodile/ui/lib/utils"
2
+ import {
3
+ Fragment,
4
+ type KeyboardEvent,
5
+ type ReactNode,
6
+ useId,
7
+ useRef,
8
+ } from "react"
9
+
10
+ export type SectionTabTriggerProps = {
11
+ readonly id: string
12
+ readonly role: "tab"
13
+ readonly tabIndex: number
14
+ readonly "aria-selected": boolean
15
+ readonly "aria-controls": string | undefined
16
+ readonly "data-testid": string | undefined
17
+ readonly onKeyDown: (event: KeyboardEvent<HTMLElement>) => void
18
+ }
19
+
20
+ export type SectionTabItem<T extends string> = {
21
+ readonly value: T
22
+ readonly label: ReactNode
23
+ readonly testId?: string
24
+ /** Replaces the trigger element (e.g. a router Link) — receives the
25
+ active state, the trigger classes and the ARIA/keyboard props to
26
+ spread onto the element. */
27
+ readonly render?: (
28
+ active: boolean,
29
+ className: string,
30
+ trigger: SectionTabTriggerProps,
31
+ ) => ReactNode
32
+ /** Content panel; mounted only while the tab is active. */
33
+ readonly panel?: ReactNode
34
+ /** Panel wrapper classes, replacing the default `mt-4`. */
35
+ readonly panelClassName?: string
36
+ }
37
+
38
+ export type SectionTabsProps<T extends string> = {
39
+ readonly value: T
40
+ readonly items: readonly SectionTabItem<T>[]
41
+ readonly onChange?: (value: T) => void
42
+ readonly className?: string
43
+ readonly panelClassName?: string
44
+ /** Overrides for the tab bar itself (e.g. an indented side-panel bar). */
45
+ readonly listClassName?: string
46
+ /** Right-hand controls sharing the tab row (e.g. a sort toggle). */
47
+ readonly controls?: ReactNode
48
+ readonly ariaLabel?: string
49
+ }
50
+
51
+ const TRIGGER_CLASSES =
52
+ "relative inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-none border-0 bg-transparent px-0.5 pt-1 pb-3 text-xs font-semibold uppercase tracking-[0.1em] text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 after:absolute after:inset-x-0 after:-bottom-[2px] after:h-0.5 after:bg-foreground after:opacity-0 after:transition-opacity"
53
+
54
+ function triggerClassName(active: boolean): string {
55
+ return cn(TRIGGER_CLASSES, active && "text-foreground after:opacity-100")
56
+ }
57
+
58
+ /**
59
+ * Section-mode tab bar: uppercase 12px labels, a 2px underline on the
60
+ * active tab over a 2px strong bottom edge, and a hidden horizontal
61
+ * scrollbar on overflow. Standalone replacement for the shadcn tabs'
62
+ * `section` variant — the group-data variant chains it leaned on were
63
+ * too fragile to extend.
64
+ */
65
+ export function SectionTabs<T extends string>(props: SectionTabsProps<T>) {
66
+ const {
67
+ value,
68
+ items,
69
+ onChange,
70
+ className,
71
+ panelClassName,
72
+ listClassName,
73
+ controls,
74
+ ariaLabel,
75
+ } = props
76
+ const baseId = useId()
77
+ const listRef = useRef<HTMLDivElement>(null)
78
+
79
+ const activeIndex = items.findIndex((item) => item.value === value)
80
+ const activeItem = activeIndex >= 0 ? items[activeIndex] : undefined
81
+
82
+ function activate(next: T): void {
83
+ if (next !== value) onChange?.(next)
84
+ }
85
+
86
+ function focusTriggerAt(index: number): void {
87
+ const trigger =
88
+ listRef.current?.querySelectorAll<HTMLElement>('[role="tab"]')[index]
89
+ trigger?.focus()
90
+ }
91
+
92
+ function handleKeyDown(event: KeyboardEvent<HTMLElement>): void {
93
+ const from = activeIndex < 0 ? 0 : activeIndex
94
+ const count = items.length
95
+ let nextIndex: number
96
+ switch (event.key) {
97
+ case "ArrowRight":
98
+ nextIndex = (from + 1) % count
99
+ break
100
+ case "ArrowLeft":
101
+ nextIndex = (from - 1 + count) % count
102
+ break
103
+ case "Home":
104
+ nextIndex = 0
105
+ break
106
+ case "End":
107
+ nextIndex = count - 1
108
+ break
109
+ default:
110
+ return
111
+ }
112
+ event.preventDefault()
113
+ const next = items[nextIndex]
114
+ if (next === undefined) return
115
+ activate(next.value)
116
+ focusTriggerAt(nextIndex)
117
+ }
118
+
119
+ return (
120
+ <div className={cn("flex min-w-0 flex-col gap-2", className)}>
121
+ <div className="flex items-center gap-6">
122
+ <div
123
+ ref={listRef}
124
+ role="tablist"
125
+ aria-label={ariaLabel}
126
+ className={cn(
127
+ // The 2px strong edge is an inset shadow over the bottom
128
+ // padding zone: the active underline (positioned 2px
129
+ // below the trigger) lands inside the padding box —
130
+ // a border would be outside it and get clipped by the
131
+ // scroll list's overflow-y-hidden. The color reads the
132
+ // runtime palette variable directly (what border-border
133
+ // compiles to), not the @theme mirror — the mirror
134
+ // lives in a cascade layer and is not reliable here.
135
+ "flex min-w-0 flex-1 items-center gap-6 overflow-x-auto overflow-y-hidden no-scrollbar pb-0.5 shadow-[inset_0_-2px_0_0_var(--border)]",
136
+ listClassName,
137
+ )}
138
+ >
139
+ {items.map((item, index) => {
140
+ const active = item.value === value
141
+ const triggerProps: SectionTabTriggerProps = {
142
+ id: `${baseId}-tab-${index}`,
143
+ role: "tab",
144
+ tabIndex: active ? 0 : -1,
145
+ "aria-selected": active,
146
+ "aria-controls":
147
+ item.panel !== undefined
148
+ ? `${baseId}-panel-${index}`
149
+ : undefined,
150
+ "data-testid": item.testId,
151
+ onKeyDown: handleKeyDown,
152
+ }
153
+ if (item.render !== undefined) {
154
+ return (
155
+ <Fragment key={item.value}>
156
+ {item.render(active, triggerClassName(active), triggerProps)}
157
+ </Fragment>
158
+ )
159
+ }
160
+ return (
161
+ <button
162
+ key={item.value}
163
+ type="button"
164
+ {...triggerProps}
165
+ onClick={() => activate(item.value)}
166
+ className={triggerClassName(active)}
167
+ >
168
+ {item.label}
169
+ </button>
170
+ )
171
+ })}
172
+ </div>
173
+ {controls}
174
+ </div>
175
+ {activeItem?.panel !== undefined ? (
176
+ <div
177
+ role="tabpanel"
178
+ id={`${baseId}-panel-${activeIndex}`}
179
+ aria-labelledby={`${baseId}-tab-${activeIndex}`}
180
+ className={cn(
181
+ "flex-1 text-sm outline-none",
182
+ activeItem.panelClassName ?? panelClassName ?? "mt-4",
183
+ )}
184
+ >
185
+ {activeItem.panel}
186
+ </div>
187
+ ) : null}
188
+ </div>
189
+ )
190
+ }
@@ -0,0 +1,38 @@
1
+ import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
2
+
3
+ import { cn } from "@hoardodile/ui/lib/utils"
4
+
5
+ export type SeparatorSize = "hairline" | "seam"
6
+
7
+ /**
8
+ * Horizontal/vertical divider: hairlines are 1px and quiet; the 2px
9
+ * `seam` tier marks only structural seams (tab bars, panel sections,
10
+ * pinned action bars).
11
+ */
12
+ function Separator({
13
+ className,
14
+ orientation = "horizontal",
15
+ size = "hairline",
16
+ ...props
17
+ }: SeparatorPrimitive.Props & {
18
+ readonly size?: SeparatorSize
19
+ }) {
20
+ const thickness =
21
+ size === "seam"
22
+ ? "data-horizontal:h-0.5 data-vertical:w-0.5"
23
+ : "data-horizontal:h-px data-vertical:w-px"
24
+ return (
25
+ <SeparatorPrimitive
26
+ data-slot="separator"
27
+ orientation={orientation}
28
+ className={cn(
29
+ "shrink-0 bg-border data-horizontal:w-full data-vertical:self-stretch",
30
+ thickness,
31
+ className
32
+ )}
33
+ {...props}
34
+ />
35
+ )
36
+ }
37
+
38
+ export { Separator }
@@ -0,0 +1,170 @@
1
+ import * as React from "react"
2
+ import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
3
+ import { useTranslation } from "react-i18next"
4
+
5
+ import { cn } from "@hoardodile/ui/lib/utils"
6
+ import { Button } from "@hoardodile/ui/components/button"
7
+ import { useMobileBackToClose } from "@hoardodile/ui/hooks/useMobileBackToClose"
8
+ import { Cross } from "@hoardodile/ui/icons/marks"
9
+
10
+ function Sheet({
11
+ open,
12
+ defaultOpen,
13
+ onOpenChange,
14
+ ...props
15
+ }: Omit<SheetPrimitive.Root.Props, "onOpenChange"> & {
16
+ /**
17
+ * `eventDetails` is absent for closes triggered by the mobile back
18
+ * gesture (a synthetic close with no underlying Base UI event).
19
+ */
20
+ onOpenChange?: (
21
+ open: boolean,
22
+ eventDetails?: SheetPrimitive.Root.ChangeEventDetails,
23
+ ) => void
24
+ }) {
25
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
26
+ defaultOpen ?? false,
27
+ )
28
+ const isControlled = open !== undefined
29
+ const currentOpen = isControlled ? open : uncontrolledOpen
30
+ function handleOpenChange(
31
+ next: boolean,
32
+ eventDetails?: SheetPrimitive.Root.ChangeEventDetails,
33
+ ) {
34
+ if (!isControlled) setUncontrolledOpen(next)
35
+ onOpenChange?.(next, eventDetails)
36
+ }
37
+ useMobileBackToClose(currentOpen, handleOpenChange)
38
+ return (
39
+ <SheetPrimitive.Root
40
+ data-slot="sheet"
41
+ open={currentOpen}
42
+ onOpenChange={handleOpenChange}
43
+ {...props}
44
+ />
45
+ )
46
+ }
47
+
48
+ function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
49
+ return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
50
+ }
51
+
52
+ function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
53
+ return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
54
+ }
55
+
56
+ function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
57
+ return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
58
+ }
59
+
60
+ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
61
+ return (
62
+ <SheetPrimitive.Backdrop
63
+ data-slot="sheet-overlay"
64
+ className={cn(
65
+ "fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
66
+ className
67
+ )}
68
+ {...props}
69
+ />
70
+ )
71
+ }
72
+
73
+ function SheetContent({
74
+ className,
75
+ children,
76
+ side = "right",
77
+ showCloseButton = true,
78
+ ...props
79
+ }: SheetPrimitive.Popup.Props & {
80
+ side?: "top" | "right" | "bottom" | "left"
81
+ showCloseButton?: boolean
82
+ }) {
83
+ const { t } = useTranslation("ui", { useSuspense: false })
84
+ return (
85
+ <SheetPortal>
86
+ <SheetOverlay />
87
+ <SheetPrimitive.Popup
88
+ data-slot="sheet-content"
89
+ data-side={side}
90
+ className={cn(
91
+ "fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
92
+ className
93
+ )}
94
+ {...props}
95
+ >
96
+ {children}
97
+ {showCloseButton && (
98
+ <SheetPrimitive.Close
99
+ data-slot="sheet-close"
100
+ render={
101
+ <Button
102
+ variant="ghost"
103
+ className="absolute top-4 right-4"
104
+ size="icon-sm"
105
+ />
106
+ }
107
+ >
108
+ <Cross
109
+ />
110
+ <span className="sr-only">{t("aria.close")}</span>
111
+ </SheetPrimitive.Close>
112
+ )}
113
+ </SheetPrimitive.Popup>
114
+ </SheetPortal>
115
+ )
116
+ }
117
+
118
+ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
119
+ return (
120
+ <div
121
+ data-slot="sheet-header"
122
+ className={cn("flex flex-col gap-1.5 p-4", className)}
123
+ {...props}
124
+ />
125
+ )
126
+ }
127
+
128
+ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
129
+ return (
130
+ <div
131
+ data-slot="sheet-footer"
132
+ className={cn("mt-auto flex flex-col gap-2 p-4", className)}
133
+ {...props}
134
+ />
135
+ )
136
+ }
137
+
138
+ function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
139
+ return (
140
+ <SheetPrimitive.Title
141
+ data-slot="sheet-title"
142
+ className={cn("font-heading font-medium text-foreground", className)}
143
+ {...props}
144
+ />
145
+ )
146
+ }
147
+
148
+ function SheetDescription({
149
+ className,
150
+ ...props
151
+ }: SheetPrimitive.Description.Props) {
152
+ return (
153
+ <SheetPrimitive.Description
154
+ data-slot="sheet-description"
155
+ className={cn("text-sm text-muted-foreground", className)}
156
+ {...props}
157
+ />
158
+ )
159
+ }
160
+
161
+ export {
162
+ Sheet,
163
+ SheetTrigger,
164
+ SheetClose,
165
+ SheetContent,
166
+ SheetHeader,
167
+ SheetFooter,
168
+ SheetTitle,
169
+ SheetDescription,
170
+ }