Skip to content

Commit 9f12015

Browse files
committed
feat(xref): copy a citation to the clipboard when clicked
Each citation in the How to Cite column is its own button; clicking or tapping it copies just that citation, with a toast confirmation. Citation buttons carry an aria-label (Copy citation …) for screen readers and keep the table cell's native role.
1 parent 7bbcf4e commit 9f12015

4 files changed

Lines changed: 208 additions & 48 deletions

File tree

static/xref/script.js

Lines changed: 81 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -156,20 +156,22 @@ function renderResults(entries, query) {
156156
const specInfo = metadata.specs[entry.status][entry.spec];
157157
const link = new URL(entry.uri, specInfo.url).href;
158158
const title = escapeHTML(specInfo.title);
159-
const cite = metadata.types.idl.has(entry.type)
159+
const cites = metadata.types.idl.has(entry.type)
160160
? howToCiteIDL(citeTerm, entry, overloadedPairs)
161161
: metadata.types.markup.has(entry.type)
162162
? howToCiteMarkup(citeTerm, entry)
163163
: metadata.types.css.has(entry.type) ||
164164
metadata.types.http.has(entry.type)
165165
? howToCiteAnchor(citeTerm, entry)
166166
: howToCiteTerm(citeTerm, entry);
167+
// Each citation is its own button: click/tap the citation to copy just it.
168+
const citeCell = cites.map(citeButton).join('<br>');
167169
let row = `
168170
<tr>
169171
<td><a href="${link}">${title}</a></td>
170172
<td>${entry.shortname}</td>
171173
<td>${entry.type}</td>
172-
<td>${cite}</td>
174+
<td>${citeCell}</td>
173175
</tr>`;
174176
html += row;
175177
}
@@ -221,19 +223,17 @@ function howToCiteIDL(term, entry, overloadedPairs = null) {
221223
const { type, for: forList } = entry;
222224
const safeTerm = escapeHTML(term);
223225
if (forList) {
224-
return forList
225-
.map(f => {
226-
const safeF = escapeHTML(f);
227-
let displayTerm = type === 'enum-value' ? `"${safeTerm}"` : safeTerm;
228-
if (overloadedPairs?.has(`${entry.uri}|${f}`)) {
229-
const hint = extractOverloadHint(entry.uri, f, term);
230-
if (hint) {
231-
displayTerm = displayTerm.replace('()', `(${escapeHTML(hint)})`);
232-
}
226+
return forList.map(f => {
227+
const safeF = escapeHTML(f);
228+
let displayTerm = type === 'enum-value' ? `"${safeTerm}"` : safeTerm;
229+
if (overloadedPairs?.has(`${entry.uri}|${f}`)) {
230+
const hint = extractOverloadHint(entry.uri, f, term);
231+
if (hint) {
232+
displayTerm = displayTerm.replace('()', `(${escapeHTML(hint)})`);
233233
}
234-
return `{{${safeF}/${displayTerm ? displayTerm : '""'}}}`;
235-
})
236-
.join('<br>');
234+
}
235+
return `{{${safeF}/${displayTerm ? displayTerm : '""'}}}`;
236+
});
237237
}
238238
let cite;
239239
switch (type) {
@@ -251,7 +251,7 @@ function howToCiteIDL(term, entry, overloadedPairs = null) {
251251
cite = cite.replace('()', `(${escapeHTML(hint)})`);
252252
}
253253
}
254-
return cite;
254+
return [cite];
255255
}
256256

257257
/**
@@ -297,45 +297,96 @@ function extractOverloadHint(uri, forContext, term) {
297297
}
298298

299299
function howToCiteMarkup(term, entry) {
300-
const { type, for: forList, shortname } = entry;
300+
const { type, for: forList } = entry;
301301
const safeTerm = escapeHTML(term);
302302
if (forList) {
303-
return forList.map(f => `[^${escapeHTML(f)}/${safeTerm}^]`).join('<br>');
303+
return forList.map(f => `[^${escapeHTML(f)}/${safeTerm}^]`);
304304
}
305305
if (type === 'element-attr') {
306-
return `[^/${safeTerm}^]`;
306+
return [`[^/${safeTerm}^]`];
307307
}
308-
return `[^${safeTerm}^]`;
308+
return [`[^${safeTerm}^]`];
309309
}
310310

311311
function howToCiteAnchor(term, entry) {
312312
const { type, for: forList } = entry;
313313
term = escapeHTML(term);
314314
if (!forList) {
315-
return escapeHTML(`<a data-xref-type="${type}">${term}</a>`);
315+
return [escapeHTML(`<a data-xref-type="${type}">${term}</a>`)];
316316
}
317-
return forList
318-
.map(f =>
319-
escapeHTML(
320-
`<a data-xref-type="${type}" data-xref-for="${f}">${term}</a>`,
321-
),
322-
)
323-
.join('<br>');
317+
return forList.map(f =>
318+
escapeHTML(`<a data-xref-type="${type}" data-xref-for="${f}">${term}</a>`),
319+
);
324320
}
325321

326322
function howToCiteTerm(term, entry) {
327-
const { type, for: forList, shortname } = entry;
323+
const { type, for: forList } = entry;
328324
term = escapeHTML(term.replace('/', '\\/'));
329325
if (forList) {
330-
return forList.map(f => `[=${escapeHTML(f)}/${term}=]`).join('<br>');
326+
return forList.map(f => `[=${escapeHTML(f)}/${term}=]`);
331327
}
332-
return `[=${term}=]`;
328+
return [`[=${term}=]`];
333329
}
334330

335331
function escapeHTML(str) {
336332
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
337333
}
338334

335+
// Render one citation as its own copy button. The accessible name conveys the
336+
// action + value; `cite` is already escaped for &<>, but an attribute value
337+
// also needs " escaped (e.g. exception citations like {{"DOMException"}}).
338+
function citeButton(cite) {
339+
const label = `Copy citation ${cite.replace(/"/g, '&quot;')}`;
340+
return `<button type="button" class="cite" aria-label="${label}">${cite}</button>`;
341+
}
342+
343+
// A single reused live region. Reusing one element (rather than appending a
344+
// new toast per click) avoids stacking overlapping toasts and competing
345+
// announcements when copying several citations in quick succession.
346+
let copyToast;
347+
let copyToastTimer;
348+
function showCopyToast(message) {
349+
if (!copyToast) {
350+
copyToast = document.createElement('div');
351+
copyToast.className = 'copy-toast';
352+
copyToast.setAttribute('role', 'status');
353+
document.body.appendChild(copyToast);
354+
}
355+
clearTimeout(copyToastTimer);
356+
// Set the text on the next frame so the (already-attached) live region
357+
// announces it as a change rather than as initial content.
358+
requestAnimationFrame(() => {
359+
copyToast.textContent = message;
360+
copyToast.classList.add('is-visible');
361+
});
362+
copyToastTimer = setTimeout(() => {
363+
copyToast.classList.remove('is-visible');
364+
}, 1500);
365+
}
366+
367+
async function copyCitation(text) {
368+
if (!text) return;
369+
if (!navigator.clipboard) {
370+
showCopyToast('Clipboard unavailable');
371+
return;
372+
}
373+
try {
374+
await navigator.clipboard.writeText(text);
375+
showCopyToast(`Copied: ${text}`);
376+
} catch (err) {
377+
console.error(err);
378+
showCopyToast('Copy failed');
379+
}
380+
}
381+
382+
// Click-to-copy: each citation is itself a <button>, so clicking/tapping the
383+
// citation copies just that one. Being a real button, keyboard activation
384+
// (Enter/Space) dispatches a click for free.
385+
output.addEventListener('click', e => {
386+
const cite = e.target.closest('.cite');
387+
if (cite) copyCitation(cite.textContent);
388+
});
389+
339390
async function ready() {
340391
const updateInput = (el, values) => {
341392
el.setAttribute('placeholder', values.slice(0, 5).join(','));

static/xref/style.css

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,55 @@ table th {
126126
text-align: left;
127127
}
128128

129+
.cite {
130+
font: inherit;
131+
color: inherit;
132+
background: none;
133+
border: 0;
134+
padding: 0.1rem 0.25rem;
135+
margin: -0.1rem -0.25rem;
136+
border-radius: 4px;
137+
text-align: left;
138+
cursor: pointer;
139+
}
140+
141+
.cite:hover {
142+
background: #dbeafe;
143+
}
144+
145+
.cite:focus-visible {
146+
outline: 2px solid #005a9c;
147+
outline-offset: 1px;
148+
}
149+
150+
.copy-toast {
151+
position: fixed;
152+
bottom: 1.5rem;
153+
left: 50%;
154+
transform: translateX(-50%);
155+
max-width: 90vw;
156+
white-space: pre-line;
157+
background: #1e293b;
158+
color: white;
159+
padding: 0.5rem 1.2rem;
160+
border-radius: 8px;
161+
font-size: 0.85rem;
162+
z-index: 9999;
163+
opacity: 0;
164+
transition: opacity 0.2s;
165+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
166+
}
167+
168+
.copy-toast.is-visible {
169+
opacity: 1;
170+
}
171+
172+
@media (prefers-reduced-motion: reduce) {
173+
.copy-toast {
174+
transition: none;
175+
}
176+
}
177+
129178
.autocomplete {
130179
background: white;
131180
z-index: 1000;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { readFileSync } from "node:fs";
2+
import { fileURLToPath } from "node:url";
3+
import vm from "node:vm";
4+
5+
// static/xref/script.js is an ES module that does top-level DOM work and a
6+
// metadata fetch on load, so it can't be imported here. Slice out the pure
7+
// citeButton helper and evaluate it in isolation to test the citation-button
8+
// markup (the part that does not need a real browser).
9+
const SRC = fileURLToPath(
10+
new URL("../../../static/xref/script.js", import.meta.url),
11+
);
12+
const text = readFileSync(SRC, "utf8");
13+
const fnBlock = text.slice(
14+
text.indexOf("function citeButton"),
15+
text.indexOf("// A single reused live region"),
16+
);
17+
// Fail loudly if the helper is reordered/renamed so the slice no longer
18+
// captures citeButton, rather than silently testing the wrong thing.
19+
if (!fnBlock.includes("aria-label")) {
20+
throw new Error("could not slice citeButton out of script.js");
21+
}
22+
23+
const sandbox = {};
24+
vm.createContext(sandbox);
25+
vm.runInContext(`${fnBlock}\nthis.citeButton = citeButton;`, sandbox);
26+
const { citeButton } = sandbox;
27+
28+
describe("xref/script - citeButton", () => {
29+
it("wraps a citation in a real button so the table cell keeps its role", () => {
30+
expect(citeButton("{{Window/postMessage}}")).toBe(
31+
'<button type="button" class="cite" ' +
32+
'aria-label="Copy citation {{Window/postMessage}}">' +
33+
"{{Window/postMessage}}</button>",
34+
);
35+
});
36+
37+
it("escapes double quotes in the aria-label so the attribute stays well-formed", () => {
38+
// Exception citations like {{"DOMException"}} contain literal quotes that
39+
// would otherwise break out of the aria-label attribute.
40+
const html = citeButton('{{"DOMException"}}');
41+
expect(html).toContain('aria-label="Copy citation {{&quot;DOMException&quot;}}"');
42+
// The visible button text keeps the real quotes (escapeHTML leaves " alone).
43+
expect(html).toContain('>{{"DOMException"}}</button>');
44+
});
45+
46+
it("leaves already-escaped &<> entities intact in the label", () => {
47+
// `cite` reaches citeButton already escaped for & < > by escapeHTML, so the
48+
// helper must not double-escape them.
49+
const html = citeButton("[=a&amp;b/x=]");
50+
expect(html).toContain('aria-label="Copy citation [=a&amp;b/x=]"');
51+
});
52+
});

tests/static/xref/script.test.js

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ const SRC = fileURLToPath(
1111
);
1212
const text = readFileSync(SRC, "utf8");
1313

14+
// Stop before citeButton so the slice excludes the clipboard code (which
15+
// references the `output` DOM global and would throw in this context).
1416
const fnBlock = text.slice(
1517
text.indexOf("function howToCiteIDL"),
16-
text.indexOf("async function ready"),
18+
text.indexOf("function citeButton"),
1719
);
1820
const excStart = text.indexOf("const exceptionExceptions");
1921
const excBlock = text.slice(excStart, text.indexOf("]);", excStart) + 3);
@@ -33,45 +35,51 @@ const XSS = "<img src=x onerror=alert(1)>";
3335

3436
describe("xref/script - citation HTML escaping", () => {
3537
it("escapes the term and for-context in IDL citations", () => {
36-
expect(howToCiteIDL(XSS, { type: "attribute", for: ["Window"] })).not.toContain(
37-
"<img",
38-
);
3938
expect(
40-
howToCiteIDL("postMessage", { type: "method", for: ["<f>"] }),
39+
howToCiteIDL(XSS, { type: "attribute", for: ["Window"] }).join(""),
40+
).not.toContain("<img");
41+
expect(
42+
howToCiteIDL("postMessage", { type: "method", for: ["<f>"] }).join(""),
4143
).toContain("&lt;f&gt;");
42-
expect(howToCiteIDL(XSS, { type: "interface" })).not.toContain("<img");
44+
expect(howToCiteIDL(XSS, { type: "interface" }).join("")).not.toContain(
45+
"<img",
46+
);
4347
});
4448

4549
it("escapes the term and for-context in markup citations", () => {
4650
expect(
47-
howToCiteMarkup(XSS, { type: "element", for: ["<f>"] }),
51+
howToCiteMarkup(XSS, { type: "element", for: ["<f>"] }).join(""),
52+
).not.toContain("<img");
53+
expect(
54+
howToCiteMarkup(XSS, { type: "element-attr" }).join(""),
4855
).not.toContain("<img");
49-
expect(howToCiteMarkup(XSS, { type: "element-attr" })).not.toContain("<img");
50-
expect(howToCiteMarkup(XSS, { type: "element" })).not.toContain("<img");
56+
expect(howToCiteMarkup(XSS, { type: "element" }).join("")).not.toContain(
57+
"<img",
58+
);
5159
});
5260

5361
it("escapes the term and for-context in dfn-term citations", () => {
54-
expect(howToCiteTerm(XSS, { type: "dfn" })).not.toContain("<img");
55-
expect(howToCiteTerm("x", { type: "dfn", for: ["<f>"] })).toContain(
56-
"&lt;f&gt;",
57-
);
62+
expect(howToCiteTerm(XSS, { type: "dfn" }).join("")).not.toContain("<img");
63+
expect(
64+
howToCiteTerm("x", { type: "dfn", for: ["<f>"] }).join(""),
65+
).toContain("&lt;f&gt;");
5866
});
5967

6068
it("leaves ordinary citations unchanged", () => {
6169
expect(
6270
howToCiteIDL("postMessage", { type: "method", for: ["Window"] }),
63-
).toBe("{{Window/postMessage}}");
71+
).toEqual(["{{Window/postMessage}}"]);
6472
expect(
6573
howToCiteIDL("classic", { type: "enum-value", for: ["WorkerType"] }),
66-
).toBe('{{WorkerType/"classic"}}');
67-
expect(howToCiteTerm("a/b", { type: "dfn" })).toBe("[=a\\/b=]");
74+
).toEqual(['{{WorkerType/"classic"}}']);
75+
expect(howToCiteTerm("a/b", { type: "dfn" })).toEqual(["[=a\\/b=]"]);
6876
});
6977

7078
it("renders the empty-term fallback (the touched truthiness branch)", () => {
7179
// `escapeHTML("")` is still falsy, so the `safeTerm ? termPart : '""'`
7280
// branch must keep producing the empty-string placeholder.
73-
expect(howToCiteIDL("", { type: "method", for: ["Window"] })).toBe(
81+
expect(howToCiteIDL("", { type: "method", for: ["Window"] })).toEqual([
7482
'{{Window/""}}',
75-
);
83+
]);
7684
});
7785
});

0 commit comments

Comments
 (0)