@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,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";