From 4bf4b5fb3a23d417c86884f1605a59f9f76517d4 Mon Sep 17 00:00:00 2001 From: Stamen Stoychev Date: Tue, 26 May 2026 15:32:51 +0300 Subject: [PATCH 1/2] feat(*): add copy button for code blocks --- src/components/overrides/Head.astro | 2 + src/scripts/copy-code.ts | 58 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 src/scripts/copy-code.ts diff --git a/src/components/overrides/Head.astro b/src/components/overrides/Head.astro index 8656e131b..6622935f8 100644 --- a/src/components/overrides/Head.astro +++ b/src/components/overrides/Head.astro @@ -90,3 +90,5 @@ if (breadcrumbItems.length > 1) { }} /> )} + + diff --git a/src/scripts/copy-code.ts b/src/scripts/copy-code.ts new file mode 100644 index 000000000..db835bbc2 --- /dev/null +++ b/src/scripts/copy-code.ts @@ -0,0 +1,58 @@ +/** + * Code-block copy button + * + * Injects a copy-to-clipboard button into every `
` block.
+ * Uses the `copy` / `check` icons from the docs icon registry.
+ *
+ * Re-runs on View-Transition navigations via the `astro:page-load` event.
+ */
+
+function initCopyButtons(): void {
+  document.querySelectorAll('pre.astro-code').forEach((pre) => {
+    if (pre.querySelector('.copy-btn')) return; // already injected
+
+    // Ensure pre is the positioning anchor for the absolutely-placed button.
+    pre.style.position = 'relative';
+
+    const btn = document.createElement('igc-icon-button') as HTMLElement;
+    btn.setAttribute('variant', 'flat');
+    btn.setAttribute('aria-label', 'Copy code');
+    btn.className = 'copy-btn';
+    btn.style.position = 'absolute';
+    btn.style.top = '0.5rem';
+    btn.style.right = '0.5rem';
+    btn.style.opacity = '0';
+    btn.style.transition = 'opacity 0.15s ease';
+
+    const icon = document.createElement('igc-icon');
+    icon.setAttribute('name', 'copy');
+    icon.setAttribute('collection', 'docs');
+    btn.appendChild(icon);
+
+    // Show on hover / focus
+    pre.addEventListener('mouseenter', () => { btn.style.opacity = '1'; });
+    pre.addEventListener('mouseleave', () => { if (!btn.matches(':focus-within')) btn.style.opacity = '0'; });
+    btn.addEventListener('focus', () => { btn.style.opacity = '1'; });
+    btn.addEventListener('blur', () => { btn.style.opacity = '0'; });
+
+    btn.addEventListener('click', () => {
+      const code = pre.querySelector('code');
+      const text = code ? code.textContent ?? '' : pre.textContent ?? '';
+      navigator.clipboard.writeText(text).then(() => {
+        icon.setAttribute('name', 'check');
+        btn.setAttribute('aria-label', 'Copied!');
+        setTimeout(() => {
+          icon.setAttribute('name', 'copy');
+          btn.setAttribute('aria-label', 'Copy code');
+        }, 1500);
+      });
+    });
+
+    pre.appendChild(btn);
+  });
+}
+
+// Run immediately (module may load after astro:page-load has fired)
+// and on subsequent View-Transition navigations.
+initCopyButtons();
+document.addEventListener('astro:page-load', initCopyButtons);

From 49a5a6dafc1112b03c82e966df07dbf58e2019df Mon Sep 17 00:00:00 2001
From: Stamen Stoychev 
Date: Tue, 26 May 2026 17:43:21 +0300
Subject: [PATCH 2/2] Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
 src/scripts/copy-code.ts | 106 ++++++++++++++++++++++++++++++++-------
 1 file changed, 87 insertions(+), 19 deletions(-)

diff --git a/src/scripts/copy-code.ts b/src/scripts/copy-code.ts
index db835bbc2..17cf35e2d 100644
--- a/src/scripts/copy-code.ts
+++ b/src/scripts/copy-code.ts
@@ -7,45 +7,113 @@
  * Re-runs on View-Transition navigations via the `astro:page-load` event.
  */
 
+const COPY_BUTTON_STYLE_ID = 'copy-code-button-styles';
+
+function ensureCopyButtonStyles(): void {
+  if (document.getElementById(COPY_BUTTON_STYLE_ID)) return;
+
+  const style = document.createElement('style');
+  style.id = COPY_BUTTON_STYLE_ID;
+  style.textContent = `
+    pre.astro-code.copy-code-host {
+      position: relative;
+    }
+
+    pre.astro-code.copy-code-host > .copy-btn {
+      position: absolute;
+      top: 0.5rem;
+      right: 0.5rem;
+      opacity: 0;
+      transition: opacity 0.15s ease;
+    }
+
+    pre.astro-code.copy-code-host:hover > .copy-btn,
+    pre.astro-code.copy-code-host:focus-within > .copy-btn {
+      opacity: 1;
+    }
+  `;
+
+  document.head.appendChild(style);
+}
+
 function initCopyButtons(): void {
+  ensureCopyButtonStyles();
+
   document.querySelectorAll('pre.astro-code').forEach((pre) => {
     if (pre.querySelector('.copy-btn')) return; // already injected
 
-    // Ensure pre is the positioning anchor for the absolutely-placed button.
-    pre.style.position = 'relative';
+    pre.classList.add('copy-code-host');
 
     const btn = document.createElement('igc-icon-button') as HTMLElement;
     btn.setAttribute('variant', 'flat');
     btn.setAttribute('aria-label', 'Copy code');
     btn.className = 'copy-btn';
-    btn.style.position = 'absolute';
-    btn.style.top = '0.5rem';
-    btn.style.right = '0.5rem';
-    btn.style.opacity = '0';
-    btn.style.transition = 'opacity 0.15s ease';
 
     const icon = document.createElement('igc-icon');
     icon.setAttribute('name', 'copy');
     icon.setAttribute('collection', 'docs');
     btn.appendChild(icon);
 
-    // Show on hover / focus
-    pre.addEventListener('mouseenter', () => { btn.style.opacity = '1'; });
-    pre.addEventListener('mouseleave', () => { if (!btn.matches(':focus-within')) btn.style.opacity = '0'; });
-    btn.addEventListener('focus', () => { btn.style.opacity = '1'; });
-    btn.addEventListener('blur', () => { btn.style.opacity = '0'; });
-
     btn.addEventListener('click', () => {
       const code = pre.querySelector('code');
       const text = code ? code.textContent ?? '' : pre.textContent ?? '';
-      navigator.clipboard.writeText(text).then(() => {
+
+      const resetButtonState = () => {
+        icon.setAttribute('name', 'copy');
+        btn.setAttribute('aria-label', 'Copy code');
+      };
+
+      const showCopiedState = () => {
         icon.setAttribute('name', 'check');
         btn.setAttribute('aria-label', 'Copied!');
-        setTimeout(() => {
-          icon.setAttribute('name', 'copy');
-          btn.setAttribute('aria-label', 'Copy code');
-        }, 1500);
-      });
+        setTimeout(resetButtonState, 1500);
+      };
+
+      const showCopyFailedState = () => {
+        btn.setAttribute('aria-label', 'Copy failed');
+        setTimeout(resetButtonState, 1500);
+      };
+
+      const fallbackCopyText = (value: string): boolean => {
+        const textArea = document.createElement('textarea');
+        textArea.value = value;
+        textArea.setAttribute('readonly', '');
+        textArea.style.position = 'fixed';
+        textArea.style.opacity = '0';
+        textArea.style.pointerEvents = 'none';
+        document.body.appendChild(textArea);
+        textArea.select();
+        textArea.setSelectionRange(0, textArea.value.length);
+
+        try {
+          return document.execCommand('copy');
+        } catch {
+          return false;
+        } finally {
+          document.body.removeChild(textArea);
+        }
+      };
+
+      if (navigator.clipboard?.writeText) {
+        navigator.clipboard.writeText(text)
+          .then(() => {
+            showCopiedState();
+          })
+          .catch(() => {
+            if (fallbackCopyText(text)) {
+              showCopiedState();
+            } else {
+              showCopyFailedState();
+            }
+          });
+        return;
+      }
+
+      if (fallbackCopyText(text)) {
+        showCopiedState();
+      } else {
+        showCopyFailedState();
+      }
     });
 
     pre.appendChild(btn);