From 2eb6bbef166497e27a54e9da5ccfef4d0c564f6a Mon Sep 17 00:00:00 2001 From: Max Horstmann Date: Sun, 9 Nov 2025 21:55:52 -0500 Subject: [PATCH] add copy button to s-codeblock --- .../lib/components/code-block/code-block.less | 18 +++ .../components/code-block/code-block.test.ts | 106 ++++++++++++++ .../lib/components/code-block/code-block.ts | 136 ++++++++++++++++++ packages/stacks-classic/lib/controllers.ts | 1 + packages/stacks-classic/lib/index.ts | 2 + .../product/components/code-blocks.html | 51 ++++++- 6 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 packages/stacks-classic/lib/components/code-block/code-block.test.ts create mode 100644 packages/stacks-classic/lib/components/code-block/code-block.ts diff --git a/packages/stacks-classic/lib/components/code-block/code-block.less b/packages/stacks-classic/lib/components/code-block/code-block.less index 18fb57981d..62d1b81c88 100644 --- a/packages/stacks-classic/lib/components/code-block/code-block.less +++ b/packages/stacks-classic/lib/components/code-block/code-block.less @@ -102,6 +102,19 @@ text-align: right; } + .s-code-block--copy { + position: absolute; + top: var(--su8); + right: var(--su8); + opacity: 1; + transition: opacity 0.15s ease-in-out; + z-index: 1; + + .svg-icon { + margin-right: var(--su4); + } + } + @scrollbar-styles(); background-color: var(--highlight-bg); border-radius: var(--br-md); @@ -112,5 +125,10 @@ margin: 0; overflow: auto; padding: var(--su12); + + &:hover .s-code-block--copy, + &:focus-within .s-code-block--copy { + opacity: 1; + } } } diff --git a/packages/stacks-classic/lib/components/code-block/code-block.test.ts b/packages/stacks-classic/lib/components/code-block/code-block.test.ts new file mode 100644 index 0000000000..0b34209780 --- /dev/null +++ b/packages/stacks-classic/lib/components/code-block/code-block.test.ts @@ -0,0 +1,106 @@ +import { html, fixture, expect } from "@open-wc/testing"; +import { screen } from "@testing-library/dom"; +import userEvent from "@testing-library/user-event"; +import { stub } from "sinon"; +import "../../index"; + +const user = userEvent.setup(); + +describe("code block", () => { + it("should add a copy button when controller is connected", async () => { + const element = await fixture(html` +
+                console.log('Hello, World!');
+            
+ `); + + // The copy button should be added automatically + const copyButton = element.querySelector(".s-code-block--copy"); + expect(copyButton).to.exist; + expect(copyButton).to.have.attribute("title", "Copy to clipboard"); + }); + + it("should copy code content when copy button is clicked", async () => { + // Mock the clipboard API + const writeTextStub = stub().resolves(); + Object.assign(navigator, { + clipboard: { + writeText: writeTextStub, + }, + }); + + const element = await fixture(html` +
+                console.log('Hello, World!');
+            
+ `); + + const copyButton = element.querySelector(".s-code-block--copy") as HTMLButtonElement; + expect(copyButton).to.exist; + + await user.click(copyButton); + + expect(writeTextStub).to.have.been.calledWith("console.log('Hello, World!');"); + + writeTextStub.restore(); + }); + + it("should handle code blocks with line numbers", async () => { + const writeTextStub = stub().resolves(); + Object.assign(navigator, { + clipboard: { + writeText: writeTextStub, + }, + }); + + const element = await fixture(html` +
+                
1\n2\n3
+ console.log('line 1');\nconsole.log('line 2');\nconsole.log('line 3'); +
+ `); + + const copyButton = element.querySelector(".s-code-block--copy") as HTMLButtonElement; + expect(copyButton).to.exist; + + await user.click(copyButton); + + // Should exclude line numbers from copied content + expect(writeTextStub).to.have.been.called; + const copiedText = writeTextStub.getCall(0).args[0]; + expect(copiedText).to.not.include("1\n2\n3"); + + writeTextStub.restore(); + }); + + it("should show feedback when copy succeeds", async () => { + const writeTextStub = stub().resolves(); + Object.assign(navigator, { + clipboard: { + writeText: writeTextStub, + }, + }); + + const element = await fixture(html` +
+                console.log('Hello, World!');
+            
+ `); + + const copyButton = element.querySelector(".s-code-block--copy") as HTMLButtonElement; + expect(copyButton).to.exist; + + const originalContent = copyButton.innerHTML; + + await user.click(copyButton); + + // Button should show success feedback + expect(copyButton.innerHTML).to.include("Copied!"); + + // Should restore original content after timeout + await new Promise(resolve => setTimeout(resolve, 2100)); + expect(copyButton.innerHTML).to.equal(originalContent); + + writeTextStub.restore(); + }); +}); \ No newline at end of file diff --git a/packages/stacks-classic/lib/components/code-block/code-block.ts b/packages/stacks-classic/lib/components/code-block/code-block.ts new file mode 100644 index 0000000000..8c1aa71ad5 --- /dev/null +++ b/packages/stacks-classic/lib/components/code-block/code-block.ts @@ -0,0 +1,136 @@ +import * as Stacks from "../../stacks"; + +export class CodeBlockController extends Stacks.StacksController { + static targets = ["copyButton", "code"]; + + declare readonly copyButtonTarget: HTMLButtonElement; + declare readonly codeTarget: HTMLElement; + + connect() { + console.log('CodeBlockController connected!', this.element); + this.addCopyButton(); + } + + /** + * Adds a copy button to the code block if it doesn't already exist + */ + private addCopyButton() { + console.log('Adding copy button...'); + + // Check if copy button already exists + try { + this.copyButtonTarget; + console.log('Copy button already exists, skipping'); + return; // Already exists + } catch { + // Button doesn't exist, create it + console.log('Copy button does not exist, creating one'); + } + + // Create the copy button + const copyButton = document.createElement("button"); + copyButton.className = "s-btn s-btn__muted s-btn__xs s-code-block--copy"; + copyButton.setAttribute("data-" + this.identifier + "-target", "copyButton"); + copyButton.setAttribute("data-" + this.identifier + "-action", "click->s-code-block#copy"); + copyButton.setAttribute("type", "button"); + copyButton.setAttribute("title", "Copy to clipboard"); + copyButton.innerHTML = ` + + Copy + `; + + // Position the button absolutely in the top-right corner + (this.element as HTMLElement).style.position = "relative"; + this.element.appendChild(copyButton); + console.log('Copy button created and added:', copyButton); + } + + /** + * Copies the code content to the clipboard + */ + copy() { + const codeContent = this.getCodeContent(); + + if (navigator.clipboard && window.isSecureContext) { + // Use the modern clipboard API + navigator.clipboard.writeText(codeContent).then(() => { + this.showCopyFeedback(); + }).catch(() => { + this.fallbackCopy(codeContent); + }); + } else { + // Fallback for older browsers or non-secure contexts + this.fallbackCopy(codeContent); + } + } + + /** + * Gets the text content of the code block, excluding line numbers + */ + private getCodeContent(): string { + // If there's a specific code target, use that + try { + return this.codeTarget.textContent || ""; + } catch { + // No specific code target, get content from the main element + } + + // Otherwise, get all text content from the element, but exclude line numbers + const lineNumbers = this.element.querySelector(".s-code-block--line-numbers"); + if (lineNumbers) { + // Clone the element and remove line numbers for clean text extraction + const clone = this.element.cloneNode(true) as HTMLElement; + const lineNumbersClone = clone.querySelector(".s-code-block--line-numbers"); + if (lineNumbersClone) { + lineNumbersClone.remove(); + } + return clone.textContent?.trim() || ""; + } + + return this.element.textContent?.trim() || ""; + } + + /** + * Fallback copy method for older browsers + */ + private fallbackCopy(text: string) { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.opacity = "0"; + document.body.appendChild(textArea); + textArea.select(); + + try { + document.execCommand("copy"); + this.showCopyFeedback(); + } catch (err) { + console.error("Failed to copy text:", err); + } finally { + document.body.removeChild(textArea); + } + } + + /** + * Shows visual feedback that the copy operation succeeded + */ + private showCopyFeedback() { + const originalContent = this.copyButtonTarget.innerHTML; + + // Update button to show success state + this.copyButtonTarget.innerHTML = ` + + Copied! + `; + + // Reset after 2 seconds + setTimeout(() => { + this.copyButtonTarget.innerHTML = originalContent; + }, 2000); + } +} \ No newline at end of file diff --git a/packages/stacks-classic/lib/controllers.ts b/packages/stacks-classic/lib/controllers.ts index 1210871488..f7e756c4b6 100644 --- a/packages/stacks-classic/lib/controllers.ts +++ b/packages/stacks-classic/lib/controllers.ts @@ -4,6 +4,7 @@ export { hideBanner, showBanner, } from "./components/banner/banner"; +export { CodeBlockController } from "./components/code-block/code-block"; export { ExpandableController } from "./components/expandable/expandable"; export { ModalController, diff --git a/packages/stacks-classic/lib/index.ts b/packages/stacks-classic/lib/index.ts index 081bbe27a5..e1c83434a3 100644 --- a/packages/stacks-classic/lib/index.ts +++ b/packages/stacks-classic/lib/index.ts @@ -1,6 +1,7 @@ import "./stacks.less"; import { BannerController, + CodeBlockController, ExpandableController, ModalController, PopoverController, @@ -14,6 +15,7 @@ import { application, StacksApplication } from "./stacks"; // register all built-in controllers application.register("s-banner", BannerController); +application.register("s-code-block", CodeBlockController); application.register("s-expandable-control", ExpandableController); application.register("s-modal", ModalController); application.register("s-toast", ToastController); diff --git a/packages/stacks-docs/product/components/code-blocks.html b/packages/stacks-docs/product/components/code-blocks.html index 2333d3e51c..087ecf1cd4 100644 --- a/packages/stacks-docs/product/components/code-blocks.html +++ b/packages/stacks-docs/product/components/code-blocks.html @@ -35,6 +35,27 @@ + + {% header "h3", "Stimulus controller" %} +

Code blocks support interactive features when the Stimulus controller s-code-block is attached.

+
+ + + + + + + + + + + + + + + +
AttributeApplies toDescription
data-controller="s-code-block"pre.s-code-blockAutomatically adds a copy-to-clipboard button that appears on hover/focus.
+
@@ -43,7 +64,7 @@ {% header "h3", "HTML" %}
{% highlight html %} -
+
{% endhighlight %} @@ -480,3 +501,31 @@
+ +
+ {% header "h2", "Interactive features" %} +

Add data-controller="s-code-block" to enable interactive features like copy-to-clipboard functionality.

+ + {% header "h3", "Copy to clipboard" %} +

When the controller is attached, a copy button automatically appears on hover or focus that allows users to copy the code content to their clipboard.

+
+{% highlight html %} +
+    …
+
+{% endhighlight %} +
+{% highlight javascript %} +data-controller="s-code-block"const greeting = "Hello, World!"; +console.log(greeting); + +function fibonacci(n) { + if (n <= 1) return n; + return fibonacci(n - 1) + fibonacci(n - 2); +} + +console.log(fibonacci(10)); +{% endhighlight %} +
+
+