mirror of
https://github.com/Nutlope/hallmark.git
synced 2026-08-14 12:35:33 +02:00
Fix · mobile copy button regression — hide button, make pre tap-to-copy
The previous mobile fix (display: block + width: 100% on the button) overflowed the frame because .code has white-space: nowrap and the button rendered inline-flex on the same nowrap line as the command, spilling past the right edge. Cleaner pattern, matching what gh repo clone / npmjs / Vercel install panes do on touch devices: - Hide .code__copy entirely below 45rem. - Bind a click handler on the whole [data-copy-source] pre so the command area itself is tap-to-copy. cursor: pointer signals it. - Add a transient "Copied ✓" badge inside the pre on success (anchored top/right/centred-vertically) — replaces the button's label-swap on mobile. Animates in and clears after 2.2 s. - Restore the original .code flex + align-items: center so the command sits vertically centred in the frame regardless of height. JS: - Refactor attachCopyButtons to share a copyFromSource helper. - Source binding uses data-copy-bound; button binding uses data-copy-btn-bound (separate flags so re-attached cloned heroes don't double-bind). - Button click stops propagation so a single tap on the visible button (desktop) doesn't fire both the button and source handlers. - Both surfaces flash data-state="copied" together so CSS feedback works on whichever surface is visible at the active breakpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
e27a02f981
commit
ad3d07e507
+39
-17
@@ -802,25 +802,47 @@
|
||||
.code__copy[data-state="copied"] .code__copy-default { display: none; }
|
||||
.code__copy[data-state="copied"] .code__copy-done { display: inline; }
|
||||
|
||||
/* Mobile · stack the copy button beneath the command instead of
|
||||
absolute-positioning over it. On coarse-pointer devices the button
|
||||
is always visible (no hover state); overlaying the right edge of a
|
||||
long command makes the text unreadable. Pattern from GitHub /
|
||||
Vercel / npmjs / MDN — command on its own line with horizontal
|
||||
scroll, copy button as a full-width tap target underneath. */
|
||||
/* Mobile · drop the copy button entirely and make the whole pre
|
||||
tap-to-copy. The button at narrow widths overflowed the frame; on
|
||||
coarse-pointer devices a full-width tap target is the right
|
||||
pattern (matches `gh repo clone` / npmjs / Vercel install panes).
|
||||
The pre keeps its flex + center alignment so the command sits
|
||||
vertically centred regardless of frame height. */
|
||||
@media (max-width: 45rem) {
|
||||
.code {
|
||||
display: block;
|
||||
padding-block: var(--space-sm) var(--space-sm);
|
||||
.code__copy { display: none; }
|
||||
|
||||
.code[data-copy-source] { cursor: pointer; }
|
||||
|
||||
/* Transient "Copied ✓" badge inside the pre, anchored top-right.
|
||||
Replaces the button's label-swap on mobile. Fades automatically
|
||||
when JS removes data-state after 2.2 s. */
|
||||
.code[data-copy-source][data-state="copied"] {
|
||||
position: relative;
|
||||
}
|
||||
.code .prompt { margin-inline-end: var(--space-2xs); }
|
||||
.code__copy {
|
||||
position: static;
|
||||
transform: none;
|
||||
width: 100%;
|
||||
margin-block-start: var(--space-sm);
|
||||
justify-content: center;
|
||||
min-height: 32px;
|
||||
.code[data-copy-source][data-state="copied"]::after {
|
||||
content: "Copied ✓";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: var(--space-sm);
|
||||
transform: translateY(-50%);
|
||||
padding: 0.25rem 0.55rem;
|
||||
font-family: var(--font-label);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-accent);
|
||||
background: var(--color-paper);
|
||||
border: var(--rule-hair) solid var(--color-accent);
|
||||
border-radius: var(--radius-input, 0);
|
||||
pointer-events: none;
|
||||
animation: copy-flash var(--dur-short) var(--ease-out);
|
||||
}
|
||||
@keyframes copy-flash {
|
||||
from { opacity: 0; transform: translateY(-50%) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(-50%) scale(1); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.code[data-copy-source][data-state="copied"]::after { animation: none; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+55
-29
@@ -647,37 +647,63 @@ function swapArchetypes(theme) {
|
||||
attachCopyButtons(slotEls.hero);
|
||||
}
|
||||
|
||||
/* — Copy-to-clipboard (silent success, label swap pattern) ————— */
|
||||
/* — Copy-to-clipboard (silent success, label swap pattern) —————
|
||||
Two click surfaces:
|
||||
- The Copy button (visible on desktop) — explicit affordance.
|
||||
- The whole pre[data-copy-source] — falls back to a tappable area
|
||||
on mobile where the button is hidden by CSS.
|
||||
Both call the same async copy + state-flash routine. We bind once
|
||||
per element via `data-copy-bound` so re-attached templates don't
|
||||
double-bind. */
|
||||
async function copyFromSource(source) {
|
||||
if (!source) return;
|
||||
const textNode = source.querySelector("[data-copy-text]");
|
||||
const text = textNode ? textNode.textContent.trim() : "";
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.setAttribute("readonly", "");
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); } catch (e) { }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
// Flash both the source and any visible button so the right
|
||||
// surface (mobile pseudo-element vs desktop button label) animates.
|
||||
source.dataset.state = "copied";
|
||||
source.setAttribute("aria-live", "polite");
|
||||
const btn = source.querySelector("[data-copy-btn]");
|
||||
if (btn) btn.dataset.state = "copied";
|
||||
|
||||
clearTimeout(source._copyTimer);
|
||||
source._copyTimer = setTimeout(() => {
|
||||
delete source.dataset.state;
|
||||
if (btn) delete btn.dataset.state;
|
||||
}, 2200);
|
||||
}
|
||||
|
||||
function attachCopyButtons(scope = document) {
|
||||
const btns = scope.querySelectorAll("[data-copy-btn]:not([data-copy-bound])");
|
||||
// Bind the whole pre — works on mobile where the button is hidden.
|
||||
const sources = scope.querySelectorAll("[data-copy-source]:not([data-copy-bound])");
|
||||
sources.forEach((source) => {
|
||||
source.dataset.copyBound = "true";
|
||||
source.addEventListener("click", () => copyFromSource(source));
|
||||
});
|
||||
// Button click is also handled — stop propagation so the source
|
||||
// listener doesn't double-fire (single copy per click).
|
||||
const btns = scope.querySelectorAll("[data-copy-btn]:not([data-copy-btn-bound])");
|
||||
btns.forEach((btn) => {
|
||||
btn.dataset.copyBound = "true";
|
||||
btn.addEventListener("click", async () => {
|
||||
const source = btn.closest("[data-copy-source]");
|
||||
const textNode = source && source.querySelector("[data-copy-text]");
|
||||
const text = textNode ? textNode.textContent.trim() : "";
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.setAttribute("readonly", "");
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); } catch (e) { }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
btn.dataset.state = "copied";
|
||||
btn.setAttribute("aria-live", "polite");
|
||||
clearTimeout(btn._copyTimer);
|
||||
btn._copyTimer = setTimeout(() => {
|
||||
delete btn.dataset.state;
|
||||
}, 2200);
|
||||
btn.dataset.copyBtnBound = "true";
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
copyFromSource(btn.closest("[data-copy-source]"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user