@heyramzi/ui 7.0.0 → 8.1.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 (259) hide show
  1. package/COMPONENTS.md +28 -17
  2. package/README.md +6 -2
  3. package/package.json +25 -15
  4. package/scripts/sync-shadcn.mjs +17 -13
  5. package/scripts/sync-transitions.mjs +11 -11
  6. package/src/agents/agent-avatar.svelte +67 -114
  7. package/src/agents/agent-cast.svelte +31 -7
  8. package/src/agents/demo.svelte +55 -8
  9. package/src/agents/helpers.ts +58 -0
  10. package/src/agents/page-state.ts +70 -0
  11. package/src/agents/types.ts +3 -1
  12. package/src/ai/ai-button/AiButton.svelte +86 -0
  13. package/src/ai/ai-button/index.ts +4 -0
  14. package/src/ai/confirmation/Confirmation.svelte +8 -8
  15. package/src/ai/confirmation/confirmation-context.svelte.ts +2 -2
  16. package/src/ai/generation-progress/GenerationProgress.svelte +354 -0
  17. package/src/ai/generation-progress/index.ts +7 -0
  18. package/src/ai/index.ts +16 -6
  19. package/src/ai/prompt-input/PromptInput.svelte +19 -1
  20. package/src/ai/prompt-input/PromptInputSubmit.svelte +9 -3
  21. package/src/ai/reasoning/Reasoning.svelte +11 -8
  22. package/src/ambient.d.ts +4 -0
  23. package/src/animations/README.md +14 -14
  24. package/src/animations/index.css +3 -14
  25. package/src/animations/upstream-coverage.test.ts +1 -1
  26. package/src/components/copy/clipboard.test.ts +206 -0
  27. package/src/components/copy/clipboard.ts +49 -0
  28. package/src/components/copy/copied.svelte.ts +39 -0
  29. package/src/components/copy/copy-button.svelte +175 -0
  30. package/src/components/copy/copy-button.test.ts +110 -0
  31. package/src/components/copy/index.ts +3 -0
  32. package/src/components/data-table/data-table.test.ts +134 -0
  33. package/src/components/data-table/data-table.ts +77 -0
  34. package/src/components/data-table/index.ts +20 -0
  35. package/src/components/data-table/infinite-scroll.svelte +69 -0
  36. package/src/components/data-table/pagination.svelte +62 -0
  37. package/src/components/data-table/row-arrow.svelte +28 -0
  38. package/src/components/data-table/row-context-menu.svelte +106 -0
  39. package/src/components/data-table/row-context-menu.test.ts +18 -0
  40. package/src/components/data-table/sortable-head.svelte +56 -0
  41. package/src/components/data-table/table-skeleton.svelte +83 -0
  42. package/src/components/hold-to-confirm/hold-to-confirm.svelte +232 -0
  43. package/src/components/hold-to-confirm/index.ts +1 -0
  44. package/src/components/icon/icon.svelte +23 -0
  45. package/src/components/icon/index.ts +1 -0
  46. package/src/components/language-flag/index.ts +1 -0
  47. package/src/components/language-flag/language-flag.svelte +60 -0
  48. package/src/components/list-page/detail-empty.svelte +50 -0
  49. package/src/components/list-page/detail-header.svelte +107 -0
  50. package/src/components/list-page/detail-panel.svelte +90 -0
  51. package/src/components/list-page/detail-row.svelte +36 -0
  52. package/src/components/list-page/detail-stack.svelte +33 -0
  53. package/src/components/list-page/empty-state.svelte +91 -0
  54. package/src/components/list-page/filter-item.svelte +49 -0
  55. package/src/components/list-page/filter-popover.svelte +88 -0
  56. package/src/components/list-page/filter-section.ts +12 -0
  57. package/src/components/list-page/grouped-list.svelte +116 -0
  58. package/src/components/list-page/index.ts +28 -0
  59. package/src/components/list-page/list-page-shell.svelte +195 -0
  60. package/src/components/list-page/list-table.ts +19 -0
  61. package/src/components/list-page/master-detail.svelte +206 -0
  62. package/src/components/list-page/master-detail.test.ts +37 -0
  63. package/src/components/list-page/notice-bar.svelte +68 -0
  64. package/src/components/list-page/prev-next.svelte +104 -0
  65. package/src/components/list-page/queue-rail.ts +25 -0
  66. package/src/components/list-page/rail-body.svelte +29 -0
  67. package/src/components/list-page/rail-empty.svelte +51 -0
  68. package/src/components/list-page/rail-head.svelte +49 -0
  69. package/src/components/list-page/rail-row.svelte +163 -0
  70. package/src/components/list-page/section-host.ts +24 -0
  71. package/src/components/list-page/section-label.svelte +39 -0
  72. package/src/components/list-page/step.test.ts +21 -0
  73. package/src/components/list-page/step.ts +10 -0
  74. package/src/components/list-page/toolbar-search.svelte +63 -0
  75. package/src/components/list-page/toolbar-trigger.ts +23 -0
  76. package/src/components/list-page/types.ts +1 -0
  77. package/src/components/locale-switch/index.ts +1 -0
  78. package/src/components/locale-switch/locale-switch.svelte +59 -0
  79. package/src/components/mark/index.ts +1 -0
  80. package/src/components/mark/mark.svelte +70 -0
  81. package/src/components/open-in/index.ts +6 -0
  82. package/src/components/open-in/open-in.svelte +106 -0
  83. package/src/components/picker/index.ts +4 -0
  84. package/src/components/picker/mark-picker.svelte +84 -0
  85. package/src/components/picker/picker.svelte +143 -0
  86. package/src/components/picker/trigger.ts +42 -0
  87. package/src/components/picker/types.ts +26 -0
  88. package/src/components/segmented/animated-tabs.svelte +175 -0
  89. package/src/components/segmented/collapse.test.ts +49 -0
  90. package/src/components/segmented/collapse.ts +39 -0
  91. package/src/components/segmented/index.ts +41 -0
  92. package/src/components/segmented/section-runs.svelte +79 -0
  93. package/src/components/segmented/segment.svelte +106 -0
  94. package/src/components/segmented/segmented-filter.svelte +125 -0
  95. package/src/components/segmented/segmented-nav.svelte +82 -0
  96. package/src/components/segmented/segmented-overflow.svelte +85 -0
  97. package/src/components/segmented/segmented-track.svelte +181 -0
  98. package/src/components/segmented/segmented.test.ts +20 -0
  99. package/src/components/segmented/segmented.ts +80 -0
  100. package/src/components/star-rating-input/index.ts +9 -0
  101. package/src/components/star-rating-input/star-rating-input.svelte +256 -0
  102. package/src/components/svg/index.ts +1 -0
  103. package/src/components/svg/svg.svelte +9 -0
  104. package/src/effects/border-beam/BorderBeam.svelte +1 -1
  105. package/src/effects/image-comparison/ImageComparison.css +42 -8
  106. package/src/effects/image-comparison/ImageComparison.svelte +138 -21
  107. package/src/effects/image-comparison/compare.test.ts +17 -0
  108. package/src/effects/image-comparison/compare.ts +5 -0
  109. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +338 -0
  110. package/src/effects/magic-card/MagicCard.css +47 -0
  111. package/src/effects/magic-card/MagicCard.svelte +57 -0
  112. package/src/effects/marquee/Marquee.css +27 -1
  113. package/src/effects/marquee/Marquee.svelte +67 -3
  114. package/src/effects/reveal/index.ts +2 -1
  115. package/src/effects/reveal/observe.test.ts +60 -0
  116. package/src/effects/reveal/observe.ts +33 -1
  117. package/src/effects/reveal/types.ts +7 -0
  118. package/src/effects/theme-toggle/ThemeToggle.css +199 -6
  119. package/src/effects/theme-toggle/ThemeToggle.svelte +51 -26
  120. package/src/gallery.test.ts +52 -0
  121. package/src/index.ts +5 -1
  122. package/src/lib/panel.ts +7 -1
  123. package/src/lib/theme-transition.ts +37 -11
  124. package/src/lib/tone.ts +48 -0
  125. package/src/shadcn/accordion/accordion-trigger.svelte +15 -6
  126. package/src/shadcn/alert/alert.svelte +3 -1
  127. package/src/shadcn/badge/badge.svelte +8 -7
  128. package/src/shadcn/breadcrumb/breadcrumb-ellipsis.svelte +2 -2
  129. package/src/shadcn/breadcrumb/breadcrumb-separator.svelte +2 -2
  130. package/src/shadcn/calendar/calendar-month-select.svelte +2 -2
  131. package/src/shadcn/calendar/calendar-next-button.svelte +2 -2
  132. package/src/shadcn/calendar/calendar-prev-button.svelte +2 -2
  133. package/src/shadcn/calendar/calendar-year-select.svelte +2 -2
  134. package/src/shadcn/calendar/calendar.svelte +6 -0
  135. package/src/shadcn/code-block/code-block-copy-button.svelte +6 -55
  136. package/src/shadcn/command/index.ts +3 -0
  137. package/src/shadcn/field/index.ts +3 -0
  138. package/src/shadcn/field/simple-field.svelte +71 -0
  139. package/src/shadcn/field/simple-field.test.ts +47 -0
  140. package/src/shadcn/message-scroller/message-scroller-button.svelte +2 -2
  141. package/src/shadcn/radio-group/radio-group-item.svelte +3 -2
  142. package/src/shadcn/sheet/sheet-content.svelte +1 -0
  143. package/src/shadcn/sheet/sheet.css +48 -0
  144. package/src/shadcn/sonner/sonner.svelte +7 -10
  145. package/src/shadcn/toggle-group/toggle-group.svelte +10 -3
  146. package/src/shadcn/trace/trace-collapsible-trigger.svelte +2 -2
  147. package/src/ai/composer/Composer.css +0 -108
  148. package/src/ai/composer/Composer.svelte +0 -111
  149. package/src/ai/composer/index.ts +0 -10
  150. package/src/animations/accordion.css +0 -58
  151. package/src/animations/icon-swap.css +0 -43
  152. package/src/animations/notification-badge.css +0 -81
  153. package/src/animations/panel.css +0 -86
  154. package/src/animations/reasoning-stream.css +0 -49
  155. package/src/animations/skeleton-reveal.css +0 -97
  156. package/src/animations/streaming-text.css +0 -31
  157. package/src/animations/tabs-sliding.css +0 -83
  158. package/src/animations/texts-reveal.css +0 -70
  159. package/src/animations/tooltip.css +0 -72
  160. package/src/animations/view-transition-reveal.ts +0 -74
  161. package/src/lib/utils.ts +0 -45
  162. package/src/showcase/AiSection.svelte +0 -150
  163. package/src/showcase/AnimationsSection.svelte +0 -1658
  164. package/src/showcase/EffectsSection.svelte +0 -152
  165. package/src/showcase/ShadcnSection.svelte +0 -27
  166. package/src/showcase/ShowcaseImport.svelte +0 -126
  167. package/src/showcase/ShowcaseList.svelte +0 -132
  168. package/src/showcase/ShowcaseNav.svelte +0 -247
  169. package/src/showcase/ShowcaseSection.svelte +0 -112
  170. package/src/showcase/ShowcaseShell.svelte +0 -55
  171. package/src/showcase/ShowcaseStage.svelte +0 -342
  172. package/src/showcase/ShowcaseVariant.svelte +0 -109
  173. package/src/showcase/assets/image-reveal/art-1.jpg +0 -0
  174. package/src/showcase/assets/image-reveal/art-2.jpg +0 -0
  175. package/src/showcase/assets/image-reveal/art-3.jpg +0 -0
  176. package/src/showcase/check.svg +0 -1
  177. package/src/showcase/chevron-arms.svg +0 -2
  178. package/src/showcase/chevron-down.svg +0 -1
  179. package/src/showcase/copy.svg +0 -2
  180. package/src/showcase/demos/ai/confirmation.svelte +0 -59
  181. package/src/showcase/demos/ai/loading-state.svelte +0 -19
  182. package/src/showcase/demos/ai/prompt-input.svelte +0 -57
  183. package/src/showcase/demos/ai/reasoning.svelte +0 -19
  184. package/src/showcase/demos/ai/thinking-dots.svelte +0 -15
  185. package/src/showcase/demos/ai/voice-waveform.svelte +0 -24
  186. package/src/showcase/demos/effects/animated-beam.svelte +0 -50
  187. package/src/showcase/demos/effects/animated-group.svelte +0 -47
  188. package/src/showcase/demos/effects/border-beam-default.svelte +0 -20
  189. package/src/showcase/demos/effects/border-beam-line.svelte +0 -20
  190. package/src/showcase/demos/effects/border-beam-mono.svelte +0 -20
  191. package/src/showcase/demos/effects/border-beam-sunset.svelte +0 -20
  192. package/src/showcase/demos/effects/image-comparison.svelte +0 -25
  193. package/src/showcase/demos/effects/image-reveal.svelte +0 -49
  194. package/src/showcase/demos/effects/in-view.svelte +0 -41
  195. package/src/showcase/demos/effects/integrations-hub.svelte +0 -82
  196. package/src/showcase/demos/effects/magnetic.svelte +0 -31
  197. package/src/showcase/demos/effects/marquee.svelte +0 -33
  198. package/src/showcase/demos/effects/progressive-blur.svelte +0 -47
  199. package/src/showcase/demos/effects/spotlight.svelte +0 -40
  200. package/src/showcase/demos/effects/text-effect.svelte +0 -55
  201. package/src/showcase/demos/shadcn/accordion.svelte +0 -45
  202. package/src/showcase/demos/shadcn/alert-dialog.svelte +0 -23
  203. package/src/showcase/demos/shadcn/alert.svelte +0 -30
  204. package/src/showcase/demos/shadcn/attachment.svelte +0 -71
  205. package/src/showcase/demos/shadcn/avatar.svelte +0 -30
  206. package/src/showcase/demos/shadcn/badge.svelte +0 -26
  207. package/src/showcase/demos/shadcn/breadcrumb.svelte +0 -34
  208. package/src/showcase/demos/shadcn/bubble.svelte +0 -36
  209. package/src/showcase/demos/shadcn/button.svelte +0 -11
  210. package/src/showcase/demos/shadcn/calendar.svelte +0 -8
  211. package/src/showcase/demos/shadcn/card.svelte +0 -39
  212. package/src/showcase/demos/shadcn/checkbox.svelte +0 -39
  213. package/src/showcase/demos/shadcn/code-block.svelte +0 -80
  214. package/src/showcase/demos/shadcn/collapsible.svelte +0 -20
  215. package/src/showcase/demos/shadcn/command.svelte +0 -48
  216. package/src/showcase/demos/shadcn/context-menu.svelte +0 -54
  217. package/src/showcase/demos/shadcn/demo-harness.svelte +0 -12
  218. package/src/showcase/demos/shadcn/demos.test.ts +0 -20
  219. package/src/showcase/demos/shadcn/dialog.svelte +0 -38
  220. package/src/showcase/demos/shadcn/dropdown-menu.svelte +0 -59
  221. package/src/showcase/demos/shadcn/empty.svelte +0 -29
  222. package/src/showcase/demos/shadcn/field.svelte +0 -115
  223. package/src/showcase/demos/shadcn/hover-card.svelte +0 -32
  224. package/src/showcase/demos/shadcn/index.ts +0 -382
  225. package/src/showcase/demos/shadcn/input-otp.svelte +0 -19
  226. package/src/showcase/demos/shadcn/input.svelte +0 -5
  227. package/src/showcase/demos/shadcn/label.svelte +0 -11
  228. package/src/showcase/demos/shadcn/marker.svelte +0 -30
  229. package/src/showcase/demos/shadcn/message-scroller.svelte +0 -44
  230. package/src/showcase/demos/shadcn/message.svelte +0 -75
  231. package/src/showcase/demos/shadcn/popover.svelte +0 -36
  232. package/src/showcase/demos/shadcn/progress.svelte +0 -13
  233. package/src/showcase/demos/shadcn/radio-group.svelte +0 -19
  234. package/src/showcase/demos/shadcn/scroll-area.svelte +0 -18
  235. package/src/showcase/demos/shadcn/select.svelte +0 -29
  236. package/src/showcase/demos/shadcn/separator.svelte +0 -12
  237. package/src/showcase/demos/shadcn/sheet.svelte +0 -48
  238. package/src/showcase/demos/shadcn/skeleton.svelte +0 -11
  239. package/src/showcase/demos/shadcn/slider.svelte +0 -7
  240. package/src/showcase/demos/shadcn/sonner.svelte +0 -18
  241. package/src/showcase/demos/shadcn/spinner.svelte +0 -11
  242. package/src/showcase/demos/shadcn/switch.svelte +0 -9
  243. package/src/showcase/demos/shadcn/table.svelte +0 -76
  244. package/src/showcase/demos/shadcn/tabs.svelte +0 -62
  245. package/src/showcase/demos/shadcn/textarea.svelte +0 -5
  246. package/src/showcase/demos/shadcn/toggle-group.svelte +0 -18
  247. package/src/showcase/demos/shadcn/toggle.svelte +0 -14
  248. package/src/showcase/demos/shadcn/tooltip.svelte +0 -13
  249. package/src/showcase/demos/shadcn/trace.svelte +0 -51
  250. package/src/showcase/heart.svg +0 -1
  251. package/src/showcase/index.ts +0 -38
  252. package/src/showcase/kit.test.ts +0 -72
  253. package/src/showcase/kit.ts +0 -16
  254. package/src/showcase/plus.svg +0 -1
  255. package/src/showcase/showcase-shell.css +0 -91
  256. package/src/showcase/stacked-section.css +0 -15
  257. package/src/showcase/success-check.svg +0 -1
  258. package/src/showcase/types.ts +0 -29
  259. 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>