@nucel/ui 0.10.0 → 0.12.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 (30) hide show
  1. package/README.md +21 -1
  2. package/package.json +29 -5
  3. package/src/lib/components/ui/Alert.svelte +47 -0
  4. package/src/lib/components/ui/AppCard.svelte +76 -0
  5. package/src/lib/components/ui/BranchPill.svelte +19 -0
  6. package/src/lib/components/ui/CommentPill.svelte +12 -0
  7. package/src/lib/components/ui/KanbanBoard.svelte +27 -0
  8. package/src/lib/components/ui/KanbanCard.svelte +43 -0
  9. package/src/lib/components/ui/KanbanColumn.svelte +52 -0
  10. package/src/lib/components/ui/ListCard.svelte +9 -0
  11. package/src/lib/components/ui/PageHeader.svelte +25 -0
  12. package/src/lib/components/ui/PermissionChips.svelte +49 -0
  13. package/src/lib/components/ui/Section.svelte +21 -0
  14. package/src/lib/components/ui/SectionTitle.svelte +16 -0
  15. package/src/lib/components/ui/StatusPill.svelte +54 -0
  16. package/src/lib/components/ui/editor/RichEditor.svelte +580 -0
  17. package/src/lib/components/ui/editor/mention-suggestion.ts +144 -0
  18. package/src/lib/components/ui/table/Table.svelte +12 -0
  19. package/src/lib/components/ui/table/TableBody.svelte +10 -0
  20. package/src/lib/components/ui/table/TableCaption.svelte +10 -0
  21. package/src/lib/components/ui/table/TableCell.svelte +10 -0
  22. package/src/lib/components/ui/table/TableHead.svelte +10 -0
  23. package/src/lib/components/ui/table/TableHeader.svelte +10 -0
  24. package/src/lib/components/ui/table/TableRow.svelte +10 -0
  25. package/src/lib/components/ui/table/index.ts +7 -0
  26. package/src/lib/index.ts +50 -0
  27. package/src/lib/components/ColorInput.test.ts +0 -126
  28. package/src/lib/components/CopyButton.test.ts +0 -213
  29. package/src/lib/components/IconButton.test.ts +0 -139
  30. package/src/lib/utils/cn.test.ts +0 -993
@@ -0,0 +1,144 @@
1
+ import tippy, { type Instance, type Props as TippyProps } from 'tippy.js';
2
+
3
+ export type MentionItem = {
4
+ id: string;
5
+ label: string;
6
+ type?: 'user' | 'team';
7
+ slug?: string;
8
+ avatar_url?: string;
9
+ sublabel?: string;
10
+ };
11
+
12
+ function escapeHtml(text: string): string {
13
+ const div = document.createElement('div');
14
+ div.textContent = text;
15
+ return div.innerHTML;
16
+ }
17
+
18
+ function getInitials(name: string): string {
19
+ return name.split(' ').map(p => p.charAt(0)).join('').toUpperCase().slice(0, 2);
20
+ }
21
+
22
+ function createDropdown(props: { items: MentionItem[]; command: (item: MentionItem) => void }) {
23
+ const el = document.createElement('div');
24
+ el.className = 'mention-list';
25
+ let selectedIndex = 0;
26
+ let items = props.items;
27
+ let command = props.command;
28
+
29
+ function render() {
30
+ if (items.length === 0) {
31
+ el.innerHTML = `<div class="mention-list-empty">No results</div>`;
32
+ return;
33
+ }
34
+ if (items.length === 1 && (items[0] as any).type === 'hint') {
35
+ el.innerHTML = `<div class="mention-list-empty">${escapeHtml(items[0].label)}</div>`;
36
+ return;
37
+ }
38
+ el.innerHTML = items.map((item, i) => `
39
+ <button type="button" class="mention-list-item ${i === selectedIndex ? 'is-selected' : ''}" data-index="${i}">
40
+ ${item.avatar_url
41
+ ? `<img src="${escapeHtml(item.avatar_url)}" alt="" class="mention-avatar" />`
42
+ : `<span class="mention-avatar-fallback">${getInitials(item.label)}</span>`
43
+ }
44
+ <span class="mention-info">
45
+ <span class="mention-label">${escapeHtml(item.label)}</span>
46
+ ${item.sublabel ? `<span class="mention-sublabel">${escapeHtml(item.sublabel)}</span>` : ''}
47
+ </span>
48
+ ${item.type === 'team' ? `<span class="mention-badge">Team</span>` : ''}
49
+ </button>
50
+ `).join('');
51
+
52
+ el.querySelectorAll('.mention-list-item').forEach(btn => {
53
+ btn.addEventListener('click', () => {
54
+ const idx = parseInt((btn as HTMLElement).dataset.index ?? '0');
55
+ select(idx);
56
+ });
57
+ });
58
+ }
59
+
60
+ function select(idx: number) {
61
+ const item = items[idx];
62
+ if (item) command(item);
63
+ }
64
+
65
+ function updateSelection(idx: number) {
66
+ selectedIndex = ((idx % items.length) + items.length) % items.length;
67
+ render();
68
+ }
69
+
70
+ render();
71
+
72
+ return {
73
+ el,
74
+ update(newItems: MentionItem[], newCommand: (item: MentionItem) => void) {
75
+ items = newItems;
76
+ command = newCommand;
77
+ selectedIndex = 0;
78
+ render();
79
+ },
80
+ onKeyDown(key: string): boolean {
81
+ if (key === 'ArrowUp') { updateSelection(selectedIndex - 1); return true; }
82
+ if (key === 'ArrowDown') { updateSelection(selectedIndex + 1); return true; }
83
+ if (key === 'Enter') { select(selectedIndex); return true; }
84
+ return false;
85
+ },
86
+ };
87
+ }
88
+
89
+ export function createMentionSuggestion(mentionsUrl: string) {
90
+ return {
91
+ char: '@',
92
+ allowSpaces: false,
93
+ items: async ({ query }: { query: string }): Promise<MentionItem[]> => {
94
+ if (!query || query.length < 1) {
95
+ return [{ id: 'hint', label: 'Type to search users…', type: 'user' } as any];
96
+ }
97
+ try {
98
+ const sep = mentionsUrl.includes('?') ? '&' : '?';
99
+ const res = await fetch(`${mentionsUrl}${sep}query=${encodeURIComponent(query)}`, {
100
+ headers: { Accept: 'application/json' },
101
+ });
102
+ if (!res.ok) return [];
103
+ const data = await res.json();
104
+ return Array.isArray(data.suggestions) ? data.suggestions : [];
105
+ } catch {
106
+ return [];
107
+ }
108
+ },
109
+ render: () => {
110
+ let dropdown: ReturnType<typeof createDropdown>;
111
+ let popup: Instance<TippyProps>[];
112
+
113
+ return {
114
+ onStart(props: any) {
115
+ dropdown = createDropdown({ items: props.items, command: (item) => props.command({ id: item.id, label: item.label, type: item.type, slug: item.slug }) });
116
+ if (!props.clientRect) return;
117
+ popup = tippy('body', {
118
+ getReferenceClientRect: props.clientRect,
119
+ appendTo: () => document.body,
120
+ content: dropdown.el,
121
+ showOnCreate: true,
122
+ interactive: true,
123
+ trigger: 'manual',
124
+ placement: 'bottom-start',
125
+ }) as Instance<TippyProps>[];
126
+ },
127
+ onUpdate(props: any) {
128
+ dropdown.update(props.items, (item) => props.command({ id: item.id, label: item.label, type: item.type, slug: item.slug }));
129
+ if (props.clientRect && popup?.[0]) {
130
+ popup[0].setProps({ getReferenceClientRect: props.clientRect });
131
+ }
132
+ },
133
+ onKeyDown(props: any) {
134
+ if (props.event.key === 'Escape') { popup?.[0]?.hide(); return true; }
135
+ return dropdown.onKeyDown(props.event.key);
136
+ },
137
+ onExit() {
138
+ popup?.[0]?.destroy();
139
+ dropdown.el.remove();
140
+ },
141
+ };
142
+ },
143
+ };
144
+ }
@@ -0,0 +1,12 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../../../utils/cn.js';
4
+
5
+ let { children, class: className }: { children: Snippet; class?: string } = $props();
6
+ </script>
7
+
8
+ <div class={cn("w-full overflow-auto", className)}>
9
+ <table class="w-full caption-bottom text-sm">
10
+ {@render children()}
11
+ </table>
12
+ </div>
@@ -0,0 +1,10 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../../../utils/cn.js';
4
+
5
+ let { children, class: className }: { children: Snippet; class?: string } = $props();
6
+ </script>
7
+
8
+ <tbody class={cn("[&_tr:last-child]:border-0", className)}>
9
+ {@render children()}
10
+ </tbody>
@@ -0,0 +1,10 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../../../utils/cn.js';
4
+
5
+ let { children, class: className }: { children: Snippet; class?: string } = $props();
6
+ </script>
7
+
8
+ <caption class={cn("mt-4 text-xs text-muted-foreground", className)}>
9
+ {@render children()}
10
+ </caption>
@@ -0,0 +1,10 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../../../utils/cn.js';
4
+
5
+ let { children, class: className, ...rest }: { children?: Snippet; class?: string; [key: string]: unknown } = $props();
6
+ </script>
7
+
8
+ <td class={cn("px-4 py-2.5 align-middle [&:has([role=checkbox])]:pr-0", className)} {...rest}>
9
+ {@render children?.()}
10
+ </td>
@@ -0,0 +1,10 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../../../utils/cn.js';
4
+
5
+ let { children, class: className, ...rest }: { children?: Snippet; class?: string; [key: string]: unknown } = $props();
6
+ </script>
7
+
8
+ <th class={cn("h-9 px-4 text-left align-middle text-[10px] font-semibold uppercase tracking-wide text-muted-foreground [&:has([role=checkbox])]:pr-0", className)} {...rest}>
9
+ {@render children?.()}
10
+ </th>
@@ -0,0 +1,10 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../../../utils/cn.js';
4
+
5
+ let { children, class: className }: { children: Snippet; class?: string } = $props();
6
+ </script>
7
+
8
+ <thead class={cn("[&_tr]:border-b [&_tr]:border-border", className)}>
9
+ {@render children()}
10
+ </thead>
@@ -0,0 +1,10 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../../../utils/cn.js';
4
+
5
+ let { children, class: className }: { children: Snippet; class?: string } = $props();
6
+ </script>
7
+
8
+ <tr class={cn("border-b border-border transition-colors hover:bg-accent/50 data-[state=selected]:bg-accent", className)}>
9
+ {@render children()}
10
+ </tr>
@@ -0,0 +1,7 @@
1
+ export { default as Table } from './Table.svelte';
2
+ export { default as TableHeader } from './TableHeader.svelte';
3
+ export { default as TableBody } from './TableBody.svelte';
4
+ export { default as TableRow } from './TableRow.svelte';
5
+ export { default as TableHead } from './TableHead.svelte';
6
+ export { default as TableCell } from './TableCell.svelte';
7
+ export { default as TableCaption } from './TableCaption.svelte';
package/src/lib/index.ts CHANGED
@@ -376,3 +376,53 @@ export {
376
376
  // ColorInput — styled native <input type="color"> wrapper matching the
377
377
  // form-control border/ring/focus tokens. Optional `showValue` hex readout.
378
378
  export { default as ColorInput } from './components/ColorInput.svelte';
379
+
380
+ // ---- 0.11.0 additions (restored components) ----
381
+ //
382
+ // These components were part of the export surface in earlier releases but
383
+ // were dropped from the 0.10.0 barrel. The nucel app still imports them, so a
384
+ // clean `bun install --frozen-lockfile` against 0.10.0 broke the build. They
385
+ // are restored here against the package's shared-component conventions.
386
+
387
+ // Alert — inline notice banner (info/success/warning/error variants).
388
+ export { default as Alert } from './components/ui/Alert.svelte';
389
+
390
+ // Section + SectionTitle — page section wrapper + heading.
391
+ export { default as Section } from './components/ui/Section.svelte';
392
+ export { default as SectionTitle } from './components/ui/SectionTitle.svelte';
393
+
394
+ // PageHeader — page title + actions header row.
395
+ export { default as PageHeader } from './components/ui/PageHeader.svelte';
396
+
397
+ // ListCard — bordered list-item card row.
398
+ export { default as ListCard } from './components/ui/ListCard.svelte';
399
+
400
+ // Pills — compact status/branch/comment chips.
401
+ export { default as StatusPill } from './components/ui/StatusPill.svelte';
402
+ export { default as BranchPill } from './components/ui/BranchPill.svelte';
403
+ export { default as CommentPill } from './components/ui/CommentPill.svelte';
404
+
405
+ // PermissionChips — permission/scope chip group.
406
+ export { default as PermissionChips } from './components/ui/PermissionChips.svelte';
407
+
408
+ // AppCard — app/repo summary card.
409
+ export { default as AppCard } from './components/ui/AppCard.svelte';
410
+
411
+ // Kanban — board / column / card composites.
412
+ export { default as KanbanBoard } from './components/ui/KanbanBoard.svelte';
413
+ export { default as KanbanColumn } from './components/ui/KanbanColumn.svelte';
414
+ export { default as KanbanCard } from './components/ui/KanbanCard.svelte';
415
+
416
+ // RichEditor — Tiptap-powered rich text editor (wiki/issues/PR comments).
417
+ export { default as RichEditor } from './components/ui/editor/RichEditor.svelte';
418
+
419
+ // Table — shadcn-styled table primitives.
420
+ export {
421
+ Table,
422
+ TableHeader,
423
+ TableBody,
424
+ TableRow,
425
+ TableHead,
426
+ TableCell,
427
+ TableCaption,
428
+ } from './components/ui/table/index.js';
@@ -1,126 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { render } from '@testing-library/svelte';
3
- import { tick } from 'svelte';
4
- import ColorInput from './ColorInput.svelte';
5
-
6
- /** Grab the native color input from a rendered ColorInput. */
7
- function getColorInput(container: HTMLElement): HTMLInputElement {
8
- return container.querySelector('input[type="color"]') as HTMLInputElement;
9
- }
10
-
11
- describe('ColorInput', () => {
12
- it('renders a native <input type="color"> inside the color-input slot', () => {
13
- const { container } = render(ColorInput, { props: { value: '#3b82f6' } });
14
- const wrapper = container.querySelector('[data-slot="color-input"]');
15
- expect(wrapper).toBeInTheDocument();
16
- const input = getColorInput(container);
17
- expect(input).toBeInTheDocument();
18
- expect(input.type).toBe('color');
19
- });
20
-
21
- it('reflects the initial value on the input', () => {
22
- const { container } = render(ColorInput, { props: { value: '#22c55e' } });
23
- expect(getColorInput(container).value).toBe('#22c55e');
24
- });
25
-
26
- it('defaults to #000000 when no value is supplied', () => {
27
- const { container } = render(ColorInput, { props: {} });
28
- expect(getColorInput(container).value).toBe('#000000');
29
- });
30
-
31
- it('does not show the hex value by default', () => {
32
- const { container } = render(ColorInput, { props: { value: '#abcdef' } });
33
- const wrapper = container.querySelector('[data-slot="color-input"]')!;
34
- // Only the <input> child, no trailing text span.
35
- expect(wrapper.querySelectorAll('span').length).toBe(0);
36
- });
37
-
38
- it('shows the hex value (uppercased via class) when showValue is set', () => {
39
- const { container } = render(ColorInput, {
40
- props: { value: '#abcdef', showValue: true },
41
- });
42
- const wrapper = container.querySelector('[data-slot="color-input"]')!;
43
- const valueSpan = wrapper.querySelector('span');
44
- expect(valueSpan).toBeInTheDocument();
45
- expect(valueSpan).toHaveTextContent('#abcdef');
46
- expect(valueSpan?.className).toContain('uppercase');
47
- expect(valueSpan?.className).toContain('font-mono');
48
- });
49
-
50
- it('updates the displayed hex value when the input changes', async () => {
51
- const { container } = render(ColorInput, {
52
- props: { value: '#000000', showValue: true },
53
- });
54
- const input = getColorInput(container);
55
-
56
- input.value = '#ff8800';
57
- input.dispatchEvent(new Event('input', { bubbles: true }));
58
- await tick();
59
-
60
- const valueSpan = container.querySelector('[data-slot="color-input"] span');
61
- expect(valueSpan).toHaveTextContent('#ff8800');
62
- });
63
-
64
- it('propagates an input change through the two-way bound value', async () => {
65
- // `bind:value` drives the `showValue` span; if the bound value updates
66
- // on input, the rendered hex text reflects the new colour.
67
- const { container } = render(ColorInput, {
68
- props: { value: '#111111', showValue: true },
69
- });
70
- const input = getColorInput(container);
71
-
72
- input.value = '#00ff00';
73
- input.dispatchEvent(new Event('input', { bubbles: true }));
74
- await tick();
75
-
76
- expect(input.value).toBe('#00ff00');
77
- expect(container.querySelector('[data-slot="color-input"] span')).toHaveTextContent(
78
- '#00ff00',
79
- );
80
- });
81
-
82
- it('emits a change event consumers can listen to', async () => {
83
- const onchange = vi.fn();
84
- const { container } = render(ColorInput, {
85
- props: { value: '#000000', onchange },
86
- });
87
- const input = getColorInput(container);
88
-
89
- input.value = '#123456';
90
- input.dispatchEvent(new Event('change', { bubbles: true }));
91
- await tick();
92
-
93
- expect(onchange).toHaveBeenCalledOnce();
94
- });
95
-
96
- it('forwards the disabled attribute to the native input', () => {
97
- const { container } = render(ColorInput, {
98
- props: { value: '#ef4444', disabled: true },
99
- });
100
- expect(getColorInput(container)).toBeDisabled();
101
- });
102
-
103
- it('merges a custom class onto the input', () => {
104
- const { container } = render(ColorInput, {
105
- props: { value: '#000000', class: 'my-swatch' },
106
- });
107
- const input = getColorInput(container);
108
- expect(input.className).toContain('my-swatch');
109
- // Base styling is preserved.
110
- expect(input.className).toContain('rounded-md');
111
- });
112
-
113
- it('honours a custom data-slot', () => {
114
- const { container } = render(ColorInput, {
115
- props: { value: '#000000', 'data-slot': 'theme-color' },
116
- });
117
- expect(container.querySelector('[data-slot="theme-color"]')).toBeInTheDocument();
118
- });
119
-
120
- it('forwards arbitrary props (e.g. aria-label) to the input', () => {
121
- const { getByLabelText } = render(ColorInput, {
122
- props: { value: '#000000', 'aria-label': 'Pick a label colour' },
123
- });
124
- expect(getByLabelText('Pick a label colour')).toBeInTheDocument();
125
- });
126
- });
@@ -1,213 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { render } from '@testing-library/svelte';
3
- import { tick } from 'svelte';
4
- import CopyButton from './CopyButton.svelte';
5
-
6
- /**
7
- * Install a mock `navigator.clipboard.writeText`. jsdom does not provide a
8
- * working clipboard, so we define it ourselves. Returns the spy.
9
- */
10
- function mockClipboard(impl?: (text: string) => Promise<void>) {
11
- const writeText = vi.fn(impl ?? (() => Promise.resolve()));
12
- Object.defineProperty(navigator, 'clipboard', {
13
- value: { writeText },
14
- configurable: true,
15
- writable: true,
16
- });
17
- return writeText;
18
- }
19
-
20
- describe('CopyButton', () => {
21
- beforeEach(() => {
22
- vi.useFakeTimers();
23
- });
24
-
25
- afterEach(() => {
26
- vi.runOnlyPendingTimers();
27
- vi.useRealTimers();
28
- vi.restoreAllMocks();
29
- });
30
-
31
- it('writes the value to the clipboard on click', async () => {
32
- const writeText = mockClipboard();
33
- const { getByRole } = render(CopyButton, { props: { value: 'hello-world' } });
34
-
35
- await getByRole('button').click();
36
- await tick();
37
-
38
- expect(writeText).toHaveBeenCalledOnce();
39
- expect(writeText).toHaveBeenCalledWith('hello-world');
40
- });
41
-
42
- it('shows the copied state after a successful copy and reverts after timeout', async () => {
43
- mockClipboard();
44
- const { getByRole } = render(CopyButton, {
45
- props: { value: 'x', label: 'Copy', copiedLabel: 'Copied', timeout: 2000 },
46
- });
47
- const btn = getByRole('button');
48
-
49
- expect(btn).toHaveTextContent('Copy');
50
- expect(btn).not.toHaveAttribute('data-copied');
51
-
52
- await btn.click();
53
- await tick();
54
-
55
- expect(btn).toHaveTextContent('Copied');
56
- expect(btn).toHaveAttribute('data-copied');
57
-
58
- // Advance past the timeout — state reverts.
59
- vi.advanceTimersByTime(2000);
60
- await tick();
61
-
62
- expect(btn).toHaveTextContent('Copy');
63
- expect(btn).not.toHaveAttribute('data-copied');
64
- });
65
-
66
- it('respects a custom timeout before reverting', async () => {
67
- mockClipboard();
68
- const { getByRole } = render(CopyButton, {
69
- props: { value: 'x', label: 'Copy', copiedLabel: 'Copied', timeout: 5000 },
70
- });
71
- const btn = getByRole('button');
72
-
73
- await btn.click();
74
- await tick();
75
- expect(btn).toHaveTextContent('Copied');
76
-
77
- // Not yet elapsed at 2s.
78
- vi.advanceTimersByTime(2000);
79
- await tick();
80
- expect(btn).toHaveTextContent('Copied');
81
-
82
- // Elapsed at 5s total.
83
- vi.advanceTimersByTime(3000);
84
- await tick();
85
- expect(btn).toHaveTextContent('Copy');
86
- });
87
-
88
- it('renders a visible label by default', () => {
89
- mockClipboard();
90
- const { getByRole } = render(CopyButton, { props: { value: 'x' } });
91
- expect(getByRole('button')).toHaveTextContent('Copy');
92
- });
93
-
94
- it('renders icon-only (no text) when label is false, exposing an aria-label', () => {
95
- mockClipboard();
96
- const { getByRole } = render(CopyButton, { props: { value: 'x', label: false } });
97
- const btn = getByRole('button');
98
- // No visible "Copy" text node; accessible name comes from aria-label.
99
- expect(btn).toHaveAttribute('aria-label', 'Copy');
100
- expect(btn.querySelector('span')).toBeNull();
101
- });
102
-
103
- it('updates the icon-only aria-label to the copied label after copying', async () => {
104
- mockClipboard();
105
- const { getByRole } = render(CopyButton, {
106
- props: { value: 'x', label: false, copiedLabel: 'Copied' },
107
- });
108
- const btn = getByRole('button');
109
-
110
- await btn.click();
111
- await tick();
112
-
113
- expect(btn).toHaveAttribute('aria-label', 'Copied');
114
- });
115
-
116
- it('fires the onCopy callback with the value after a successful copy', async () => {
117
- mockClipboard();
118
- const onCopy = vi.fn();
119
- const { getByRole } = render(CopyButton, { props: { value: 'token-123', onCopy } });
120
-
121
- await getByRole('button').click();
122
- await tick();
123
-
124
- expect(onCopy).toHaveBeenCalledOnce();
125
- expect(onCopy).toHaveBeenCalledWith('token-123');
126
- });
127
-
128
- it('fires onError and does NOT enter copied state when the write rejects', async () => {
129
- const boom = new Error('permission denied');
130
- mockClipboard(() => Promise.reject(boom));
131
- const onError = vi.fn();
132
- const onCopy = vi.fn();
133
- const { getByRole } = render(CopyButton, {
134
- props: { value: 'x', onError, onCopy },
135
- });
136
- const btn = getByRole('button');
137
-
138
- await btn.click();
139
- await tick();
140
-
141
- expect(onError).toHaveBeenCalledOnce();
142
- expect(onError).toHaveBeenCalledWith(boom);
143
- expect(onCopy).not.toHaveBeenCalled();
144
- expect(btn).not.toHaveAttribute('data-copied');
145
- expect(btn).toHaveTextContent('Copy');
146
- });
147
-
148
- it('hides the icon when hideIcon is set', () => {
149
- mockClipboard();
150
- const { getByRole } = render(CopyButton, {
151
- props: { value: 'x', hideIcon: true },
152
- });
153
- const btn = getByRole('button');
154
- expect(btn.querySelector('svg')).toBeNull();
155
- expect(btn).toHaveTextContent('Copy');
156
- });
157
-
158
- it('renders custom label and copiedLabel text', async () => {
159
- mockClipboard();
160
- const { getByRole } = render(CopyButton, {
161
- props: { value: 'x', label: 'Copy URL', copiedLabel: 'Copied URL!' },
162
- });
163
- const btn = getByRole('button');
164
- expect(btn).toHaveTextContent('Copy URL');
165
-
166
- await btn.click();
167
- await tick();
168
- expect(btn).toHaveTextContent('Copied URL!');
169
- });
170
-
171
- it('does not copy or change state when disabled', async () => {
172
- const writeText = mockClipboard();
173
- const { getByRole } = render(CopyButton, {
174
- props: { value: 'x', disabled: true },
175
- });
176
- const btn = getByRole('button');
177
- expect(btn).toBeDisabled();
178
-
179
- await btn.click();
180
- await tick();
181
-
182
- expect(writeText).not.toHaveBeenCalled();
183
- expect(btn).not.toHaveAttribute('data-copied');
184
- });
185
-
186
- it('restarts the timer on a second click (debounced revert)', async () => {
187
- mockClipboard();
188
- const { getByRole } = render(CopyButton, {
189
- props: { value: 'x', label: 'Copy', copiedLabel: 'Copied', timeout: 2000 },
190
- });
191
- const btn = getByRole('button');
192
-
193
- await btn.click();
194
- await tick();
195
- expect(btn).toHaveTextContent('Copied');
196
-
197
- // 1.5s in, click again — timer should reset.
198
- vi.advanceTimersByTime(1500);
199
- await btn.click();
200
- await tick();
201
- expect(btn).toHaveTextContent('Copied');
202
-
203
- // 1s more (2.5s since first click, 1s since second) — still copied.
204
- vi.advanceTimersByTime(1000);
205
- await tick();
206
- expect(btn).toHaveTextContent('Copied');
207
-
208
- // Reach 2s since the second click — now reverts.
209
- vi.advanceTimersByTime(1000);
210
- await tick();
211
- expect(btn).toHaveTextContent('Copy');
212
- });
213
- });