From 1b6e1d9b8d05da014f43f1f47470fa7c351842f4 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Thu, 9 Jul 2026 15:49:28 +0100 Subject: [PATCH] Add copy to clipboard component --- app/assets/javascript/copy-to-clipboard.js | 75 +++++++++++++++++++ app/assets/sass/_app-styles.scss | 1 + .../sass/components/_copy-to-clipboard.scss | 64 ++++++++++++++++ .../_components/copy-to-clipboard/macro.njk | 5 ++ .../copy-to-clipboard/template.njk | 51 +++++++++++++ .../_includes/appointment-status-bar.njk | 10 ++- app/views/_includes/scripts.html | 1 + app/views/_templates/layout-modal-form.html | 1 + app/views/_templates/layout.html | 1 + app/views/admin/mammogram-sets.html | 4 +- 10 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 app/assets/javascript/copy-to-clipboard.js create mode 100644 app/assets/sass/components/_copy-to-clipboard.scss create mode 100644 app/views/_components/copy-to-clipboard/macro.njk create mode 100644 app/views/_components/copy-to-clipboard/template.njk diff --git a/app/assets/javascript/copy-to-clipboard.js b/app/assets/javascript/copy-to-clipboard.js new file mode 100644 index 00000000..70bac911 --- /dev/null +++ b/app/assets/javascript/copy-to-clipboard.js @@ -0,0 +1,75 @@ +// app/assets/javascript/copy-to-clipboard.js +// Progressive-enhancement component: the button is rendered with [hidden] +// and only made visible once JS has initialised it. + +class CopyToClipboard { + constructor(element) { + this.element = element + this.resetTimeout = null + + this.init() + } + + init() { + // Reveal the button now that JS is available + this.element.removeAttribute('hidden') + + this.element.addEventListener('click', () => { + this.copy() + }) + } + + copy() { + // Get text from data attribute, stripping all whitespace + const rawText = this.element.dataset.copyText || '' + const text = rawText.replace(/\s+/g, '') + + navigator.clipboard + .writeText(text) + .then(() => { + this.showCopiedFeedback() + }) + .catch(() => { + // Silently fail — clipboard API can be unavailable in some contexts + }) + } + + showCopiedFeedback() { + // Cancel any in-progress reset so rapid clicks don't cause flicker + if (this.resetTimeout) { + clearTimeout(this.resetTimeout) + } + + this.element.classList.add('app-copy-to-clipboard--copied') + this.element.setAttribute('aria-label', 'Copied') + + this.resetTimeout = setTimeout(() => { + this.element.classList.remove('app-copy-to-clipboard--copied') + + // Restore original aria-label from the data attribute if present, + // otherwise fall back to the current aria-label without "Copied" + const originalLabel = this.element.dataset.ariaLabel + if (originalLabel) { + this.element.setAttribute('aria-label', originalLabel) + } + + this.resetTimeout = null + }, 2000) + } +} + +// Initialise all copy-to-clipboard buttons when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + const buttons = document.querySelectorAll( + '[data-module="app-copy-to-clipboard"]' + ) + + buttons.forEach((element) => { + // Store original aria-label so we can restore it after the "Copied" feedback + if (element.getAttribute('aria-label')) { + element.dataset.ariaLabel = element.getAttribute('aria-label') + } + + new CopyToClipboard(element) + }) +}) diff --git a/app/assets/sass/_app-styles.scss b/app/assets/sass/_app-styles.scss index 57e44d43..87b82235 100644 --- a/app/assets/sass/_app-styles.scss +++ b/app/assets/sass/_app-styles.scss @@ -26,6 +26,7 @@ @forward "components/annotation-images"; @forward "components/environment"; +@forward "components/copy-to-clipboard"; @forward "components/overrides"; @forward "components/notification-banner"; @forward "components/checkboxes"; diff --git a/app/assets/sass/components/_copy-to-clipboard.scss b/app/assets/sass/components/_copy-to-clipboard.scss new file mode 100644 index 00000000..5fa59f89 --- /dev/null +++ b/app/assets/sass/components/_copy-to-clipboard.scss @@ -0,0 +1,64 @@ +// app/assets/sass/components/_copy-to-clipboard.scss + +@use "nhsuk-frontend/dist/nhsuk/core" as *; + +// Base button — looks like a small inline text link +.app-copy-to-clipboard { + display: inline-flex; + align-items: center; + gap: nhsuk-spacing(1); + padding: 0 nhsuk-spacing(1); + background: none; + border: none; + cursor: pointer; + font-size: $nhsuk-base-font-size * 0.875; // slightly smaller than body text + font-family: inherit; + line-height: 1; + color: inherit; + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 2px; + vertical-align: middle; +} + +.app-copy-to-clipboard:hover { + text-decoration-thickness: 3px; +} + +.app-copy-to-clipboard:focus { + @include nhsuk-focused-text; +} + +// Hide the "copied" label/icon by default +.app-copy-to-clipboard__copied { + display: none; +} + +// When copied: swap which label is visible +.app-copy-to-clipboard--copied .app-copy-to-clipboard__default { + display: none; +} + +.app-copy-to-clipboard--copied .app-copy-to-clipboard__copied { + display: inline-flex; + align-items: center; +} + +// Icon variant — no underline, just the icon +.app-copy-to-clipboard--icon { + padding: 0 nhsuk-spacing(1); + text-decoration: none; +} + +// Fill-based SVG icons — sized relative to surrounding text +.app-copy-to-clipboard--icon svg { + display: block; + fill: currentColor; + width: 1em; + height: 1em; +} + +// Tick should be dark regardless of parent context (e.g. white text on dark bar) +.app-copy-to-clipboard__copied svg { + fill: $nhsuk-text-colour; +} diff --git a/app/views/_components/copy-to-clipboard/macro.njk b/app/views/_components/copy-to-clipboard/macro.njk new file mode 100644 index 00000000..f8bd1767 --- /dev/null +++ b/app/views/_components/copy-to-clipboard/macro.njk @@ -0,0 +1,5 @@ +{# app/views/_components/copy-to-clipboard/macro.njk #} + +{% macro appCopyToClipboard(params) %} + {%- include "./template.njk" -%} +{% endmacro %} diff --git a/app/views/_components/copy-to-clipboard/template.njk b/app/views/_components/copy-to-clipboard/template.njk new file mode 100644 index 00000000..88df0fb3 --- /dev/null +++ b/app/views/_components/copy-to-clipboard/template.njk @@ -0,0 +1,51 @@ +{# app/views/_components/copy-to-clipboard/template.njk #} + +{# + Params: + text (required) — the string to copy to the clipboard + type (optional) — "text" (default) shows a "Copy" label; "icon" shows a clipboard icon + label (optional) — describes what is being copied, e.g. "NHS number" + used to build accessible labels: "Copy NHS number" / "Copied" + classes (optional) — additional classes on the button +#} + +{% set type = params.type if params.type else "text" %} + +{% if params.label %} + {% set defaultAriaLabel = "Copy " + params.label %} +{% else %} + {% set defaultAriaLabel = "Copy to clipboard" %} +{% endif %} + + diff --git a/app/views/_includes/appointment-status-bar.njk b/app/views/_includes/appointment-status-bar.njk index e246564b..a394769f 100644 --- a/app/views/_includes/appointment-status-bar.njk +++ b/app/views/_includes/appointment-status-bar.njk @@ -40,9 +40,12 @@ {# Worklist accession number with inline worklist status #} {% set accessionNumberFormatted = event.accessionNumber | formatAccessionNumber %} +{% set accessionCopyButtonHtml %} + {{ appCopyToClipboard({ text: event.accessionNumber, label: "accession number" })}} +{% endset %} {% set appointmentRowItems = appointmentRowItems | push({ key: 'Accn:', - value: '' + accessionNumberFormatted + '' + worklistStatusHtml + '' + value: '' + accessionNumberFormatted + '' + accessionCopyButtonHtml + '' + worklistStatusHtml + '' }) %} {# Appointment type #} @@ -99,9 +102,12 @@ }) %} {# NHS Number #} +{% set nhsCopyButtonHtml %} + {{ appCopyToClipboard({ text: participant.medicalInformation.nhsNumber, type:'icon', label: "NHS number" })}} +{% endset %} {% set participantRowItems = participantRowItems | push({ key: "NHS:", - value: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '' + value: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '' + nhsCopyButtonHtml }) %} {{ appStatusBar({ diff --git a/app/views/_includes/scripts.html b/app/views/_includes/scripts.html index 78986800..b3e3f04d 100755 --- a/app/views/_includes/scripts.html +++ b/app/views/_includes/scripts.html @@ -3,6 +3,7 @@ {% if currentPage.indexOf("/prototype-admin/") === -1 %} + diff --git a/app/views/_templates/layout-modal-form.html b/app/views/_templates/layout-modal-form.html index 3b5c039d..27feaad5 100644 --- a/app/views/_templates/layout-modal-form.html +++ b/app/views/_templates/layout-modal-form.html @@ -43,6 +43,7 @@ {%- from '_components/collapsible-input/macro.njk' import appCollapsibleInput %} {%- from '_components/summary-list/macro.njk' import appSummaryList %} {%- from '_components/summary-list/macro.njk' import appSummaryListRow %} +{%- from '_components/copy-to-clipboard/macro.njk' import appCopyToClipboard %} {% set _errorList = errors if (errors and errors | length) else flash.error %} {% if _errorList and _errorList | length %} diff --git a/app/views/_templates/layout.html b/app/views/_templates/layout.html index 1acdb544..ad905c3a 100755 --- a/app/views/_templates/layout.html +++ b/app/views/_templates/layout.html @@ -28,6 +28,7 @@ {%- from "_components/notification-banner/macro.njk" import appNotificationBanner -%} {%- from '_components/icon/macro.njk' import appIcon %} {%- from '_components/status-message/macro.njk' import appStatusMessage %} +{%- from '_components/copy-to-clipboard/macro.njk' import appCopyToClipboard %} {%- from '_components/option-picker/macro.njk' import appOptionPicker %} {% block head %} diff --git a/app/views/admin/mammogram-sets.html b/app/views/admin/mammogram-sets.html index 74e48eae..86741283 100644 --- a/app/views/admin/mammogram-sets.html +++ b/app/views/admin/mammogram-sets.html @@ -1220,7 +1220,7 @@

Annotations

await navigator.clipboard.writeText(json) const btn = document.getElementById('copyJson') const originalText = btn.textContent - btn.textContent = '✓ Copied!' + btn.textContent = '✓ Copied' btn.style.background = '#007f3b' btn.style.color = 'white' setTimeout(() => { @@ -1236,7 +1236,7 @@

Annotations

textarea.select() document.execCommand('copy') document.body.removeChild(textarea) - alert('Copied!') + alert('Copied') } })