feat: add copy code btn (#1841)

* test: copy code btn

* add copy btn svg

* add copy code btn

* fix: keyboard a11y and improve design

* show "copied !" text on the copy btn

* remove space in copied text

* Remove text shift

* handle failed copy code

* Minimize delay

* remove outline on code blocks

* refactor copycode.js

* Remove border width

Co-authored-by: Sebastian Beltran <bjohansebas@gmail.com>

* Convert timerId into a Number()

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Carlos Stenzel <carlosstenzel@hotmail.com>
Co-authored-by: Sebastian Beltran <bjohansebas@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
shubham oulkar
2025-04-26 06:21:12 +05:30
committed by GitHub
parent 021c4d69bb
commit f24f45a281
4 changed files with 178 additions and 1 deletions

View File

@@ -45,6 +45,7 @@
<script data-cfasync="false" src="//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script data-cfasync="false" src="/js/app.js"></script>
<script data-cfasync="false" defer src="/js/menu.js"></script>
<script data-cfasync="false" defer src="/js/copycode.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.css" />
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Express Blog" />

View File

@@ -344,14 +344,132 @@ code {
pre {
padding: 16px;
border-radius: 3px;
border: 1px solid #ddd;
border: 1px solid var(--border);
background-color: var(--code-bg);
/* keyboard focus offset improve visibility */
&:focus {
border-color: var(--hover-border);
}
}
pre code {
padding: 0;
}
pre:has(code) {
position: relative;
&:is(:hover, :focus) {
button {
display: flex;
}
}
/* focus copy btn by keyboard */
&:focus-within button {
display: flex;
}
}
pre:has(code) button {
position: absolute;
top: 5px;
right: 5px;
border: none;
z-index: 100;
display: none;
cursor: pointer;
background-color: inherit;
padding: 2px;
border-radius: 5px;
&::after {
content: "";
background-color: var(--card-fg);
mask-image: url("../images/copy-btn.svg");
mask-size: 1.5rem;
mask-repeat: no-repeat;
width: 1.5rem;
height: 1.5rem;
}
&:is(:hover, :focus) {
background-color: var(--hover-bg);
outline: 2px solid var(--hover-border);
}
@media all and (max-width: 370px) {
padding: 1px;
&::after {
mask-size: 1rem;
width: 1rem;
height: 1rem;
}
}
}
pre:has(code) button.copied {
outline-color: var(--supported-fg);
&::after {
background-color: var(--supported-fg);
}
&::before {
font-size: 0.85rem;
position: absolute;
left: -58px;
content: "copied!";
width: fit-content;
height: fit-content;
padding: 4px;
border-radius: 2px;
color: var(--card-fg);
background-color: var(--card-bg);
outline: 1px solid var(--supported-fg);
}
@media all and (max-width: 400px) {
&::before {
left: -50px;
font-size: 0.7rem;
padding: 3px;
}
}
}
pre:has(code) button.failed {
outline-color: var(--eol-fg);
&::after {
background-color: var(--eol-fg);
}
&::before {
font-size: 0.85rem;
position: absolute;
left: -58px;
content: "failed!";
width: fit-content;
height: fit-content;
padding: 4px;
border-radius: 2px;
color: var(--card-fg);
background-color: var(--card-bg);
outline: 1px solid var(--eol-fg);
}
@media all and (max-width: 400px) {
&::before {
left: -50px;
font-size: 0.7rem;
padding: 3px;
}
}
}
/* top button */
.scroll #top {

1
images/copy-btn.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon" class="CodeBox_icon__pCRVM"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75"></path></svg>

After

Width:  |  Height:  |  Size: 717 B

57
js/copycode.js Normal file
View File

@@ -0,0 +1,57 @@
const codeBlocks = document.querySelectorAll("pre:has(code)");
codeBlocks.forEach((block) => {
// Only add button if browser supports Clipboard API
if (!navigator.clipboard) return;
const button = createCopyButton();
block.appendChild(button);
block.setAttribute("tabindex", 0); // Add keyboard a11y for <pre></pre>
button.addEventListener("click", async () => {
await copyCode(block, button);
});
});
function createCopyButton() {
const button = document.createElement("button");
setButtonAttributes(button, {
type: "button", // button doesn't act as a submit button
title: "copy code",
"aria-label": "click to copy code",
});
return button;
}
function setButtonAttributes(button, attributes) {
for (const [key, value] of Object.entries(attributes)) {
button.setAttribute(key, value);
}
}
async function copyCode(block, button) {
const code = block.querySelector("code");
const text = code.innerText;
try {
await navigator.clipboard.writeText(text);
updateButtonState(button, "copied", "code is copied!");
} catch {
updateButtonState(button, "failed", "failed!");
}
}
function updateButtonState(button, statusClass, ariaLabel) {
button.setAttribute("aria-live", "polite");
button.setAttribute("aria-label", ariaLabel);
button.classList.add(statusClass);
// Clear any existing timer
if (button.dataset.timerId) clearTimeout(Number(button.dataset.timerId));
const timer = setTimeout(() => {
button.classList.remove(statusClass);
button.setAttribute("aria-label", "click to copy code");
}, 1000);
button.dataset.timerId = timer;
}