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`
+ ++ `); + + 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` +1\n2\n3+console.log('line 1');\nconsole.log('line 2');\nconsole.log('line 3');+
+ 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.
| Attribute | +Applies to | +Description | +
|---|---|---|
data-controller="s-code-block" |
+ pre.s-code-block |
+ Automatically adds a copy-to-clipboard button that appears on hover/focus. | +
+
…
{% endhighlight %}
@@ -480,3 +501,31 @@
Add data-controller="s-code-block" to enable interactive features like copy-to-clipboard functionality.
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.
++ … ++{% endhighlight %} +