From ad3d07e5072855921c7fa18486d9ca7ab13d66be Mon Sep 17 00:00:00 2001 From: Youssef Date: Thu, 7 May 2026 19:07:01 +0100 Subject: [PATCH] =?UTF-8?q?Fix=20=C2=B7=20mobile=20copy=20button=20regress?= =?UTF-8?q?ion=20=E2=80=94=20hide=20button,=20make=20pre=20tap-to-copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- site/css/components.css | 56 ++++++++++++++++++--------- site/js/main.js | 84 +++++++++++++++++++++++++++-------------- 2 files changed, 94 insertions(+), 46 deletions(-) diff --git a/site/css/components.css b/site/css/components.css index d90d21d..649e623 100644 --- a/site/css/components.css +++ b/site/css/components.css @@ -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; } } } diff --git a/site/js/main.js b/site/js/main.js index 129d260..804ed9c 100644 --- a/site/js/main.js +++ b/site/js/main.js @@ -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]")); }); }); }