@heyramzi/ui 6.1.0 → 8.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 (332) hide show
  1. package/COMPONENTS.md +32 -40
  2. package/README.md +10 -4
  3. package/package.json +31 -29
  4. package/scripts/sync-shadcn.mjs +29 -16
  5. package/scripts/sync-transitions.mjs +11 -11
  6. package/src/ai/ai-button/AiButton.svelte +86 -0
  7. package/src/ai/ai-button/index.ts +4 -0
  8. package/src/ai/confirmation/Confirmation.svelte +8 -8
  9. package/src/ai/confirmation/confirmation-context.svelte.ts +2 -2
  10. package/src/ai/generation-progress/GenerationProgress.svelte +354 -0
  11. package/src/ai/generation-progress/index.ts +7 -0
  12. package/src/ai/index.ts +16 -24
  13. package/src/ai/prompt-input/PromptInput.svelte +19 -1
  14. package/src/ai/prompt-input/PromptInputSubmit.svelte +9 -3
  15. package/src/ai/reasoning/Reasoning.svelte +11 -8
  16. package/src/ambient.d.ts +4 -0
  17. package/src/animations/README.md +17 -17
  18. package/src/animations/index.css +3 -14
  19. package/src/animations/upstream-coverage.test.ts +1 -1
  20. package/src/components/copy/clipboard.test.ts +206 -0
  21. package/src/components/copy/clipboard.ts +49 -0
  22. package/src/components/copy/copied.svelte.ts +39 -0
  23. package/src/components/copy/copy-button.svelte +175 -0
  24. package/src/components/copy/copy-button.test.ts +110 -0
  25. package/src/components/copy/index.ts +3 -0
  26. package/src/components/data-table/data-table.test.ts +134 -0
  27. package/src/components/data-table/data-table.ts +77 -0
  28. package/src/components/data-table/index.ts +20 -0
  29. package/src/components/data-table/infinite-scroll.svelte +69 -0
  30. package/src/components/data-table/pagination.svelte +62 -0
  31. package/src/components/data-table/row-arrow.svelte +28 -0
  32. package/src/components/data-table/row-context-menu.svelte +106 -0
  33. package/src/components/data-table/row-context-menu.test.ts +18 -0
  34. package/src/components/data-table/sortable-head.svelte +56 -0
  35. package/src/components/data-table/table-skeleton.svelte +83 -0
  36. package/src/components/hold-to-confirm/hold-to-confirm.svelte +232 -0
  37. package/src/components/hold-to-confirm/index.ts +1 -0
  38. package/src/components/icon/icon.svelte +23 -0
  39. package/src/components/icon/index.ts +1 -0
  40. package/src/components/language-flag/index.ts +1 -0
  41. package/src/components/language-flag/language-flag.svelte +60 -0
  42. package/src/components/list-page/detail-empty.svelte +50 -0
  43. package/src/components/list-page/detail-header.svelte +107 -0
  44. package/src/components/list-page/detail-panel.svelte +90 -0
  45. package/src/components/list-page/detail-row.svelte +36 -0
  46. package/src/components/list-page/detail-stack.svelte +33 -0
  47. package/src/components/list-page/empty-state.svelte +91 -0
  48. package/src/components/list-page/filter-item.svelte +49 -0
  49. package/src/components/list-page/filter-popover.svelte +88 -0
  50. package/src/components/list-page/filter-section.ts +12 -0
  51. package/src/components/list-page/grouped-list.svelte +116 -0
  52. package/src/components/list-page/index.ts +28 -0
  53. package/src/components/list-page/list-page-shell.svelte +195 -0
  54. package/src/components/list-page/list-table.ts +19 -0
  55. package/src/components/list-page/master-detail.svelte +206 -0
  56. package/src/components/list-page/master-detail.test.ts +37 -0
  57. package/src/components/list-page/notice-bar.svelte +68 -0
  58. package/src/components/list-page/prev-next.svelte +104 -0
  59. package/src/components/list-page/queue-rail.ts +25 -0
  60. package/src/components/list-page/rail-body.svelte +29 -0
  61. package/src/components/list-page/rail-empty.svelte +51 -0
  62. package/src/components/list-page/rail-head.svelte +49 -0
  63. package/src/components/list-page/rail-row.svelte +163 -0
  64. package/src/components/list-page/section-host.ts +24 -0
  65. package/src/components/list-page/section-label.svelte +39 -0
  66. package/src/components/list-page/step.test.ts +21 -0
  67. package/src/components/list-page/step.ts +10 -0
  68. package/src/components/list-page/toolbar-search.svelte +63 -0
  69. package/src/components/list-page/toolbar-trigger.ts +23 -0
  70. package/src/components/list-page/types.ts +1 -0
  71. package/src/components/locale-switch/index.ts +1 -0
  72. package/src/components/locale-switch/locale-switch.svelte +59 -0
  73. package/src/components/mark/index.ts +1 -0
  74. package/src/components/mark/mark.svelte +70 -0
  75. package/src/components/open-in/index.ts +6 -0
  76. package/src/components/open-in/open-in.svelte +106 -0
  77. package/src/components/picker/index.ts +4 -0
  78. package/src/components/picker/mark-picker.svelte +84 -0
  79. package/src/components/picker/picker.svelte +143 -0
  80. package/src/components/picker/trigger.ts +42 -0
  81. package/src/components/picker/types.ts +26 -0
  82. package/src/components/segmented/animated-tabs.svelte +175 -0
  83. package/src/components/segmented/collapse.test.ts +49 -0
  84. package/src/components/segmented/collapse.ts +39 -0
  85. package/src/components/segmented/index.ts +41 -0
  86. package/src/components/segmented/section-runs.svelte +79 -0
  87. package/src/components/segmented/segment.svelte +106 -0
  88. package/src/components/segmented/segmented-filter.svelte +125 -0
  89. package/src/components/segmented/segmented-nav.svelte +82 -0
  90. package/src/components/segmented/segmented-overflow.svelte +85 -0
  91. package/src/components/segmented/segmented-track.svelte +181 -0
  92. package/src/components/segmented/segmented.test.ts +20 -0
  93. package/src/components/segmented/segmented.ts +80 -0
  94. package/src/components/star-rating-input/index.ts +9 -0
  95. package/src/components/star-rating-input/star-rating-input.svelte +256 -0
  96. package/src/components/svg/index.ts +1 -0
  97. package/src/components/svg/svg.svelte +9 -0
  98. package/src/effects/border-beam/BorderBeam.svelte +1 -1
  99. package/src/effects/image-comparison/ImageComparison.css +42 -8
  100. package/src/effects/image-comparison/ImageComparison.svelte +138 -21
  101. package/src/effects/image-comparison/compare.test.ts +17 -0
  102. package/src/effects/image-comparison/compare.ts +5 -0
  103. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +338 -0
  104. package/src/effects/magic-card/MagicCard.css +47 -0
  105. package/src/effects/magic-card/MagicCard.svelte +57 -0
  106. package/src/effects/marquee/Marquee.css +27 -1
  107. package/src/effects/marquee/Marquee.svelte +67 -3
  108. package/src/effects/reveal/index.ts +2 -1
  109. package/src/effects/reveal/observe.test.ts +60 -0
  110. package/src/effects/reveal/observe.ts +33 -1
  111. package/src/effects/reveal/types.ts +7 -0
  112. package/src/effects/theme-toggle/ThemeToggle.css +199 -6
  113. package/src/effects/theme-toggle/ThemeToggle.svelte +51 -26
  114. package/src/gallery.test.ts +52 -0
  115. package/src/index.ts +5 -1
  116. package/src/lib/panel.ts +7 -1
  117. package/src/lib/sliding-pill.ts +68 -0
  118. package/src/lib/theme-transition.ts +41 -13
  119. package/src/lib/tone.ts +48 -0
  120. package/src/shadcn/accordion/accordion-trigger.svelte +15 -6
  121. package/src/shadcn/alert/alert.svelte +3 -1
  122. package/src/shadcn/badge/badge.svelte +8 -7
  123. package/src/shadcn/breadcrumb/breadcrumb-ellipsis.svelte +2 -2
  124. package/src/shadcn/breadcrumb/breadcrumb-separator.svelte +2 -2
  125. package/src/shadcn/calendar/calendar-month-select.svelte +2 -2
  126. package/src/shadcn/calendar/calendar-next-button.svelte +2 -2
  127. package/src/shadcn/calendar/calendar-prev-button.svelte +2 -2
  128. package/src/shadcn/calendar/calendar-year-select.svelte +2 -2
  129. package/src/shadcn/calendar/calendar.svelte +6 -0
  130. package/src/shadcn/code-block/code-block-actions.svelte +23 -0
  131. package/src/shadcn/code-block/code-block-content.svelte +55 -0
  132. package/src/shadcn/code-block/code-block-copy-button.svelte +10 -0
  133. package/src/shadcn/code-block/code-block-footer.svelte +20 -0
  134. package/src/shadcn/code-block/code-block-header.svelte +23 -0
  135. package/src/shadcn/code-block/code-block-line.svelte +71 -0
  136. package/src/shadcn/code-block/code-block-title.svelte +23 -0
  137. package/src/shadcn/code-block/code-block.svelte +47 -0
  138. package/src/shadcn/code-block/index.ts +42 -0
  139. package/src/shadcn/command/index.ts +3 -0
  140. package/src/shadcn/field/index.ts +3 -0
  141. package/src/shadcn/field/simple-field.svelte +71 -0
  142. package/src/shadcn/field/simple-field.test.ts +47 -0
  143. package/src/shadcn/hooks/highlighter.ts +132 -0
  144. package/src/shadcn/hooks/use-highlighted-code.svelte.ts +98 -0
  145. package/src/shadcn/kbd/index.ts +10 -0
  146. package/src/shadcn/kbd/kbd-group.svelte +20 -0
  147. package/src/shadcn/kbd/kbd.svelte +23 -0
  148. package/src/shadcn/message-scroller/index.ts +28 -0
  149. package/src/shadcn/message-scroller/message-scroller-button.svelte +52 -0
  150. package/src/shadcn/message-scroller/message-scroller-content.svelte +17 -0
  151. package/src/shadcn/message-scroller/message-scroller-item.svelte +22 -0
  152. package/src/shadcn/message-scroller/message-scroller-provider.svelte +7 -0
  153. package/src/shadcn/message-scroller/message-scroller-viewport.svelte +20 -0
  154. package/src/shadcn/message-scroller/message-scroller.svelte +20 -0
  155. package/src/shadcn/radio-group/radio-group-item.svelte +3 -2
  156. package/src/shadcn/sheet/sheet-content.svelte +1 -0
  157. package/src/shadcn/sheet/sheet.css +48 -0
  158. package/src/shadcn/sonner/sonner.svelte +7 -10
  159. package/src/shadcn/tabs/tabs-list.svelte +13 -2
  160. package/src/shadcn/tabs/tabs-trigger.svelte +1 -1
  161. package/src/shadcn/toggle-group/toggle-group.svelte +10 -3
  162. package/src/shadcn/trace/index.ts +29 -0
  163. package/src/shadcn/trace/trace-collapsible-content.svelte +29 -0
  164. package/src/shadcn/trace/trace-collapsible-trigger.svelte +29 -0
  165. package/src/shadcn/trace/trace-collapsible.svelte +20 -0
  166. package/src/shadcn/trace/trace-description.svelte +20 -0
  167. package/src/shadcn/trace/trace-icon.svelte +24 -0
  168. package/src/shadcn/trace/trace-line.svelte +31 -0
  169. package/src/shadcn/trace/trace-title.svelte +20 -0
  170. package/src/shadcn/trace/trace.svelte +23 -0
  171. package/src/ai/composer/Composer.css +0 -108
  172. package/src/ai/composer/Composer.svelte +0 -111
  173. package/src/ai/composer/index.ts +0 -10
  174. package/src/ai/conversation/Conversation.svelte +0 -28
  175. package/src/ai/conversation/ConversationContent.svelte +0 -35
  176. package/src/ai/conversation/ConversationEmptyState.svelte +0 -46
  177. package/src/ai/conversation/ConversationScrollButton.svelte +0 -51
  178. package/src/ai/conversation/index.ts +0 -33
  179. package/src/ai/conversation/stick-to-bottom-context.svelte.ts +0 -131
  180. package/src/animations/accordion.css +0 -58
  181. package/src/animations/icon-swap.css +0 -43
  182. package/src/animations/notification-badge.css +0 -81
  183. package/src/animations/panel.css +0 -86
  184. package/src/animations/reasoning-stream.css +0 -49
  185. package/src/animations/skeleton-reveal.css +0 -97
  186. package/src/animations/streaming-text.css +0 -31
  187. package/src/animations/tabs-sliding.css +0 -83
  188. package/src/animations/texts-reveal.css +0 -70
  189. package/src/animations/tooltip.css +0 -72
  190. package/src/animations/view-transition-reveal.ts +0 -74
  191. package/src/charts/animated-value.css +0 -7
  192. package/src/charts/animated-value.svelte +0 -110
  193. package/src/charts/area-chart.css +0 -29
  194. package/src/charts/area-chart.svelte +0 -195
  195. package/src/charts/bar-chart.css +0 -119
  196. package/src/charts/bar-chart.svelte +0 -269
  197. package/src/charts/charts.css +0 -201
  198. package/src/charts/crosshair.svelte +0 -35
  199. package/src/charts/donut-chart.css +0 -56
  200. package/src/charts/donut-chart.svelte +0 -158
  201. package/src/charts/edge-fade.svelte +0 -43
  202. package/src/charts/edge-fade.ts +0 -16
  203. package/src/charts/figure.css +0 -100
  204. package/src/charts/figure.svelte +0 -122
  205. package/src/charts/gauge-chart.css +0 -94
  206. package/src/charts/gauge-chart.svelte +0 -303
  207. package/src/charts/hover-band.test.ts +0 -30
  208. package/src/charts/hover-band.ts +0 -21
  209. package/src/charts/hover-focus.svelte +0 -85
  210. package/src/charts/index.ts +0 -30
  211. package/src/charts/legend.css +0 -131
  212. package/src/charts/legend.svelte +0 -112
  213. package/src/charts/line-chart.css +0 -26
  214. package/src/charts/line-chart.svelte +0 -256
  215. package/src/charts/motion.ts +0 -19
  216. package/src/charts/number-chart.css +0 -168
  217. package/src/charts/number-chart.svelte +0 -177
  218. package/src/charts/primitives/chart-container.svelte +0 -57
  219. package/src/charts/primitives/chart-style.svelte +0 -36
  220. package/src/charts/primitives/chart-tooltip.svelte +0 -161
  221. package/src/charts/primitives/chart-utils.ts +0 -54
  222. package/src/charts/primitives/index.ts +0 -11
  223. package/src/charts/reveal.svelte +0 -61
  224. package/src/charts/ring-chart.css +0 -96
  225. package/src/charts/ring-chart.svelte +0 -158
  226. package/src/charts/sparkline.css +0 -45
  227. package/src/charts/sparkline.svelte +0 -140
  228. package/src/charts/stat-card.css +0 -103
  229. package/src/charts/stat-card.svelte +0 -267
  230. package/src/charts/time-series-chart.css +0 -28
  231. package/src/charts/time-series-chart.svelte +0 -179
  232. package/src/charts/trend-pill.css +0 -38
  233. package/src/charts/trend-pill.svelte +0 -48
  234. package/src/lib/hooks/is-mobile.svelte.ts +0 -11
  235. package/src/lib/utils.ts +0 -45
  236. package/src/showcase/AiSection.svelte +0 -157
  237. package/src/showcase/AnimationsSection.svelte +0 -1658
  238. package/src/showcase/ChartsSection.svelte +0 -458
  239. package/src/showcase/EffectsSection.svelte +0 -152
  240. package/src/showcase/ShadcnSection.svelte +0 -27
  241. package/src/showcase/ShowcaseImport.svelte +0 -126
  242. package/src/showcase/ShowcaseList.svelte +0 -132
  243. package/src/showcase/ShowcaseNav.svelte +0 -247
  244. package/src/showcase/ShowcaseSection.svelte +0 -112
  245. package/src/showcase/ShowcaseShell.svelte +0 -55
  246. package/src/showcase/ShowcaseStage.svelte +0 -342
  247. package/src/showcase/ShowcaseVariant.svelte +0 -109
  248. package/src/showcase/assets/image-reveal/art-1.jpg +0 -0
  249. package/src/showcase/assets/image-reveal/art-2.jpg +0 -0
  250. package/src/showcase/assets/image-reveal/art-3.jpg +0 -0
  251. package/src/showcase/check.svg +0 -1
  252. package/src/showcase/chevron-arms.svg +0 -2
  253. package/src/showcase/chevron-down.svg +0 -1
  254. package/src/showcase/copy.svg +0 -2
  255. package/src/showcase/demos/ai/confirmation.svelte +0 -59
  256. package/src/showcase/demos/ai/conversation.svelte +0 -59
  257. package/src/showcase/demos/ai/loading-state.svelte +0 -19
  258. package/src/showcase/demos/ai/prompt-input.svelte +0 -57
  259. package/src/showcase/demos/ai/reasoning.svelte +0 -19
  260. package/src/showcase/demos/ai/thinking-dots.svelte +0 -15
  261. package/src/showcase/demos/ai/voice-waveform.svelte +0 -24
  262. package/src/showcase/demos/effects/animated-beam.svelte +0 -50
  263. package/src/showcase/demos/effects/animated-group.svelte +0 -47
  264. package/src/showcase/demos/effects/border-beam-default.svelte +0 -20
  265. package/src/showcase/demos/effects/border-beam-line.svelte +0 -20
  266. package/src/showcase/demos/effects/border-beam-mono.svelte +0 -20
  267. package/src/showcase/demos/effects/border-beam-sunset.svelte +0 -20
  268. package/src/showcase/demos/effects/image-comparison.svelte +0 -25
  269. package/src/showcase/demos/effects/image-reveal.svelte +0 -49
  270. package/src/showcase/demos/effects/in-view.svelte +0 -41
  271. package/src/showcase/demos/effects/integrations-hub.svelte +0 -82
  272. package/src/showcase/demos/effects/magnetic.svelte +0 -31
  273. package/src/showcase/demos/effects/marquee.svelte +0 -33
  274. package/src/showcase/demos/effects/progressive-blur.svelte +0 -47
  275. package/src/showcase/demos/effects/spotlight.svelte +0 -40
  276. package/src/showcase/demos/effects/text-effect.svelte +0 -55
  277. package/src/showcase/demos/shadcn/accordion.svelte +0 -45
  278. package/src/showcase/demos/shadcn/alert-dialog.svelte +0 -23
  279. package/src/showcase/demos/shadcn/alert.svelte +0 -30
  280. package/src/showcase/demos/shadcn/attachment.svelte +0 -71
  281. package/src/showcase/demos/shadcn/avatar.svelte +0 -30
  282. package/src/showcase/demos/shadcn/badge.svelte +0 -26
  283. package/src/showcase/demos/shadcn/breadcrumb.svelte +0 -34
  284. package/src/showcase/demos/shadcn/bubble.svelte +0 -36
  285. package/src/showcase/demos/shadcn/button.svelte +0 -11
  286. package/src/showcase/demos/shadcn/calendar.svelte +0 -8
  287. package/src/showcase/demos/shadcn/card.svelte +0 -39
  288. package/src/showcase/demos/shadcn/checkbox.svelte +0 -39
  289. package/src/showcase/demos/shadcn/collapsible.svelte +0 -20
  290. package/src/showcase/demos/shadcn/command.svelte +0 -48
  291. package/src/showcase/demos/shadcn/context-menu.svelte +0 -54
  292. package/src/showcase/demos/shadcn/demo-harness.svelte +0 -12
  293. package/src/showcase/demos/shadcn/demos.test.ts +0 -20
  294. package/src/showcase/demos/shadcn/dialog.svelte +0 -38
  295. package/src/showcase/demos/shadcn/dropdown-menu.svelte +0 -59
  296. package/src/showcase/demos/shadcn/empty.svelte +0 -29
  297. package/src/showcase/demos/shadcn/field.svelte +0 -115
  298. package/src/showcase/demos/shadcn/hover-card.svelte +0 -32
  299. package/src/showcase/demos/shadcn/index.ts +0 -358
  300. package/src/showcase/demos/shadcn/input-otp.svelte +0 -19
  301. package/src/showcase/demos/shadcn/input.svelte +0 -5
  302. package/src/showcase/demos/shadcn/label.svelte +0 -11
  303. package/src/showcase/demos/shadcn/marker.svelte +0 -30
  304. package/src/showcase/demos/shadcn/message.svelte +0 -75
  305. package/src/showcase/demos/shadcn/popover.svelte +0 -36
  306. package/src/showcase/demos/shadcn/progress.svelte +0 -13
  307. package/src/showcase/demos/shadcn/radio-group.svelte +0 -19
  308. package/src/showcase/demos/shadcn/scroll-area.svelte +0 -18
  309. package/src/showcase/demos/shadcn/select.svelte +0 -29
  310. package/src/showcase/demos/shadcn/separator.svelte +0 -12
  311. package/src/showcase/demos/shadcn/sheet.svelte +0 -48
  312. package/src/showcase/demos/shadcn/skeleton.svelte +0 -11
  313. package/src/showcase/demos/shadcn/slider.svelte +0 -7
  314. package/src/showcase/demos/shadcn/sonner.svelte +0 -18
  315. package/src/showcase/demos/shadcn/spinner.svelte +0 -11
  316. package/src/showcase/demos/shadcn/switch.svelte +0 -9
  317. package/src/showcase/demos/shadcn/table.svelte +0 -76
  318. package/src/showcase/demos/shadcn/tabs.svelte +0 -62
  319. package/src/showcase/demos/shadcn/textarea.svelte +0 -5
  320. package/src/showcase/demos/shadcn/toggle-group.svelte +0 -18
  321. package/src/showcase/demos/shadcn/toggle.svelte +0 -14
  322. package/src/showcase/demos/shadcn/tooltip.svelte +0 -13
  323. package/src/showcase/heart.svg +0 -1
  324. package/src/showcase/index.ts +0 -38
  325. package/src/showcase/kit.test.ts +0 -72
  326. package/src/showcase/kit.ts +0 -17
  327. package/src/showcase/plus.svg +0 -1
  328. package/src/showcase/showcase-shell.css +0 -91
  329. package/src/showcase/stacked-section.css +0 -15
  330. package/src/showcase/success-check.svg +0 -1
  331. package/src/showcase/types.ts +0 -29
  332. package/src/svg-raw.d.ts +0 -7
@@ -0,0 +1,134 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import {
3
+ toggleSort,
4
+ sortRows,
5
+ paginateRows,
6
+ totalPages,
7
+ normalizeQuery,
8
+ containsQuery,
9
+ anyContainsQuery,
10
+ formatCount,
11
+ } from "./data-table.js";
12
+
13
+ type Item = { name: string; value: number; date: string | null };
14
+
15
+ const items: Item[] = [
16
+ { name: "Charlie", value: 3, date: "2025-03-01" },
17
+ { name: "Alice", value: 1, date: "2025-01-01" },
18
+ { name: "Bob", value: 2, date: null },
19
+ ];
20
+
21
+ describe("toggleSort", () => {
22
+ it("returns asc when no current sort", () => {
23
+ expect(toggleSort<Item>(null, "name")).toEqual({ key: "name", direction: "asc" });
24
+ });
25
+
26
+ it("returns desc when current is asc on same key", () => {
27
+ expect(toggleSort<Item>({ key: "name", direction: "asc" }, "name")).toEqual({
28
+ key: "name",
29
+ direction: "desc",
30
+ });
31
+ });
32
+
33
+ it("returns null when current is desc on same key", () => {
34
+ expect(toggleSort<Item>({ key: "name", direction: "desc" }, "name")).toBeNull();
35
+ });
36
+
37
+ it("returns asc on new key", () => {
38
+ expect(toggleSort<Item>({ key: "name", direction: "desc" }, "value")).toEqual({
39
+ key: "value",
40
+ direction: "asc",
41
+ });
42
+ });
43
+ });
44
+
45
+ describe("sortRows", () => {
46
+ it("returns same order when sort is null", () => {
47
+ expect(sortRows(items, null)).toEqual(items);
48
+ });
49
+
50
+ it("sorts strings ascending", () => {
51
+ const sorted = sortRows(items, { key: "name", direction: "asc" });
52
+ expect(sorted.map((i) => i.name)).toEqual(["Alice", "Bob", "Charlie"]);
53
+ });
54
+
55
+ it("sorts strings descending", () => {
56
+ const sorted = sortRows(items, { key: "name", direction: "desc" });
57
+ expect(sorted.map((i) => i.name)).toEqual(["Charlie", "Bob", "Alice"]);
58
+ });
59
+
60
+ it("sorts numbers ascending", () => {
61
+ const sorted = sortRows(items, { key: "value", direction: "asc" });
62
+ expect(sorted.map((i) => i.value)).toEqual([1, 2, 3]);
63
+ });
64
+
65
+ it("pushes nulls to end", () => {
66
+ const sorted = sortRows(items, { key: "date", direction: "asc" });
67
+ expect(sorted[2].date).toBeNull();
68
+ });
69
+
70
+ it("does not mutate original array", () => {
71
+ const original = [...items];
72
+ sortRows(items, { key: "name", direction: "asc" });
73
+ expect(items).toEqual(original);
74
+ });
75
+ });
76
+
77
+ describe("paginateRows", () => {
78
+ const rows = Array.from({ length: 25 }, (_, i) => ({ id: i }));
79
+
80
+ it("returns first page", () => {
81
+ const result = paginateRows(rows, 1, 10);
82
+ expect(result).toHaveLength(10);
83
+ expect(result[0].id).toBe(0);
84
+ });
85
+
86
+ it("returns last partial page", () => {
87
+ const result = paginateRows(rows, 3, 10);
88
+ expect(result).toHaveLength(5);
89
+ expect(result[0].id).toBe(20);
90
+ });
91
+
92
+ it("returns empty for out-of-range page", () => {
93
+ expect(paginateRows(rows, 5, 10)).toHaveLength(0);
94
+ });
95
+ });
96
+
97
+ describe("totalPages", () => {
98
+ it("calculates correct total", () => {
99
+ expect(totalPages(25, 10)).toBe(3);
100
+ expect(totalPages(20, 10)).toBe(2);
101
+ expect(totalPages(0, 10)).toBe(1);
102
+ });
103
+ });
104
+
105
+ describe("formatCount", () => {
106
+ it("shows total when no filter active", () => {
107
+ expect(formatCount(10, 10, "items")).toBe("10 items");
108
+ });
109
+ it("shows filtered of total when filtered", () => {
110
+ expect(formatCount(3, 10, "items")).toBe("3 of 10 items");
111
+ });
112
+ it("singularizes when count is 1", () => {
113
+ expect(formatCount(1, 1, "items")).toBe("1 item");
114
+ });
115
+ it("singularizes filtered count", () => {
116
+ expect(formatCount(1, 10, "items")).toBe("1 of 10 items");
117
+ });
118
+ });
119
+
120
+ describe("existing utilities", () => {
121
+ it("normalizeQuery trims and lowercases", () => {
122
+ expect(normalizeQuery(" Hello ")).toBe("hello");
123
+ });
124
+
125
+ it("containsQuery handles null", () => {
126
+ expect(containsQuery(null, "test")).toBe(false);
127
+ expect(containsQuery("testing", "test")).toBe(true);
128
+ });
129
+
130
+ it("anyContainsQuery matches any value", () => {
131
+ expect(anyContainsQuery(["foo", null, "bar"], "bar")).toBe(true);
132
+ expect(anyContainsQuery(["foo", null], "bar")).toBe(false);
133
+ });
134
+ });
@@ -0,0 +1,77 @@
1
+ import type { Snippet } from "svelte";
2
+
3
+ export type DataTableFilter<T> = (row: T, query: string) => boolean;
4
+
5
+ export type DataTableEmptyState = {
6
+ title: string;
7
+ description: string;
8
+ searchDescription?: string;
9
+ action?: Snippet;
10
+ };
11
+
12
+ export function normalizeQuery(query: string): string {
13
+ return query.trim().toLowerCase();
14
+ }
15
+
16
+ export function containsQuery(value: string | null | undefined, query: string): boolean {
17
+ if (!query) return true;
18
+
19
+ return value?.toLowerCase().includes(query) ?? false;
20
+ }
21
+
22
+ export function anyContainsQuery(values: Array<string | null | undefined>, query: string): boolean {
23
+ return values.some((value) => containsQuery(value, query));
24
+ }
25
+
26
+ // ========================= TYPES
27
+ export type SortDirection = "asc" | "desc";
28
+
29
+ export type SortState<T> = {
30
+ key: keyof T & string;
31
+ direction: SortDirection;
32
+ } | null;
33
+
34
+ // ========================= FUNCTIONS
35
+ export function toggleSort<T>(current: SortState<T>, key: keyof T & string): SortState<T> {
36
+ if (current?.key !== key) return { key, direction: "asc" };
37
+ if (current.direction === "asc") return { key, direction: "desc" };
38
+ return null;
39
+ }
40
+
41
+ export function sortRows<T>(rows: T[], sort: SortState<T>): T[] {
42
+ if (!sort) return rows;
43
+ const { key, direction } = sort;
44
+ const multiplier = direction === "asc" ? 1 : -1;
45
+ return [...rows].sort((a, b) => {
46
+ const aVal = a[key];
47
+ const bVal = b[key];
48
+ if (aVal == null && bVal == null) return 0;
49
+ if (aVal == null) return 1;
50
+ if (bVal == null) return -1;
51
+ if (typeof aVal === "string" && typeof bVal === "string") {
52
+ return multiplier * aVal.localeCompare(bVal);
53
+ }
54
+ if (typeof aVal === "number" && typeof bVal === "number") {
55
+ return multiplier * (aVal - bVal);
56
+ }
57
+ return multiplier * String(aVal).localeCompare(String(bVal));
58
+ });
59
+ }
60
+
61
+ export function paginateRows<T>(rows: T[], page: number, pageSize: number): T[] {
62
+ const start = (page - 1) * pageSize;
63
+ return rows.slice(start, start + pageSize);
64
+ }
65
+
66
+ export function totalPages(total: number, pageSize: number): number {
67
+ return Math.max(1, Math.ceil(total / pageSize));
68
+ }
69
+
70
+ export function formatCount(visible: number, total: number, label: string): string {
71
+ const singular = label.endsWith("s") ? label.slice(0, -1) : label;
72
+ if (visible === total) {
73
+ const displayLabel = visible === 1 ? singular : label;
74
+ return `${total} ${displayLabel}`;
75
+ }
76
+ return `${visible} of ${total} ${label}`;
77
+ }
@@ -0,0 +1,20 @@
1
+ export { default as InfiniteScroll } from "./infinite-scroll.svelte";
2
+ export { default as Pagination } from "./pagination.svelte";
3
+ export { default as RowArrow } from "./row-arrow.svelte";
4
+ export { default as RowContextMenu } from "./row-context-menu.svelte";
5
+ export { default as SortableHead } from "./sortable-head.svelte";
6
+ export { default as TableSkeleton } from "./table-skeleton.svelte";
7
+ export {
8
+ anyContainsQuery,
9
+ containsQuery,
10
+ formatCount,
11
+ normalizeQuery,
12
+ paginateRows,
13
+ sortRows,
14
+ toggleSort,
15
+ totalPages,
16
+ type DataTableEmptyState,
17
+ type DataTableFilter,
18
+ type SortDirection,
19
+ type SortState,
20
+ } from "./data-table.js";
@@ -0,0 +1,69 @@
1
+ <script lang="ts" generics="T">
2
+ // ========================= IMPORTS
3
+ import type { Snippet } from "svelte";
4
+ import { Spinner } from "../../shadcn/spinner/index.js";
5
+
6
+ // ========================= TYPES
7
+ type Props = {
8
+ items: T[];
9
+ isDone: boolean;
10
+ isLoading: boolean;
11
+ children: Snippet<[{ items: T[] }]>;
12
+ /** Skeleton snippet rendered while loading more */
13
+ skeleton?: Snippet;
14
+ onLoadMore: () => void;
15
+ threshold?: number;
16
+ loadingClass?: string;
17
+ };
18
+
19
+ // ========================= PROPS
20
+ let {
21
+ items,
22
+ isDone,
23
+ isLoading,
24
+ children,
25
+ skeleton,
26
+ onLoadMore,
27
+ threshold = 200,
28
+ loadingClass,
29
+ }: Props = $props();
30
+
31
+ // ========================= STATE
32
+ let sentinelEl: HTMLDivElement | undefined = $state();
33
+
34
+ // ========================= EFFECTS
35
+ $effect(() => {
36
+ if (!sentinelEl || isDone) return;
37
+
38
+ const observer = new IntersectionObserver(
39
+ (entries) => {
40
+ if (entries[0]?.isIntersecting && !isLoading && !isDone) {
41
+ onLoadMore();
42
+ }
43
+ },
44
+ { rootMargin: `${threshold}px` },
45
+ );
46
+
47
+ observer.observe(sentinelEl);
48
+ return () => observer.disconnect();
49
+ });
50
+ </script>
51
+
52
+ <!-- =========================== -->
53
+ <!-- MARKUP -->
54
+ <!-- =========================== -->
55
+ {@render children({ items })}
56
+
57
+ {#if !isDone}
58
+ <div bind:this={sentinelEl} class={loadingClass ?? ""}>
59
+ {#if isLoading}
60
+ {#if skeleton}
61
+ {@render skeleton()}
62
+ {:else}
63
+ <div class="flex items-center justify-center py-4">
64
+ <Spinner class="size-5 text-muted-foreground" />
65
+ </div>
66
+ {/if}
67
+ {/if}
68
+ </div>
69
+ {/if}
@@ -0,0 +1,62 @@
1
+ <script lang="ts">
2
+ import { CaretLeft, CaretRight } from "phosphor-svelte";
3
+ import { Button } from "../../shadcn/button/index.js";
4
+ import { totalPages as calcTotalPages } from "./data-table.js";
5
+
6
+ // ========================= TYPES
7
+ type Props = {
8
+ page: number;
9
+ pageSize: number;
10
+ total: number;
11
+ onPageChange?: (page: number) => void;
12
+ /** If provided, renders <a> tags instead of buttons (for server-side pagination) */
13
+ buildHref?: (page: number) => string;
14
+ };
15
+
16
+ // ========================= PROPS
17
+ let { page, pageSize, total, onPageChange, buildHref }: Props = $props();
18
+
19
+ // ========================= DERIVED
20
+ let pages = $derived(calcTotalPages(total, pageSize));
21
+ let hasPrev = $derived(page > 1);
22
+ let hasNext = $derived(page < pages);
23
+ let start = $derived((page - 1) * pageSize + 1);
24
+ let end = $derived(Math.min(page * pageSize, total));
25
+ </script>
26
+
27
+ {#if pages > 1}
28
+ <div class="flex items-center justify-between border-t px-4 py-3 text-sm text-muted-foreground">
29
+ <span>{start}-{end} of {total}</span>
30
+ <div class="flex items-center gap-1">
31
+ {#if buildHref}
32
+ {#if hasPrev}
33
+ <Button variant="outline" size="sm" icon href={buildHref(page - 1)}>
34
+ <CaretLeft class="size-4" />
35
+ </Button>
36
+ {:else}
37
+ <Button variant="outline" size="sm" icon disabled>
38
+ <CaretLeft class="size-4" />
39
+ </Button>
40
+ {/if}
41
+ <span class="mx-2 tabular-nums">{page} / {pages}</span>
42
+ {#if hasNext}
43
+ <Button variant="outline" size="sm" icon href={buildHref(page + 1)}>
44
+ <CaretRight class="size-4" />
45
+ </Button>
46
+ {:else}
47
+ <Button variant="outline" size="sm" icon disabled>
48
+ <CaretRight class="size-4" />
49
+ </Button>
50
+ {/if}
51
+ {:else}
52
+ <Button variant="outline" size="sm" icon disabled={!hasPrev} onclick={() => onPageChange?.(page - 1)}>
53
+ <CaretLeft class="size-4" />
54
+ </Button>
55
+ <span class="mx-2 tabular-nums">{page} / {pages}</span>
56
+ <Button variant="outline" size="sm" icon disabled={!hasNext} onclick={() => onPageChange?.(page + 1)}>
57
+ <CaretRight class="size-4" />
58
+ </Button>
59
+ {/if}
60
+ </div>
61
+ </div>
62
+ {/if}
@@ -0,0 +1,28 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { CaretRight } from "phosphor-svelte";
6
+ import { cn } from "@heyramzi/utils";
7
+
8
+ // =========================
9
+ // TYPES
10
+ // =========================
11
+ type Props = {
12
+ class?: string;
13
+ };
14
+
15
+ // =========================
16
+ // PROPS
17
+ // =========================
18
+ let { class: className }: Props = $props();
19
+ </script>
20
+
21
+ <!-- =========================== -->
22
+ <!-- MARKUP -->
23
+ <!-- =========================== -->
24
+ <CaretRight
25
+ class={cn("size-3.5 text-muted-foreground opacity-0 transition-opacity group-hover/row:opacity-100", className)}
26
+ weight="bold"
27
+ aria-hidden="true"
28
+ />
@@ -0,0 +1,106 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import * as ContextMenu from "../../shadcn/context-menu/index.js";
6
+ import { HoldToConfirm } from "../hold-to-confirm/index.js";
7
+ import { ArrowUpRight, Copy, LinkSimple, Trash } from "phosphor-svelte";
8
+ import type { Snippet } from "svelte";
9
+
10
+ // =========================
11
+ // TYPES
12
+ // =========================
13
+ type Props = {
14
+ onDelete: () => void;
15
+ href?: string;
16
+ id?: string;
17
+ deleteLabel?: string;
18
+ copyLinkLabel?: string;
19
+ copyIdLabel?: string;
20
+ /** Called after a successful clipboard copy so consumers can show their own toast */
21
+ onCopied?: () => void;
22
+ children: Snippet<[Record<string, unknown>]>;
23
+ };
24
+
25
+ // =========================
26
+ // PROPS
27
+ // =========================
28
+ let {
29
+ onDelete,
30
+ href,
31
+ id,
32
+ deleteLabel = "Delete",
33
+ copyLinkLabel = "Copy link",
34
+ copyIdLabel = "Copy ID",
35
+ onCopied,
36
+ children,
37
+ }: Props = $props();
38
+
39
+ // =========================
40
+ // FUNCTIONS
41
+ // =========================
42
+ function handleOpen() {
43
+ if (href) window.location.href = href;
44
+ }
45
+
46
+ // WHY the whole URL and not the path: what leaves this menu is pasted into a
47
+ // message, a doc or an agent prompt, none of which can resolve "/clients/42".
48
+ async function handleCopyLink() {
49
+ if (!href) return;
50
+ await navigator.clipboard.writeText(new URL(href, window.location.origin).href);
51
+ onCopied?.();
52
+ }
53
+
54
+ async function handleCopyId() {
55
+ if (!id) return;
56
+ await navigator.clipboard.writeText(id);
57
+ onCopied?.();
58
+ }
59
+ </script>
60
+
61
+ <!-- =========================== -->
62
+ <!-- MARKUP -->
63
+ <!-- =========================== -->
64
+ <ContextMenu.Root>
65
+ <ContextMenu.Trigger>
66
+ {#snippet child({ props })}
67
+ {@render children(props)}
68
+ {/snippet}
69
+ </ContextMenu.Trigger>
70
+ <ContextMenu.Content class="w-48">
71
+ {#if href}
72
+ <ContextMenu.Item onclick={handleOpen}>
73
+ <ArrowUpRight size={14} />
74
+ Open
75
+ </ContextMenu.Item>
76
+ {/if}
77
+ {#if href}
78
+ <ContextMenu.Item onclick={handleCopyLink}>
79
+ <LinkSimple size={14} />
80
+ {copyLinkLabel}
81
+ </ContextMenu.Item>
82
+ {/if}
83
+ {#if id}
84
+ <ContextMenu.Item onclick={handleCopyId}>
85
+ <Copy size={14} />
86
+ {copyIdLabel}
87
+ </ContextMenu.Item>
88
+ {/if}
89
+ {#if href || id}
90
+ <ContextMenu.Separator />
91
+ {/if}
92
+ <HoldToConfirm
93
+ duration={800}
94
+ onConfirm={onDelete}
95
+ class="relative flex w-full cursor-default items-center justify-start gap-2 rounded-md px-1.5 py-1 text-sm text-destructive outline-hidden"
96
+ >
97
+ {#snippet idle()}
98
+ <Trash size={14} />
99
+ {deleteLabel}
100
+ {/snippet}
101
+ {#snippet holding()}
102
+ Hold to delete...
103
+ {/snippet}
104
+ </HoldToConfirm>
105
+ </ContextMenu.Content>
106
+ </ContextMenu.Root>
@@ -0,0 +1,18 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vite-plus/test";
5
+
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+ const sourcePath = resolve(here, "row-context-menu.svelte");
8
+ if (!existsSync(sourcePath)) {
9
+ throw new Error(`Unable to find source file: ${sourcePath}`);
10
+ }
11
+ const source = readFileSync(sourcePath, "utf8");
12
+
13
+ describe("row context menu delete affordance", () => {
14
+ it("renders the hold-to-delete action at full menu-item width", () => {
15
+ expect(source).toContain("w-full");
16
+ expect(source).toContain("justify-start");
17
+ });
18
+ });
@@ -0,0 +1,56 @@
1
+ <script lang="ts" generics="T">
2
+ import type { Snippet } from "svelte";
3
+ import { CaretDown, CaretUp, CaretUpDown } from "phosphor-svelte";
4
+ import { cn } from "@heyramzi/utils";
5
+ import type { SortDirection, SortState } from "./data-table.js";
6
+
7
+ // ========================= TYPES
8
+ type Props = {
9
+ key: keyof T & string;
10
+ sort: SortState<T>;
11
+ onSort: (key: keyof T & string) => void;
12
+ class?: string;
13
+ children: Snippet;
14
+ };
15
+
16
+ // ========================= PROPS
17
+ let { key, sort, onSort, class: className, children }: Props = $props();
18
+
19
+ // ========================= DERIVED
20
+ let active = $derived(sort?.key === key);
21
+ let direction = $derived<SortDirection | null>(sort && active ? sort.direction : null);
22
+ let ariaSort = $derived<"ascending" | "descending" | "none">(
23
+ direction === "asc" ? "ascending" : direction === "desc" ? "descending" : "none",
24
+ );
25
+
26
+ function handleKeydown(e: KeyboardEvent) {
27
+ if (e.key === "Enter" || e.key === " ") {
28
+ e.preventDefault();
29
+ onSort(key);
30
+ }
31
+ }
32
+ </script>
33
+
34
+ <th
35
+ data-slot="table-head"
36
+ class={cn(
37
+ "text-muted-foreground h-8 px-2 text-left align-middle text-xs font-medium whitespace-nowrap select-none",
38
+ "cursor-pointer hover:bg-muted/30 transition-colors",
39
+ className,
40
+ )}
41
+ tabindex="0"
42
+ aria-sort={ariaSort}
43
+ onclick={() => onSort(key)}
44
+ onkeydown={handleKeydown}
45
+ >
46
+ <span class="inline-flex items-center gap-1">
47
+ {@render children()}
48
+ {#if direction === "asc"}
49
+ <CaretUp class="size-3.5 text-foreground" weight="bold" />
50
+ {:else if direction === "desc"}
51
+ <CaretDown class="size-3.5 text-foreground" weight="bold" />
52
+ {:else}
53
+ <CaretUpDown class="size-3.5 text-muted-foreground/50" />
54
+ {/if}
55
+ </span>
56
+ </th>
@@ -0,0 +1,83 @@
1
+ <script lang="ts">
2
+ // ========================= IMPORTS
3
+ import { Skeleton } from "../../shadcn/skeleton/index.js";
4
+
5
+ // ========================= TYPES
6
+ type ColumnDef = {
7
+ width?: string;
8
+ align?: "left" | "center" | "right";
9
+ /** Show a small circle before the skeleton bar (e.g. logo/avatar column) */
10
+ circle?: boolean;
11
+ };
12
+
13
+ type Props = {
14
+ /** Number of skeleton rows to render */
15
+ rows?: number;
16
+ /** Column definitions control skeleton widths and alignment */
17
+ columns?: ColumnDef[];
18
+ /** Simple mode: just specify column count */
19
+ columnCount?: number;
20
+ class?: string;
21
+ };
22
+
23
+ // ========================= PROPS
24
+ let {
25
+ rows = 5,
26
+ columns,
27
+ columnCount = 6,
28
+ class: className,
29
+ }: Props = $props();
30
+
31
+ // ========================= DERIVED
32
+ let cols = $derived(
33
+ columns ??
34
+ Array.from<unknown, ColumnDef>({ length: columnCount }, () => ({
35
+ width: undefined,
36
+ align: "left" as const,
37
+ })),
38
+ );
39
+
40
+ const WIDTHS = ["w-24", "w-20", "w-28", "w-16", "w-32", "w-20"];
41
+ </script>
42
+
43
+ <!-- =========================== -->
44
+ <!-- MARKUP -->
45
+ <!-- =========================== -->
46
+ <tbody class={className}>
47
+ {#each { length: rows } as _, rowIdx (rowIdx)}
48
+ <tr
49
+ data-slot="table-row"
50
+ class="border-b border-border/50 transition-opacity"
51
+ style="animation: skeleton-fade-in var(--duration-slow) var(--ease-out) calc(var(--duration-stagger) * {rowIdx}) both"
52
+ >
53
+ {#each cols as col, colIdx (colIdx)}
54
+ <td
55
+ data-slot="table-cell"
56
+ class="h-11 px-2 {col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : ''}"
57
+ >
58
+ <div class="flex items-center gap-2 {col.align === 'center' ? 'justify-center' : col.align === 'right' ? 'justify-end' : ''}">
59
+ {#if col.circle}
60
+ <Skeleton class="size-6 shrink-0 rounded-md" />
61
+ {/if}
62
+ <Skeleton
63
+ class="h-3.5 rounded {col.width ?? WIDTHS[(rowIdx + colIdx) % WIDTHS.length]}"
64
+ />
65
+ </div>
66
+ </td>
67
+ {/each}
68
+ </tr>
69
+ {/each}
70
+ </tbody>
71
+
72
+ <style>
73
+ @keyframes skeleton-fade-in {
74
+ from {
75
+ opacity: 0;
76
+ transform: translateY(4px);
77
+ }
78
+ to {
79
+ opacity: 1;
80
+ transform: translateY(0);
81
+ }
82
+ }
83
+ </style>