@heyramzi/ui 7.0.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 (253) hide show
  1. package/COMPONENTS.md +28 -17
  2. package/README.md +5 -1
  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/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 -6
  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 +14 -14
  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/theme-transition.ts +37 -11
  118. package/src/lib/tone.ts +48 -0
  119. package/src/shadcn/accordion/accordion-trigger.svelte +15 -6
  120. package/src/shadcn/alert/alert.svelte +3 -1
  121. package/src/shadcn/badge/badge.svelte +8 -7
  122. package/src/shadcn/breadcrumb/breadcrumb-ellipsis.svelte +2 -2
  123. package/src/shadcn/breadcrumb/breadcrumb-separator.svelte +2 -2
  124. package/src/shadcn/calendar/calendar-month-select.svelte +2 -2
  125. package/src/shadcn/calendar/calendar-next-button.svelte +2 -2
  126. package/src/shadcn/calendar/calendar-prev-button.svelte +2 -2
  127. package/src/shadcn/calendar/calendar-year-select.svelte +2 -2
  128. package/src/shadcn/calendar/calendar.svelte +6 -0
  129. package/src/shadcn/code-block/code-block-copy-button.svelte +6 -55
  130. package/src/shadcn/command/index.ts +3 -0
  131. package/src/shadcn/field/index.ts +3 -0
  132. package/src/shadcn/field/simple-field.svelte +71 -0
  133. package/src/shadcn/field/simple-field.test.ts +47 -0
  134. package/src/shadcn/message-scroller/message-scroller-button.svelte +2 -2
  135. package/src/shadcn/radio-group/radio-group-item.svelte +3 -2
  136. package/src/shadcn/sheet/sheet-content.svelte +1 -0
  137. package/src/shadcn/sheet/sheet.css +48 -0
  138. package/src/shadcn/sonner/sonner.svelte +7 -10
  139. package/src/shadcn/toggle-group/toggle-group.svelte +10 -3
  140. package/src/shadcn/trace/trace-collapsible-trigger.svelte +2 -2
  141. package/src/ai/composer/Composer.css +0 -108
  142. package/src/ai/composer/Composer.svelte +0 -111
  143. package/src/ai/composer/index.ts +0 -10
  144. package/src/animations/accordion.css +0 -58
  145. package/src/animations/icon-swap.css +0 -43
  146. package/src/animations/notification-badge.css +0 -81
  147. package/src/animations/panel.css +0 -86
  148. package/src/animations/reasoning-stream.css +0 -49
  149. package/src/animations/skeleton-reveal.css +0 -97
  150. package/src/animations/streaming-text.css +0 -31
  151. package/src/animations/tabs-sliding.css +0 -83
  152. package/src/animations/texts-reveal.css +0 -70
  153. package/src/animations/tooltip.css +0 -72
  154. package/src/animations/view-transition-reveal.ts +0 -74
  155. package/src/lib/utils.ts +0 -45
  156. package/src/showcase/AiSection.svelte +0 -150
  157. package/src/showcase/AnimationsSection.svelte +0 -1658
  158. package/src/showcase/EffectsSection.svelte +0 -152
  159. package/src/showcase/ShadcnSection.svelte +0 -27
  160. package/src/showcase/ShowcaseImport.svelte +0 -126
  161. package/src/showcase/ShowcaseList.svelte +0 -132
  162. package/src/showcase/ShowcaseNav.svelte +0 -247
  163. package/src/showcase/ShowcaseSection.svelte +0 -112
  164. package/src/showcase/ShowcaseShell.svelte +0 -55
  165. package/src/showcase/ShowcaseStage.svelte +0 -342
  166. package/src/showcase/ShowcaseVariant.svelte +0 -109
  167. package/src/showcase/assets/image-reveal/art-1.jpg +0 -0
  168. package/src/showcase/assets/image-reveal/art-2.jpg +0 -0
  169. package/src/showcase/assets/image-reveal/art-3.jpg +0 -0
  170. package/src/showcase/check.svg +0 -1
  171. package/src/showcase/chevron-arms.svg +0 -2
  172. package/src/showcase/chevron-down.svg +0 -1
  173. package/src/showcase/copy.svg +0 -2
  174. package/src/showcase/demos/ai/confirmation.svelte +0 -59
  175. package/src/showcase/demos/ai/loading-state.svelte +0 -19
  176. package/src/showcase/demos/ai/prompt-input.svelte +0 -57
  177. package/src/showcase/demos/ai/reasoning.svelte +0 -19
  178. package/src/showcase/demos/ai/thinking-dots.svelte +0 -15
  179. package/src/showcase/demos/ai/voice-waveform.svelte +0 -24
  180. package/src/showcase/demos/effects/animated-beam.svelte +0 -50
  181. package/src/showcase/demos/effects/animated-group.svelte +0 -47
  182. package/src/showcase/demos/effects/border-beam-default.svelte +0 -20
  183. package/src/showcase/demos/effects/border-beam-line.svelte +0 -20
  184. package/src/showcase/demos/effects/border-beam-mono.svelte +0 -20
  185. package/src/showcase/demos/effects/border-beam-sunset.svelte +0 -20
  186. package/src/showcase/demos/effects/image-comparison.svelte +0 -25
  187. package/src/showcase/demos/effects/image-reveal.svelte +0 -49
  188. package/src/showcase/demos/effects/in-view.svelte +0 -41
  189. package/src/showcase/demos/effects/integrations-hub.svelte +0 -82
  190. package/src/showcase/demos/effects/magnetic.svelte +0 -31
  191. package/src/showcase/demos/effects/marquee.svelte +0 -33
  192. package/src/showcase/demos/effects/progressive-blur.svelte +0 -47
  193. package/src/showcase/demos/effects/spotlight.svelte +0 -40
  194. package/src/showcase/demos/effects/text-effect.svelte +0 -55
  195. package/src/showcase/demos/shadcn/accordion.svelte +0 -45
  196. package/src/showcase/demos/shadcn/alert-dialog.svelte +0 -23
  197. package/src/showcase/demos/shadcn/alert.svelte +0 -30
  198. package/src/showcase/demos/shadcn/attachment.svelte +0 -71
  199. package/src/showcase/demos/shadcn/avatar.svelte +0 -30
  200. package/src/showcase/demos/shadcn/badge.svelte +0 -26
  201. package/src/showcase/demos/shadcn/breadcrumb.svelte +0 -34
  202. package/src/showcase/demos/shadcn/bubble.svelte +0 -36
  203. package/src/showcase/demos/shadcn/button.svelte +0 -11
  204. package/src/showcase/demos/shadcn/calendar.svelte +0 -8
  205. package/src/showcase/demos/shadcn/card.svelte +0 -39
  206. package/src/showcase/demos/shadcn/checkbox.svelte +0 -39
  207. package/src/showcase/demos/shadcn/code-block.svelte +0 -80
  208. package/src/showcase/demos/shadcn/collapsible.svelte +0 -20
  209. package/src/showcase/demos/shadcn/command.svelte +0 -48
  210. package/src/showcase/demos/shadcn/context-menu.svelte +0 -54
  211. package/src/showcase/demos/shadcn/demo-harness.svelte +0 -12
  212. package/src/showcase/demos/shadcn/demos.test.ts +0 -20
  213. package/src/showcase/demos/shadcn/dialog.svelte +0 -38
  214. package/src/showcase/demos/shadcn/dropdown-menu.svelte +0 -59
  215. package/src/showcase/demos/shadcn/empty.svelte +0 -29
  216. package/src/showcase/demos/shadcn/field.svelte +0 -115
  217. package/src/showcase/demos/shadcn/hover-card.svelte +0 -32
  218. package/src/showcase/demos/shadcn/index.ts +0 -382
  219. package/src/showcase/demos/shadcn/input-otp.svelte +0 -19
  220. package/src/showcase/demos/shadcn/input.svelte +0 -5
  221. package/src/showcase/demos/shadcn/label.svelte +0 -11
  222. package/src/showcase/demos/shadcn/marker.svelte +0 -30
  223. package/src/showcase/demos/shadcn/message-scroller.svelte +0 -44
  224. package/src/showcase/demos/shadcn/message.svelte +0 -75
  225. package/src/showcase/demos/shadcn/popover.svelte +0 -36
  226. package/src/showcase/demos/shadcn/progress.svelte +0 -13
  227. package/src/showcase/demos/shadcn/radio-group.svelte +0 -19
  228. package/src/showcase/demos/shadcn/scroll-area.svelte +0 -18
  229. package/src/showcase/demos/shadcn/select.svelte +0 -29
  230. package/src/showcase/demos/shadcn/separator.svelte +0 -12
  231. package/src/showcase/demos/shadcn/sheet.svelte +0 -48
  232. package/src/showcase/demos/shadcn/skeleton.svelte +0 -11
  233. package/src/showcase/demos/shadcn/slider.svelte +0 -7
  234. package/src/showcase/demos/shadcn/sonner.svelte +0 -18
  235. package/src/showcase/demos/shadcn/spinner.svelte +0 -11
  236. package/src/showcase/demos/shadcn/switch.svelte +0 -9
  237. package/src/showcase/demos/shadcn/table.svelte +0 -76
  238. package/src/showcase/demos/shadcn/tabs.svelte +0 -62
  239. package/src/showcase/demos/shadcn/textarea.svelte +0 -5
  240. package/src/showcase/demos/shadcn/toggle-group.svelte +0 -18
  241. package/src/showcase/demos/shadcn/toggle.svelte +0 -14
  242. package/src/showcase/demos/shadcn/tooltip.svelte +0 -13
  243. package/src/showcase/demos/shadcn/trace.svelte +0 -51
  244. package/src/showcase/heart.svg +0 -1
  245. package/src/showcase/index.ts +0 -38
  246. package/src/showcase/kit.test.ts +0 -72
  247. package/src/showcase/kit.ts +0 -16
  248. package/src/showcase/plus.svg +0 -1
  249. package/src/showcase/showcase-shell.css +0 -91
  250. package/src/showcase/stacked-section.css +0 -15
  251. package/src/showcase/success-check.svg +0 -1
  252. package/src/showcase/types.ts +0 -29
  253. package/src/svg-raw.d.ts +0 -7
@@ -0,0 +1,206 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
+ import { copyRichText, copyText } from "./clipboard.js";
3
+
4
+ // Mock navigator.clipboard
5
+ const mockClipboard = {
6
+ write: vi.fn(),
7
+ writeText: vi.fn(),
8
+ };
9
+
10
+ // Mock ClipboardItem
11
+ const mockClipboardItem = vi.fn();
12
+
13
+ describe("clipboard", () => {
14
+ beforeEach(() => {
15
+ vi.stubGlobal("navigator", { clipboard: mockClipboard });
16
+ vi.stubGlobal("ClipboardItem", mockClipboardItem);
17
+ vi.clearAllMocks();
18
+ });
19
+
20
+ afterEach(() => {
21
+ vi.unstubAllGlobals();
22
+ });
23
+
24
+ describe("copyRichText", () => {
25
+ it("copies HTML with modern Clipboard API", async () => {
26
+ mockClipboard.write.mockResolvedValue(undefined);
27
+
28
+ const result = await copyRichText("<p>Hello <strong>World</strong></p>", "Hello World");
29
+
30
+ expect(result).toBe(true);
31
+ expect(mockClipboardItem).toHaveBeenCalledWith({
32
+ "text/html": expect.any(Blob),
33
+ "text/plain": expect.any(Blob),
34
+ });
35
+ expect(mockClipboard.write).toHaveBeenCalledTimes(1);
36
+ });
37
+
38
+ it("extracts plain text from HTML when not provided", async () => {
39
+ mockClipboard.write.mockResolvedValue(undefined);
40
+
41
+ await copyRichText("<p>Test</p>");
42
+
43
+ // Verify ClipboardItem was called with both HTML and plain text blobs
44
+ expect(mockClipboardItem).toHaveBeenCalled();
45
+ });
46
+
47
+ it("falls back to plain text when Clipboard API fails", async () => {
48
+ mockClipboard.write.mockRejectedValue(new Error("Not supported"));
49
+ mockClipboard.writeText.mockResolvedValue(undefined);
50
+
51
+ const result = await copyRichText("<p>Test</p>", "Test");
52
+
53
+ expect(result).toBe(true);
54
+ expect(mockClipboard.writeText).toHaveBeenCalledWith("Test");
55
+ });
56
+
57
+ it("returns false when both methods fail", async () => {
58
+ mockClipboard.write.mockRejectedValue(new Error("Not supported"));
59
+ mockClipboard.writeText.mockRejectedValue(new Error("Blocked"));
60
+
61
+ const result = await copyRichText("<p>Test</p>", "Test");
62
+
63
+ expect(result).toBe(false);
64
+ });
65
+
66
+ it("creates correct blob types", async () => {
67
+ mockClipboard.write.mockResolvedValue(undefined);
68
+
69
+ await copyRichText("<b>Bold</b>", "Bold");
70
+
71
+ const clipboardItemCall = mockClipboardItem.mock.calls[0][0];
72
+
73
+ expect(clipboardItemCall["text/html"]).toBeInstanceOf(Blob);
74
+ expect(clipboardItemCall["text/html"].type).toBe("text/html");
75
+ expect(clipboardItemCall["text/plain"]).toBeInstanceOf(Blob);
76
+ expect(clipboardItemCall["text/plain"].type).toBe("text/plain");
77
+ });
78
+ });
79
+
80
+ describe("copyText", () => {
81
+ it("copies text successfully", async () => {
82
+ mockClipboard.writeText.mockResolvedValue(undefined);
83
+
84
+ const result = await copyText("Hello World");
85
+
86
+ expect(result).toBe(true);
87
+ expect(mockClipboard.writeText).toHaveBeenCalledWith("Hello World");
88
+ });
89
+
90
+ it("returns false on failure", async () => {
91
+ mockClipboard.writeText.mockRejectedValue(new Error("Permission denied"));
92
+
93
+ const result = await copyText("Test");
94
+
95
+ expect(result).toBe(false);
96
+ });
97
+
98
+ it("handles empty string", async () => {
99
+ mockClipboard.writeText.mockResolvedValue(undefined);
100
+
101
+ const result = await copyText("");
102
+
103
+ expect(result).toBe(true);
104
+ expect(mockClipboard.writeText).toHaveBeenCalledWith("");
105
+ });
106
+
107
+ it("handles special characters", async () => {
108
+ mockClipboard.writeText.mockResolvedValue(undefined);
109
+
110
+ const result = await copyText("Hello <>&\"' World");
111
+
112
+ expect(result).toBe(true);
113
+ expect(mockClipboard.writeText).toHaveBeenCalledWith("Hello <>&\"' World");
114
+ });
115
+ });
116
+ });
117
+
118
+ // Test htmlToPlainText server-side conversion (via copyRichText behavior)
119
+ describe("htmlToPlainText (server-side)", () => {
120
+ beforeEach(() => {
121
+ // Remove document to simulate server environment
122
+ vi.stubGlobal("document", undefined);
123
+ vi.stubGlobal("navigator", {
124
+ clipboard: {
125
+ write: vi.fn().mockRejectedValue(new Error("Server")),
126
+ writeText: vi.fn().mockResolvedValue(undefined),
127
+ },
128
+ });
129
+ vi.stubGlobal("ClipboardItem", vi.fn());
130
+ });
131
+
132
+ afterEach(() => {
133
+ vi.unstubAllGlobals();
134
+ });
135
+
136
+ it("converts line breaks", async () => {
137
+ const mockWriteText = vi.fn().mockResolvedValue(undefined);
138
+ vi.stubGlobal("navigator", {
139
+ clipboard: {
140
+ write: vi.fn().mockRejectedValue(new Error()),
141
+ writeText: mockWriteText,
142
+ },
143
+ });
144
+
145
+ await copyRichText("Line 1<br>Line 2<br/>Line 3");
146
+
147
+ expect(mockWriteText).toHaveBeenCalledWith("Line 1\nLine 2\nLine 3");
148
+ });
149
+
150
+ it("converts paragraph tags", async () => {
151
+ const mockWriteText = vi.fn().mockResolvedValue(undefined);
152
+ vi.stubGlobal("navigator", {
153
+ clipboard: {
154
+ write: vi.fn().mockRejectedValue(new Error()),
155
+ writeText: mockWriteText,
156
+ },
157
+ });
158
+
159
+ await copyRichText("<p>Para 1</p><p>Para 2</p>");
160
+
161
+ expect(mockWriteText).toHaveBeenCalledWith("Para 1\n\nPara 2");
162
+ });
163
+
164
+ it("converts list items to bullets", async () => {
165
+ const mockWriteText = vi.fn().mockResolvedValue(undefined);
166
+ vi.stubGlobal("navigator", {
167
+ clipboard: {
168
+ write: vi.fn().mockRejectedValue(new Error()),
169
+ writeText: mockWriteText,
170
+ },
171
+ });
172
+
173
+ await copyRichText("<ul><li>Item 1</li><li>Item 2</li></ul>");
174
+
175
+ expect(mockWriteText).toHaveBeenCalledWith("• Item 1\n• Item 2");
176
+ });
177
+
178
+ it("strips remaining HTML tags", async () => {
179
+ const mockWriteText = vi.fn().mockResolvedValue(undefined);
180
+ vi.stubGlobal("navigator", {
181
+ clipboard: {
182
+ write: vi.fn().mockRejectedValue(new Error()),
183
+ writeText: mockWriteText,
184
+ },
185
+ });
186
+
187
+ await copyRichText("<div><span>Hello</span> <strong>World</strong></div>");
188
+
189
+ expect(mockWriteText).toHaveBeenCalledWith("Hello World");
190
+ });
191
+
192
+ it("decodes HTML entities", async () => {
193
+ const mockWriteText = vi.fn().mockResolvedValue(undefined);
194
+ vi.stubGlobal("navigator", {
195
+ clipboard: {
196
+ write: vi.fn().mockRejectedValue(new Error()),
197
+ writeText: mockWriteText,
198
+ },
199
+ });
200
+
201
+ // The function decodes &nbsp; to space, &amp; to &, &lt; to <, &gt; to >
202
+ await copyRichText("Hello&nbsp;World &amp; Friends &lt;3&gt;");
203
+
204
+ expect(mockWriteText).toHaveBeenCalledWith("Hello World & Friends <3>");
205
+ });
206
+ });
@@ -0,0 +1,49 @@
1
+ import { tryCatchAsync } from "@heyramzi/utils";
2
+
3
+ /**
4
+ * The one write to the clipboard. A rejected permission, a missing gesture and
5
+ * an insecure origin get one answer: false. The caller decides what to show.
6
+ */
7
+
8
+ /** Plain text. Returns false when the browser refused the write. */
9
+ export async function copyText(value: string): Promise<boolean> {
10
+ const result = await tryCatchAsync(() => navigator.clipboard.writeText(value));
11
+ return result.ok;
12
+ }
13
+
14
+ /**
15
+ * HTML plus a plain-text twin: Gmail keeps the formatting, Cmd+Shift+V stays
16
+ * readable. Falls back to plain text.
17
+ */
18
+ export async function copyRichText(html: string, plainText?: string): Promise<boolean> {
19
+ const text = plainText ?? htmlToPlainText(html);
20
+ const result = await tryCatchAsync(() =>
21
+ navigator.clipboard.write([
22
+ new ClipboardItem({
23
+ "text/html": new Blob([html], { type: "text/html" }),
24
+ "text/plain": new Blob([text], { type: "text/plain" }),
25
+ }),
26
+ ]),
27
+ );
28
+ return result.ok ? true : copyText(text);
29
+ }
30
+
31
+ /** The plain-text twin of an HTML fragment. */
32
+ export function htmlToPlainText(html: string): string {
33
+ if (typeof document === "undefined") {
34
+ return html
35
+ .replace(/<br\s*\/?>/gi, "\n")
36
+ .replace(/<\/p>/gi, "\n\n")
37
+ .replace(/<\/li>/gi, "\n")
38
+ .replace(/<li>/gi, "• ")
39
+ .replace(/<[^>]+>/g, "")
40
+ .replace(/&nbsp;/g, " ")
41
+ .replace(/&amp;/g, "&")
42
+ .replace(/&lt;/g, "<")
43
+ .replace(/&gt;/g, ">")
44
+ .trim();
45
+ }
46
+ const div = document.createElement("div");
47
+ div.innerHTML = html;
48
+ return div.textContent || div.innerText || "";
49
+ }
@@ -0,0 +1,39 @@
1
+ import { copyText } from "./clipboard.js";
2
+
3
+ /**
4
+ * The copied flag for a surface whose affordance is a whole row or card rather
5
+ * than a `CopyButton`. Keyed, so one instance serves a list of rows.
6
+ */
7
+ export class Copied {
8
+ /** The key that last copied, or null. Empty string when a surface has one. */
9
+ key = $state<string | null>(null);
10
+ #timer: ReturnType<typeof setTimeout> | null = null;
11
+
12
+ /** True when anything copied; with a key, true only for that one. */
13
+ is(key = ""): boolean {
14
+ return this.key === key;
15
+ }
16
+
17
+ /** Writes, then holds the check for `hold` ms. False when the write failed. */
18
+ async copy(value: string, key = "", hold = 1400): Promise<boolean> {
19
+ if (!(await copyText(value))) return false;
20
+ this.mark(key, hold);
21
+ return true;
22
+ }
23
+
24
+ /** The confirmation without the write, for a copy this class did not make. */
25
+ mark(key = "", hold = 1400): void {
26
+ this.key = key;
27
+ if (this.#timer) clearTimeout(this.#timer);
28
+ this.#timer = setTimeout(() => {
29
+ this.key = null;
30
+ this.#timer = null;
31
+ }, hold);
32
+ }
33
+
34
+ reset(): void {
35
+ if (this.#timer) clearTimeout(this.#timer);
36
+ this.#timer = null;
37
+ this.key = null;
38
+ }
39
+ }
@@ -0,0 +1,175 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The one copy control. No `text` prop draws the icon button; `text` draws the
4
+ * chip. `agent` marks a payload written for a model.
5
+ */
6
+
7
+ // =========================
8
+ // IMPORTS
9
+ // =========================
10
+ import { Check, Copy, StarFour } from "phosphor-svelte";
11
+ import { cn } from "@heyramzi/utils";
12
+ import { buttonVariants } from "../../shadcn/button/index.js";
13
+ import type { HTMLButtonAttributes } from "svelte/elements";
14
+ import { copyText } from "./clipboard.js";
15
+
16
+ // =========================
17
+ // TYPES
18
+ // =========================
19
+ interface Props extends Omit<HTMLButtonAttributes, "value" | "onclick" | "children"> {
20
+ /**
21
+ * What lands on the clipboard. A function runs on the click, so a long list
22
+ * doesn't build every prompt up front. It may return a promise.
23
+ */
24
+ value: string | (() => string | Promise<string>);
25
+ /** Names the thing being copied, for a screen reader and the tooltip. */
26
+ label?: string;
27
+ /** Announced when the write lands; see `announce`. */
28
+ copiedLabel?: string;
29
+ /** The chip's visible text. Its presence is what makes this a chip. */
30
+ text?: string;
31
+ /** Marks the payload as an instruction for an AI rather than a raw string. */
32
+ agent?: boolean;
33
+ /**
34
+ * `sm` matches `OpenIn`, for the icon row inside a calendar chip. `md` is
35
+ * the 28px icon `Button`, for a toolbar that sits beside other icon buttons.
36
+ */
37
+ size?: "default" | "md" | "sm";
38
+ /**
39
+ * The icon button's `Button` variant: ghost unless it sits beside an outline
40
+ * one. `inline` is for the chip: it takes the surrounding text's font and
41
+ * colour, and the icon trails it.
42
+ */
43
+ variant?: "ghost" | "outline" | "inline";
44
+ /**
45
+ * How a screen reader hears the copy. `region` keeps the name fixed and
46
+ * announces `copiedLabel` in a live region. `name` swaps the button's own name
47
+ * to `copiedLabel` (write it in full) and has no region, for a page that
48
+ * renders hundreds of these.
49
+ */
50
+ announce?: "region" | "name";
51
+ /** Runs after a write that landed, for a toast the call site owns. */
52
+ onCopied?: () => void;
53
+ class?: string;
54
+ }
55
+
56
+ // =========================
57
+ // PROPS
58
+ // =========================
59
+ let {
60
+ value,
61
+ label = "Copy",
62
+ copiedLabel = "Copied",
63
+ text,
64
+ agent = false,
65
+ size = "default",
66
+ variant = "ghost",
67
+ announce = "region",
68
+ onCopied,
69
+ class: className,
70
+ ...restProps
71
+ }: Props = $props();
72
+
73
+ // =========================
74
+ // STATE
75
+ // =========================
76
+ let copied = $state(false);
77
+ let resetAt: ReturnType<typeof setTimeout> | undefined;
78
+
79
+ // =========================
80
+ // DERIVED
81
+ // =========================
82
+ const inline = $derived(text !== undefined && variant === "inline");
83
+
84
+ // =========================
85
+ // FUNCTIONS
86
+ // =========================
87
+ async function copy(event: MouseEvent) {
88
+ // Rows and cards are clickable; taking the payload out of one must not also
89
+ // open the record it belongs to.
90
+ event.stopPropagation();
91
+ event.preventDefault();
92
+ const payload = typeof value === "function" ? await value() : value;
93
+ if (!(await copyText(payload))) return;
94
+ copied = true;
95
+ clearTimeout(resetAt);
96
+ resetAt = setTimeout(() => (copied = false), 1400);
97
+ onCopied?.();
98
+ }
99
+ </script>
100
+
101
+ <!-- =========================== -->
102
+ <!-- MARKUP -->
103
+ <!-- =========================== -->
104
+ <button
105
+ {...restProps}
106
+ type="button"
107
+ onclick={copy}
108
+ title={label}
109
+ aria-label={announce === "name" && copied ? copiedLabel : label}
110
+ class={cn(
111
+ "group/copy t-press shrink-0 outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
112
+ text === undefined
113
+ ? size === "sm"
114
+ ? "grid size-5 place-items-center rounded-md hover:bg-accent"
115
+ : buttonVariants({
116
+ variant: variant === "outline" ? "outline" : "ghost",
117
+ size: size === "md" ? "sm" : "default",
118
+ icon: true,
119
+ })
120
+ : inline
121
+ ? "inline-flex cursor-copy items-baseline gap-1.5 transition-colors hover:text-foreground"
122
+ : "flex items-center gap-1 rounded px-1.5 py-0.5 font-mono text-xs tabular-nums text-muted-foreground/70 transition-colors hover:bg-muted hover:text-foreground",
123
+ className,
124
+ )}
125
+ >
126
+ <!-- One slot, two glyphs, per the transitions-dev icon-swap: the check
127
+ arrives on the bounce, the copy glyph leaves without one. -->
128
+ <span
129
+ class={cn(
130
+ "relative grid place-items-center",
131
+ text === undefined && size !== "sm" ? "size-4" : "size-3",
132
+ inline && "order-last self-center opacity-60 transition-opacity group-hover/copy:opacity-100",
133
+ )}
134
+ >
135
+ <Check
136
+ class={cn(
137
+ "col-start-1 row-start-1 size-full transition-[opacity,transform] duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)]",
138
+ inline ? "text-current" : "text-success-800 dark:text-success-400",
139
+ copied ? "scale-100 opacity-100" : "scale-50 opacity-0",
140
+ )}
141
+ weight="bold"
142
+ aria-hidden="true"
143
+ />
144
+ {#if agent}
145
+ <StarFour
146
+ class={cn(
147
+ "col-start-1 row-start-1 size-full text-brand-indigo/70 transition-[opacity,color] duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] group-hover/copy:text-brand-indigo",
148
+ copied ? "opacity-0" : "opacity-100",
149
+ )}
150
+ weight="bold"
151
+ aria-hidden="true"
152
+ />
153
+ {:else}
154
+ <Copy
155
+ class={cn(
156
+ "col-start-1 row-start-1 size-full transition-[opacity,color] duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)]",
157
+ inline
158
+ ? "text-current"
159
+ : "text-muted-foreground group-hover/copy:text-foreground",
160
+ copied ? "opacity-0" : "opacity-100",
161
+ )}
162
+ aria-hidden="true"
163
+ />
164
+ {/if}
165
+ </span>
166
+
167
+ {#if text !== undefined}
168
+ <span>{text}</span>
169
+ {/if}
170
+
171
+ <!-- The swap is invisible to a reader who cannot see it. -->
172
+ {#if announce === "region"}
173
+ <span aria-live="polite" class="sr-only">{copied ? copiedLabel : ""}</span>
174
+ {/if}
175
+ </button>
@@ -0,0 +1,110 @@
1
+ import { flushSync, mount, unmount } from "svelte";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
+ import CopyButton from "./copy-button.svelte";
4
+
5
+ type Props = { value: string; [key: string]: unknown };
6
+
7
+ // jsdom ships no clipboard, so the write is stubbed and asserted on directly.
8
+ function stubClipboard(result: Promise<void> = Promise.resolve()) {
9
+ const writeText = vi.fn().mockReturnValue(result);
10
+ vi.stubGlobal("navigator", { clipboard: { writeText } });
11
+ return writeText;
12
+ }
13
+
14
+ function render(props: Props) {
15
+ const target = document.createElement("div");
16
+ document.body.appendChild(target);
17
+ const app = mount(CopyButton, { target, props });
18
+ flushSync();
19
+ return {
20
+ button: target.querySelector("button")!,
21
+ target,
22
+ done: () => {
23
+ void unmount(app);
24
+ target.remove();
25
+ },
26
+ };
27
+ }
28
+
29
+ async function click(button: HTMLButtonElement) {
30
+ button.click();
31
+ await vi.advanceTimersByTimeAsync(0);
32
+ flushSync();
33
+ }
34
+
35
+ describe("CopyButton", () => {
36
+ beforeEach(() => vi.useFakeTimers());
37
+ afterEach(() => {
38
+ vi.useRealTimers();
39
+ vi.unstubAllGlobals();
40
+ });
41
+
42
+ it("puts the value on the clipboard", async () => {
43
+ const writeText = stubClipboard();
44
+ const { button, done } = render({ value: "26011200", text: "26011200" });
45
+ await click(button);
46
+ expect(writeText).toHaveBeenCalledWith("26011200");
47
+ done();
48
+ });
49
+
50
+ it("names what it copies, so the button is not just a value", () => {
51
+ stubClipboard();
52
+ const { button, done } = render({ value: "abc123", text: "abc123", label: "Copy API key abc123" });
53
+ expect(button.getAttribute("aria-label")).toBe("Copy API key abc123");
54
+ done();
55
+ });
56
+
57
+ it("announces in a live region by default and keeps its name", async () => {
58
+ stubClipboard();
59
+ const { button, target, done } = render({ value: "x", label: "Copy id", copiedLabel: "Copied" });
60
+ await click(button);
61
+ expect(target.querySelector("[aria-live]")?.textContent).toBe("Copied");
62
+ expect(button.getAttribute("aria-label")).toBe("Copy id");
63
+ done();
64
+ });
65
+
66
+ // A page of 500 codes would read as a wall of live regions, so the name carries it.
67
+ it("announce name: no live region, the accessible name says it copied, then rests", async () => {
68
+ stubClipboard();
69
+ const { button, target, done } = render({
70
+ value: "25070080",
71
+ text: "25070080",
72
+ label: "Copy CN code 25070080",
73
+ copiedLabel: "CN code 25070080 copied",
74
+ announce: "name",
75
+ });
76
+ expect(target.querySelector("[aria-live]")).toBeNull();
77
+ await click(button);
78
+ expect(button.getAttribute("aria-label")).toBe("CN code 25070080 copied");
79
+ await vi.advanceTimersByTimeAsync(1500);
80
+ flushSync();
81
+ expect(button.getAttribute("aria-label")).toBe("Copy CN code 25070080");
82
+ done();
83
+ });
84
+
85
+ it("calls onCopied after a write that landed", async () => {
86
+ stubClipboard();
87
+ const onCopied = vi.fn();
88
+ const { button, done } = render({ value: "x", onCopied });
89
+ await click(button);
90
+ expect(onCopied).toHaveBeenCalledOnce();
91
+ done();
92
+ });
93
+
94
+ it("keeps the resting state when the browser refuses the clipboard", async () => {
95
+ const writeText = stubClipboard(Promise.reject(new Error("denied")));
96
+ const onCopied = vi.fn();
97
+ const { button, done } = render({
98
+ value: "x",
99
+ label: "Copy x",
100
+ copiedLabel: "x copied",
101
+ announce: "name",
102
+ onCopied,
103
+ });
104
+ await click(button);
105
+ expect(writeText).toHaveBeenCalled();
106
+ expect(button.getAttribute("aria-label")).toBe("Copy x");
107
+ expect(onCopied).not.toHaveBeenCalled();
108
+ done();
109
+ });
110
+ });
@@ -0,0 +1,3 @@
1
+ export { default as CopyButton } from "./copy-button.svelte";
2
+ export { copyText, copyRichText, htmlToPlainText } from "./clipboard.js";
3
+ export { Copied } from "./copied.svelte.js";