Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/stacks-classic/lib/components/code-block/code-block.less
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -112,5 +125,10 @@
margin: 0;
overflow: auto;
padding: var(--su12);

&:hover .s-code-block--copy,
&:focus-within .s-code-block--copy {
opacity: 1;
}
}
}
106 changes: 106 additions & 0 deletions packages/stacks-classic/lib/components/code-block/code-block.test.ts
Original file line number Diff line number Diff line change
@@ -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`
<pre class="s-code-block" data-controller="s-code-block">
<code>console.log('Hello, World!');</code>
</pre>
`);

// 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`
<pre class="s-code-block" data-controller="s-code-block">
<code>console.log('Hello, World!');</code>
</pre>
`);

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`
<pre class="s-code-block" data-controller="s-code-block">
<div class="s-code-block--line-numbers">1\n2\n3</div>
<code>console.log('line 1');\nconsole.log('line 2');\nconsole.log('line 3');</code>
</pre>
`);

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`
<pre class="s-code-block" data-controller="s-code-block">
<code>console.log('Hello, World!');</code>
</pre>
`);

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();
});
});
136 changes: 136 additions & 0 deletions packages/stacks-classic/lib/components/code-block/code-block.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<svg aria-hidden="true" class="svg-icon iconCopy" width="14" height="14" viewBox="0 0 14 14">
<path d="M5 1a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H5ZM3 3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V3Zm1 1v6h4V4H4Z"/>
<path d="M1 2a1 1 0 0 1 1-1h4.5a.5.5 0 0 1 0 1H2v7.5a.5.5 0 0 1-1 0V2Z"/>
</svg>
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 = `
<svg aria-hidden="true" class="svg-icon iconCheckmark" width="14" height="14" viewBox="0 0 14 14">
<path d="M13 3.5 5.5 11 1 6.5l1-1L5.5 9 12 2.5l1 1Z"/>
</svg>
Copied!
`;

// Reset after 2 seconds
setTimeout(() => {
this.copyButtonTarget.innerHTML = originalContent;
}, 2000);
}
}
1 change: 1 addition & 0 deletions packages/stacks-classic/lib/controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/stacks-classic/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "./stacks.less";
import {
BannerController,
CodeBlockController,
ExpandableController,
ModalController,
PopoverController,
Expand All @@ -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);
Expand Down
51 changes: 50 additions & 1 deletion packages/stacks-docs/product/components/code-blocks.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@
</tbody>
</table>
</div>

{% header "h3", "Stimulus controller" %}
<p class="stacks-copy">Code blocks support interactive features when the Stimulus controller <code class="stacks-code">s-code-block</code> is attached.</p>
<div class="overflow-x-auto mb32" tabindex="0">
<table class="wmn3 s-table s-table__bx-simple">
<thead>
<tr>
<th scope="col">Attribute</th>
<th scope="col" class="s-table--cell2">Applies to</th>
<th scope="col">Description</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><code class="stacks-code">data-controller="s-code-block"</code></th>
<td class="ff-mono"><code class="stacks-code">pre.s-code-block</code></td>
<td class="ff-mono">Automatically adds a copy-to-clipboard button that appears on hover/focus.</td>
</tr>
</tbody>
</table>
</div>
</section>

<section class="stacks-section">
Expand All @@ -43,7 +64,7 @@
{% header "h3", "HTML" %}
<div class="stacks-preview">
{% highlight html %}
<pre class="s-code-block language-html">
<pre class="s-code-block language-html" data-controller="s-code-block">
</pre>
{% endhighlight %}
Expand Down Expand Up @@ -480,3 +501,31 @@
</div>
</div>
</section>

<section class="stacks-section">
{% header "h2", "Interactive features" %}
<p class="stacks-copy">Add <code class="stacks-code">data-controller="s-code-block"</code> to enable interactive features like copy-to-clipboard functionality.</p>

{% header "h3", "Copy to clipboard" %}
<p class="stacks-copy">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.</p>
<div class="stacks-preview">
{% highlight html %}
<pre class="s-code-block language-javascript" data-controller="s-code-block">
</pre>
{% endhighlight %}
<div class="stacks-preview--example">
{% 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 %}
</div>
</div>
</section>
Loading