@netlify/spark-ui 1.27.0-alpha.1 → 1.27.0-alpha.2
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.
- package/dist/components/articleCopyActions.d.ts +9 -0
- package/dist/components/articleCopyActions.test.d.ts +1 -0
- package/package.json +5 -2
- package/packages/components/ArticleCopyActions.astro +4 -26
- package/packages/components/articleCopyActions.test.ts +143 -0
- package/packages/components/articleCopyActions.ts +41 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire up the "Copy URL" / "Copy page" buttons rendered by ArticleCopyActions.astro.
|
|
3
|
+
*
|
|
4
|
+
* This runs on every `astro:page-load` (initial load *and* every client-side
|
|
5
|
+
* navigation via `<ClientRouter />`), so the buttons keep working after routing
|
|
6
|
+
* to another article/post. Each wrapper is guarded so re-initialising the same
|
|
7
|
+
* DOM node never attaches duplicate listeners.
|
|
8
|
+
*/
|
|
9
|
+
export declare function initArticleCopyActions(root?: ParentNode): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netlify/spark-ui",
|
|
3
3
|
"description": "Assets, design tokens, components, and utilities",
|
|
4
|
-
"version": "1.27.0-alpha.
|
|
4
|
+
"version": "1.27.0-alpha.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/components/preact/index.js",
|
|
7
7
|
"types": "dist/components/preact/index.d.ts",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx,.astro --fix",
|
|
16
16
|
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,astro,json,css,md,mdx}\" --ignore-path packages/styles/tokens.css",
|
|
17
17
|
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,astro,json,css,md,mdx}\" --ignore-path packages/styles/tokens.css",
|
|
18
|
+
"test": "vitest run",
|
|
18
19
|
"prepublishOnly": "npm run build:vite"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
@@ -61,6 +62,7 @@
|
|
|
61
62
|
"eslint-config-prettier": "^10.1.1",
|
|
62
63
|
"eslint-plugin-astro": "^1.3.1",
|
|
63
64
|
"glob": "^11.0.1",
|
|
65
|
+
"jsdom": "^30.0.0",
|
|
64
66
|
"preact": "^10.26.4",
|
|
65
67
|
"prettier": "^3.5.3",
|
|
66
68
|
"prettier-plugin-astro": "^0.14.1",
|
|
@@ -70,7 +72,8 @@
|
|
|
70
72
|
"vite": "^6.2.5",
|
|
71
73
|
"vite-plugin-dts": "^4.5.3",
|
|
72
74
|
"vite-plugin-lib-inject-css": "^2.2.1",
|
|
73
|
-
"vite-tsconfig-paths": "^5.1.4"
|
|
75
|
+
"vite-tsconfig-paths": "^5.1.4",
|
|
76
|
+
"vitest": "^4.1.10"
|
|
74
77
|
},
|
|
75
78
|
"peerDependencies": {
|
|
76
79
|
"preact": "^10.26.4"
|
|
@@ -114,31 +114,9 @@ const copyContent = title ? `# ${title}\n\n${content}` : content;
|
|
|
114
114
|
</style>
|
|
115
115
|
|
|
116
116
|
<script>
|
|
117
|
-
|
|
117
|
+
import { initArticleCopyActions } from './articleCopyActions';
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
wrapper.querySelectorAll<HTMLButtonElement>('.copy-actions-action').forEach(btn => {
|
|
124
|
-
const label = btn.querySelector<HTMLElement>('span');
|
|
125
|
-
const originalText = label?.textContent;
|
|
126
|
-
|
|
127
|
-
btn.addEventListener('click', async () => {
|
|
128
|
-
const value =
|
|
129
|
-
btn.getAttribute('data-copy') === 'url'
|
|
130
|
-
? btn.getAttribute('data-value')
|
|
131
|
-
: source?.textContent;
|
|
132
|
-
|
|
133
|
-
if (!value || !label) return;
|
|
134
|
-
|
|
135
|
-
await navigator.clipboard.writeText(value);
|
|
136
|
-
|
|
137
|
-
label.textContent = copiedLabel;
|
|
138
|
-
setTimeout(() => {
|
|
139
|
-
label.textContent = originalText ?? '';
|
|
140
|
-
}, COPIED_RESET_DELAY);
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
});
|
|
119
|
+
// `astro:page-load` fires on the initial load *and* after every client-side
|
|
120
|
+
// navigation (`<ClientRouter />`).
|
|
121
|
+
document.addEventListener('astro:page-load', () => initArticleCopyActions());
|
|
144
122
|
</script>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { initArticleCopyActions } from './articleCopyActions';
|
|
4
|
+
|
|
5
|
+
const ARTICLE_URL = 'https://www.netlify.com/blog/some-post/';
|
|
6
|
+
const MARKDOWN = '# Some post\n\nBody content.';
|
|
7
|
+
|
|
8
|
+
function renderCopyActions({ url = ARTICLE_URL, markdown = MARKDOWN, copiedLabel = 'Copied!' } = {}) {
|
|
9
|
+
document.body.innerHTML = `
|
|
10
|
+
<div class="copy-actions" data-copied-label="${copiedLabel}">
|
|
11
|
+
<button
|
|
12
|
+
type="button"
|
|
13
|
+
class="copy-actions-action copy-actions-copy-url"
|
|
14
|
+
data-copy="url"
|
|
15
|
+
data-value="${url}"
|
|
16
|
+
>
|
|
17
|
+
<span>Copy URL</span>
|
|
18
|
+
</button>
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
class="copy-actions-action copy-actions-copy-content"
|
|
22
|
+
data-copy="page"
|
|
23
|
+
>
|
|
24
|
+
<span>Copy page</span>
|
|
25
|
+
</button>
|
|
26
|
+
</div>
|
|
27
|
+
<script type="text/plain" data-copy-actions-source>${markdown}</script>
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
wrapper: document.querySelector<HTMLElement>('.copy-actions')!,
|
|
32
|
+
copyUrlBtn: document.querySelector<HTMLButtonElement>('.copy-actions-copy-url')!,
|
|
33
|
+
copyPageBtn: document.querySelector<HTMLButtonElement>('.copy-actions-copy-content')!,
|
|
34
|
+
urlLabel: document.querySelector<HTMLElement>('.copy-actions-copy-url span')!,
|
|
35
|
+
pageLabel: document.querySelector<HTMLElement>('.copy-actions-copy-content span')!,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('initArticleCopyActions', () => {
|
|
40
|
+
let writeText: ReturnType<typeof vi.fn>;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.useFakeTimers();
|
|
44
|
+
writeText = vi.fn().mockResolvedValue(undefined);
|
|
45
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
46
|
+
value: { writeText },
|
|
47
|
+
configurable: true,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
vi.runOnlyPendingTimers();
|
|
53
|
+
vi.useRealTimers();
|
|
54
|
+
document.body.innerHTML = '';
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('copies the article URL and shows a temporary confirmation', async () => {
|
|
58
|
+
const { copyUrlBtn, urlLabel } = renderCopyActions();
|
|
59
|
+
initArticleCopyActions();
|
|
60
|
+
|
|
61
|
+
copyUrlBtn.click();
|
|
62
|
+
await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith(ARTICLE_URL));
|
|
63
|
+
expect(urlLabel.textContent).toBe('Copied!');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('copies the markdown source for the "Copy page" button', async () => {
|
|
67
|
+
const { copyPageBtn, pageLabel } = renderCopyActions();
|
|
68
|
+
initArticleCopyActions();
|
|
69
|
+
|
|
70
|
+
copyPageBtn.click();
|
|
71
|
+
await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith(MARKDOWN));
|
|
72
|
+
expect(pageLabel.textContent).toBe('Copied!');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('resets the label back to its original text after the delay', async () => {
|
|
76
|
+
const { copyUrlBtn, urlLabel } = renderCopyActions();
|
|
77
|
+
initArticleCopyActions();
|
|
78
|
+
|
|
79
|
+
copyUrlBtn.click();
|
|
80
|
+
await vi.waitFor(() => expect(urlLabel.textContent).toBe('Copied!'));
|
|
81
|
+
|
|
82
|
+
vi.advanceTimersByTime(1500);
|
|
83
|
+
expect(urlLabel.textContent).toBe('Copy URL');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('uses a custom copied label when data-copied-label is set', async () => {
|
|
87
|
+
const { copyUrlBtn, urlLabel } = renderCopyActions({ copiedLabel: 'Done!' });
|
|
88
|
+
initArticleCopyActions();
|
|
89
|
+
|
|
90
|
+
copyUrlBtn.click();
|
|
91
|
+
await vi.waitFor(() => expect(urlLabel.textContent).toBe('Done!'));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('does not attach duplicate listeners when initialised twice on the same DOM', async () => {
|
|
95
|
+
const { copyUrlBtn } = renderCopyActions();
|
|
96
|
+
initArticleCopyActions();
|
|
97
|
+
initArticleCopyActions(); // e.g. a spurious re-fire
|
|
98
|
+
|
|
99
|
+
copyUrlBtn.click();
|
|
100
|
+
await vi.waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('re-binds after a client-side navigation swaps in fresh buttons', async () => {
|
|
104
|
+
// Simulate the ClientRouter bug: first page loads and works...
|
|
105
|
+
const first = renderCopyActions({ url: 'https://example.com/first/' });
|
|
106
|
+
initArticleCopyActions();
|
|
107
|
+
first.copyUrlBtn.click();
|
|
108
|
+
await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith('https://example.com/first/'));
|
|
109
|
+
|
|
110
|
+
// ...then a navigation replaces the DOM with a new post's buttons.
|
|
111
|
+
const second = renderCopyActions({ url: 'https://example.com/second/' });
|
|
112
|
+
// Re-running on `astro:page-load` is what makes the new buttons work.
|
|
113
|
+
initArticleCopyActions();
|
|
114
|
+
|
|
115
|
+
second.copyUrlBtn.click();
|
|
116
|
+
await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith('https://example.com/second/'));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('scopes the markdown source lookup to each wrapper, not a global selector', async () => {
|
|
120
|
+
document.body.innerHTML = `
|
|
121
|
+
<div class="copy-actions" data-copied-label="Copied!">
|
|
122
|
+
<button type="button" class="copy-actions-action copy-actions-copy-content" data-copy="page">
|
|
123
|
+
<span>Copy page</span>
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
<script type="text/plain" data-copy-actions-source>First post markdown</script>
|
|
127
|
+
<div class="copy-actions" data-copied-label="Copied!">
|
|
128
|
+
<button type="button" class="copy-actions-action copy-actions-copy-content" data-copy="page">
|
|
129
|
+
<span>Copy page</span>
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
<script type="text/plain" data-copy-actions-source>Second post markdown</script>
|
|
133
|
+
`;
|
|
134
|
+
initArticleCopyActions();
|
|
135
|
+
|
|
136
|
+
const buttons = document.querySelectorAll<HTMLButtonElement>('.copy-actions-copy-content');
|
|
137
|
+
buttons[1].click();
|
|
138
|
+
await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith('Second post markdown'));
|
|
139
|
+
|
|
140
|
+
buttons[0].click();
|
|
141
|
+
await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith('First post markdown'));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const COPIED_LABEL_FALLBACK = 'Copied!';
|
|
2
|
+
const RESET_DELAY_MS = 1500;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wire up the "Copy URL" / "Copy page" buttons rendered by ArticleCopyActions.astro.
|
|
6
|
+
*
|
|
7
|
+
* This runs on every `astro:page-load` (initial load *and* every client-side
|
|
8
|
+
* navigation via `<ClientRouter />`), so the buttons keep working after routing
|
|
9
|
+
* to another article/post. Each wrapper is guarded so re-initialising the same
|
|
10
|
+
* DOM node never attaches duplicate listeners.
|
|
11
|
+
*/
|
|
12
|
+
export function initArticleCopyActions(root: ParentNode = document): void {
|
|
13
|
+
root.querySelectorAll<HTMLElement>('.copy-actions').forEach(wrapper => {
|
|
14
|
+
if (wrapper.dataset.copyBound === 'true') return;
|
|
15
|
+
wrapper.dataset.copyBound = 'true';
|
|
16
|
+
|
|
17
|
+
const source = wrapper.nextElementSibling as HTMLElement | null;
|
|
18
|
+
const copiedLabel = wrapper.dataset.copiedLabel ?? COPIED_LABEL_FALLBACK;
|
|
19
|
+
|
|
20
|
+
wrapper.querySelectorAll<HTMLButtonElement>('.copy-actions-action').forEach(btn => {
|
|
21
|
+
const label = btn.querySelector<HTMLElement>('span');
|
|
22
|
+
const originalText = label?.textContent;
|
|
23
|
+
|
|
24
|
+
btn.addEventListener('click', async () => {
|
|
25
|
+
const value =
|
|
26
|
+
btn.getAttribute('data-copy') === 'url'
|
|
27
|
+
? btn.getAttribute('data-value')
|
|
28
|
+
: source?.textContent;
|
|
29
|
+
|
|
30
|
+
if (!value || !label) return;
|
|
31
|
+
|
|
32
|
+
await navigator.clipboard.writeText(value);
|
|
33
|
+
|
|
34
|
+
label.textContent = copiedLabel;
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
label.textContent = originalText ?? '';
|
|
37
|
+
}, RESET_DELAY_MS);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|