diff --git a/_includes/head.html b/_includes/head.html index 4fd72140..e25d5f4b 100644 --- a/_includes/head.html +++ b/_includes/head.html @@ -45,6 +45,7 @@ + diff --git a/css/style.css b/css/style.css index c9c42d0d..46de8e96 100644 --- a/css/style.css +++ b/css/style.css @@ -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 { diff --git a/images/copy-btn.svg b/images/copy-btn.svg new file mode 100644 index 00000000..d44b4a72 --- /dev/null +++ b/images/copy-btn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/copycode.js b/js/copycode.js new file mode 100644 index 00000000..d95a73f0 --- /dev/null +++ b/js/copycode.js @@ -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
+ + 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; +}