fix: better copy icon

This commit is contained in:
2026-02-23 20:58:30 +01:00
parent 3840b928ba
commit 7213b64913
3 changed files with 52 additions and 34 deletions

View File

@@ -103,29 +103,48 @@
.documentation-code-copy-button { .documentation-code-copy-button {
position: absolute; position: absolute;
top: 8px; top: .4rem;
right: 8px; right: .4rem;
border: 1px solid var(--doc-border); border: 1px solid var(--doc-border);
border-radius: 6px; border-radius: .25rem;
background: var(--doc-surface); background: var(--doc-surface);
color: var(--doc-text); color: var(--doc-muted);
width: 30px; width: 1.8rem;
height: 30px; height: 1.8rem;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0;
cursor: pointer; cursor: pointer;
z-index: 1; z-index: 1;
opacity: .88;
} }
.documentation-code-copy-button:hover { .documentation-code-copy-button:hover,
background: var(--doc-code-bg); .documentation-code-copy-button:focus-visible {
opacity: 1;
color: var(--doc-text);
} }
.documentation-code-block pre { .documentation-code-block pre {
margin-top: 0; margin-top: 0;
} }
.documentation-code-block .code-copy-icon {
font-size: .95rem;
line-height: 1;
}
.documentation-code-block.code-copy-success .documentation-code-copy-button {
color: var(--pico-ins-color, rgb(53, 117, 56));
border-color: var(--pico-ins-color, rgb(53, 117, 56));
}
.documentation-code-block.code-copy-failed .documentation-code-copy-button {
color: var(--pico-del-color, rgb(183, 72, 72));
border-color: var(--pico-del-color, rgb(183, 72, 72));
}
.documentation-content.markdown-body blockquote { .documentation-content.markdown-body blockquote {
margin: 10px 0; margin: 10px 0;
padding: 0 0 0 12px; padding: 0 0 0 12px;

View File

@@ -103,25 +103,20 @@ export const DocumentationView: React.FC = () => {
const preferredScrollContainer = scrollContainerRef.current; const preferredScrollContainer = scrollContainerRef.current;
if (!articleElement) { if (!articleElement) {
console.info('[DocumentationView] hash jump skipped', { href, targetId, reason: 'article-not-available' });
return false; return false;
} }
const targetHeading = resolveTargetHeadingInArticle(articleElement, targetId); const targetHeading = resolveTargetHeadingInArticle(articleElement, targetId);
if (!targetHeading) { if (!targetHeading) {
console.info('[DocumentationView] hash jump skipped', { href, targetId, reason: 'target-not-found-or-outside-article' });
return false; return false;
} }
const scrollContainer = resolveScrollContainer(targetHeading, preferredScrollContainer); const scrollContainer = resolveScrollContainer(targetHeading, preferredScrollContainer);
if (!scrollContainer) { if (!scrollContainer) {
console.info('[DocumentationView] hash jump skipped', { href, targetId, reason: 'no-scroll-container' });
return false; return false;
} }
const beforeTop = scrollContainer.scrollTop;
const containerRect = scrollContainer.getBoundingClientRect(); const containerRect = scrollContainer.getBoundingClientRect();
const headingRect = targetHeading.getBoundingClientRect(); const headingRect = targetHeading.getBoundingClientRect();
const targetTop = Math.max(0, scrollContainer.scrollTop + (headingRect.top - containerRect.top) - 12); const targetTop = Math.max(0, scrollContainer.scrollTop + (headingRect.top - containerRect.top) - 12);
@@ -130,19 +125,6 @@ export const DocumentationView: React.FC = () => {
scrollContainer.scrollTop = targetTop; scrollContainer.scrollTop = targetTop;
window.location.hash = href; window.location.hash = href;
articleElement.dataset.lastJumpTarget = targetId;
articleElement.dataset.lastJumpTop = String(targetTop);
articleElement.dataset.lastJumpContainer = scrollContainer.className || scrollContainer.tagName;
console.info('[DocumentationView] hash jump applied', {
href,
targetId,
beforeTop,
targetTop,
afterTop: scrollContainer.scrollTop,
container: scrollContainer.className || scrollContainer.tagName,
});
return true; return true;
}; };
@@ -203,10 +185,14 @@ export const DocumentationView: React.FC = () => {
<div className="documentation-code-block" key={codeBlockKey}> <div className="documentation-code-block" key={codeBlockKey}>
<button <button
type="button" type="button"
className="documentation-code-copy-button" className="code-copy-button documentation-code-copy-button"
aria-label={tr('docs.copyCode')} aria-label={tr('docs.copyCode')}
title={tr('docs.copyCode')} title={tr('docs.copyCode')}
onClick={() => { onClick={(event) => {
const button = event.currentTarget;
const wrapper = button.closest('.documentation-code-block');
const icon = button.querySelector('.code-copy-icon');
const copyToClipboard = async () => { const copyToClipboard = async () => {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(sourceCode); await navigator.clipboard.writeText(sourceCode);
@@ -225,13 +211,30 @@ export const DocumentationView: React.FC = () => {
}; };
copyToClipboard() copyToClipboard()
.then(() => undefined) .then(() => {
wrapper?.classList.remove('code-copy-failed');
wrapper?.classList.remove('code-copy-success');
wrapper?.classList.add('code-copy-success');
if (icon) {
icon.textContent = '✓';
setTimeout(() => {
icon.textContent = '⧉';
wrapper?.classList.remove('code-copy-success');
}, 1200);
}
})
.catch((error) => { .catch((error) => {
wrapper?.classList.remove('code-copy-success');
wrapper?.classList.add('code-copy-failed');
setTimeout(() => {
wrapper?.classList.remove('code-copy-failed');
}, 1200);
console.error('Failed to copy documentation code block:', error); console.error('Failed to copy documentation code block:', error);
}); });
}} }}
> >
📋 <span className="code-copy-icon"></span>
</button> </button>
<pre> <pre>
<code <code

View File

@@ -80,9 +80,6 @@ describe('DocumentationView', () => {
fireEvent.click(tocLink as HTMLAnchorElement); fireEvent.click(tocLink as HTMLAnchorElement);
expect((scrollContainer as HTMLElement).scrollTop).toBe(238); expect((scrollContainer as HTMLElement).scrollTop).toBe(238);
const article = container.querySelector('.documentation-article') as HTMLElement;
expect(article.dataset.lastJumpTarget).toBe('who-this-guide-is-for');
expect(article.dataset.lastJumpTop).toBe('238');
expect(window.location.hash).toBe('#who-this-guide-is-for'); expect(window.location.hash).toBe('#who-this-guide-is-for');
expect(window.open).not.toHaveBeenCalled(); expect(window.open).not.toHaveBeenCalled();
}); });
@@ -190,7 +187,6 @@ describe('DocumentationView', () => {
expect(firstScroll.scrollTop).toBe(7); expect(firstScroll.scrollTop).toBe(7);
expect(secondScroll.scrollTop).toBe(299); expect(secondScroll.scrollTop).toBe(299);
expect(secondArticle.dataset.lastJumpTarget).toBe('using-macros');
}); });
it('falls back to heading text slug when heading id does not match hash', async () => { it('falls back to heading text slug when heading id does not match hash', async () => {