From 1b6e1d9b8d05da014f43f1f47470fa7c351842f4 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Thu, 9 Jul 2026 15:49:28 +0100 Subject: [PATCH 1/3] 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') } }) From 7d2efdd7b0fd924e3c7dfcf61fb63724d80b0203 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Fri, 18 Sep 2026 15:55:11 +0100 Subject: [PATCH 2/3] Style copy to clipboard as an underlined value Add a "value" variant that wraps the value itself, so clicking the number copies it. Dashed rule at rest, solid on hover, drawn as a background so it runs under the icon as well as the value. Use it for the NHS number and accession number in the appointment status bar, and the NHS and SX numbers in the reading status bar. Nudge the monospace utility onto the body text baseline. --- app/assets/javascript/copy-to-clipboard.js | 64 +++++---- app/assets/sass/_typography.scss | 7 + .../sass/components/_copy-to-clipboard.scss | 129 ++++++++++++++---- .../copy-to-clipboard/template.njk | 49 ++++--- app/views/_components/icon/macro.njk | 7 + .../_includes/appointment-status-bar.njk | 22 ++- .../_includes/reading/reading-status-bar.njk | 22 ++- app/views/style-guide/_nav.njk | 1 + app/views/style-guide/copy-to-clipboard.html | 123 +++++++++++++++++ 9 files changed, 341 insertions(+), 83 deletions(-) create mode 100644 app/views/style-guide/copy-to-clipboard.html diff --git a/app/assets/javascript/copy-to-clipboard.js b/app/assets/javascript/copy-to-clipboard.js index 70bac911..1cf48229 100644 --- a/app/assets/javascript/copy-to-clipboard.js +++ b/app/assets/javascript/copy-to-clipboard.js @@ -1,17 +1,27 @@ // 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. +// and only made visible once JS has initialised it and confirmed the +// clipboard API is available. + +const RESET_DELAY = 5000 class CopyToClipboard { constructor(element) { this.element = element this.resetTimeout = null + this.defaultLabel = element.getAttribute('aria-label') + + // Visually hidden live region so screen readers hear the result. + // Swapping the button's aria-label alone is not announced. + this.status = document.createElement('span') + this.status.setAttribute('aria-live', 'polite') + this.status.classList.add('nhsuk-u-visually-hidden') this.init() } init() { - // Reveal the button now that JS is available + this.element.insertAdjacentElement('afterend', this.status) this.element.removeAttribute('hidden') this.element.addEventListener('click', () => { @@ -26,50 +36,52 @@ class CopyToClipboard { navigator.clipboard .writeText(text) - .then(() => { - this.showCopiedFeedback() - }) - .catch(() => { - // Silently fail — clipboard API can be unavailable in some contexts - }) + .then(() => this.copied()) + .catch(() => this.reset()) + } + + copied() { + this.element.classList.add('app-copy-to-clipboard--copied') + this.setLabel('Copied') + this.status.textContent = this.element.dataset.copiedAnnouncement || 'Copied to clipboard' + + this.reset(RESET_DELAY) + } + + // Only buttons that started with an aria-label get it swapped. The value + // variant names itself from its visible content, so it is left alone. + setLabel(label) { + if (this.defaultLabel) { + this.element.setAttribute('aria-label', label) + } } - showCopiedFeedback() { + reset(delay = 0) { // 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.setLabel(this.defaultLabel) + this.status.textContent = '' this.resetTimeout = null - }, 2000) + }, delay) } } // Initialise all copy-to-clipboard buttons when DOM is ready document.addEventListener('DOMContentLoaded', () => { + if (!('clipboard' in navigator)) { + return + } + 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/_typography.scss b/app/assets/sass/_typography.scss index f3d9df85..c21eccf5 100644 --- a/app/assets/sass/_typography.scss +++ b/app/assets/sass/_typography.scss @@ -49,3 +49,10 @@ h3 { text-decoration: none; } } + +// The monospace stack sits a couple of pixels high against the body text, so +// nudge it back onto the same optical baseline without affecting layout +.nhsuk-u-font-code { + position: relative; + top: 1px; +} diff --git a/app/assets/sass/components/_copy-to-clipboard.scss b/app/assets/sass/components/_copy-to-clipboard.scss index 5fa59f89..8b248d9d 100644 --- a/app/assets/sass/components/_copy-to-clipboard.scss +++ b/app/assets/sass/components/_copy-to-clipboard.scss @@ -2,63 +2,136 @@ @use "nhsuk-frontend/dist/nhsuk/core" as *; -// Base button — looks like a small inline text link +// Inline button that sits beside a value and inherits its colour, so it works +// on light backgrounds and on the dark status bar alike. The box is at least +// 24px square to give a usable hit area even for the icon-only variant. .app-copy-to-clipboard { + @include nhsuk-font-size(14); + display: inline-flex; align-items: center; + justify-content: center; gap: nhsuk-spacing(1); + min-width: nhsuk-px-to-rem($nhsuk-icon-size); + min-height: nhsuk-px-to-rem($nhsuk-icon-size); padding: 0 nhsuk-spacing(1); + border: 0; background: none; - border: none; - cursor: pointer; - font-size: $nhsuk-base-font-size * 0.875; // slightly smaller than body text + color: inherit; font-family: inherit; line-height: 1; - color: inherit; + vertical-align: middle; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 2px; - vertical-align: middle; + cursor: pointer; } .app-copy-to-clipboard:hover { text-decoration-thickness: 3px; } -.app-copy-to-clipboard:focus { - @include nhsuk-focused-text; +// Icon variant: no underline at rest. Hover draws a bar along the foot of the +// button, so it runs under the icon, which a text underline cannot do. +.app-copy-to-clipboard--icon { + padding: 0; + text-decoration: none; } -// Hide the "copied" label/icon by default -.app-copy-to-clipboard__copied { - display: none; +.app-copy-to-clipboard--icon:hover { + box-shadow: inset 0 -3px currentcolor; } -// When copied: swap which label is visible -.app-copy-to-clipboard--copied .app-copy-to-clipboard__default { - display: none; +// The value variant wraps the value itself, so it takes the size of the +// surrounding text. It lays out as inline text rather than a flex box, so the +// underline hugs the value and its icon rather than running the width of the +// button. Dashed at rest hints that the value does something, as on an +// abbreviation. +.app-copy-to-clipboard--value { + display: inline; + padding: 0 0 3px; + font-size: inherit; + line-height: inherit; + white-space: nowrap; + text-decoration: none; + + // Drawn as a background rather than an underline so the rule runs under the + // icon as well as the value: a text underline stops where the text does + background-image: linear-gradient(to right, currentcolor 0 3px, transparent 3px 6px); + background-repeat: repeat-x; + background-position: 0 100%; + background-size: 6px 1px; } -.app-copy-to-clipboard--copied .app-copy-to-clipboard__copied { - display: inline-flex; - align-items: center; +.app-copy-to-clipboard--value:hover { + background-image: linear-gradient(currentcolor, currentcolor); + background-size: 100% 3px; } -// Icon variant — no underline, just the icon -.app-copy-to-clipboard--icon { - padding: 0 nhsuk-spacing(1); - text-decoration: none; +// Sized to the icon itself rather than the standard square box, which would +// pad the icon away from the value. Both states share the 1em box so the +// button keeps its width when the tick shows. +.app-copy-to-clipboard--value .app-copy-to-clipboard__icon { + position: relative; + top: -1px; + width: 1em; + height: 1em; + margin-left: nhsuk-spacing(1); + vertical-align: middle; +} + +// Icons sit in a fixed square so the copy icon and the larger tick take the +// same space, keeping the button (and its focus box) the same shape in both +// states, with room either side of the icon +.app-copy-to-clipboard__icon { + justify-content: center; + width: nhsuk-px-to-rem($nhsuk-icon-size); + height: nhsuk-px-to-rem($nhsuk-icon-size); +} + +// Focus matches header links: yellow box, dark content, inset bottom bar. +// Declared after hover so it wins when both apply. +.app-copy-to-clipboard:focus { + @include nhsuk-focused-text; + + box-shadow: inset 0 (-$nhsuk-focus-width) $nhsuk-focus-text-colour; +} + +// The value variant is inline text, so it takes the standard focus bar drawn +// below the box rather than an inset one +.app-copy-to-clipboard--value:focus { + background-image: none; + box-shadow: + 0 -2px $nhsuk-focus-colour, + 0 $nhsuk-focus-width $nhsuk-focus-text-colour; +} + +// Icons inherit the button colour, so the tick stays visible on dark +// backgrounds and turns dark on the yellow focus box. Sized to match the +// 14px text, which also keeps them clear of the focus bar at the foot of the box. +.app-copy-to-clipboard .nhsuk-icon { + width: nhsuk-px-to-rem(14px); + height: nhsuk-px-to-rem(14px); } -// Fill-based SVG icons — sized relative to surrounding text -.app-copy-to-clipboard--icon svg { - display: block; - fill: currentColor; +// The tick is sized to match the one on the worklist status in the status bar +.app-copy-to-clipboard__copied .nhsuk-icon { 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; +// Swap the default label for the copied one while feedback is showing +.app-copy-to-clipboard__default, +.app-copy-to-clipboard__copied { + display: inline-flex; + align-items: center; +} + +.app-copy-to-clipboard__copied, +.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; } diff --git a/app/views/_components/copy-to-clipboard/template.njk b/app/views/_components/copy-to-clipboard/template.njk index 88df0fb3..bdef9cf7 100644 --- a/app/views/_components/copy-to-clipboard/template.njk +++ b/app/views/_components/copy-to-clipboard/template.njk @@ -3,43 +3,54 @@ {# Params: text (required) — the string to copy to the clipboard - type (optional) — "text" (default) shows a "Copy" label; "icon" shows a clipboard icon + type (optional) — "text" (default) shows a "Copy" label + "icon" shows a copy icon only + "value" wraps the visible value itself (from `html`) with an icon, + so clicking anywhere on the value copies it + html (required for type "value") — the visible value, e.g. the formatted number label (optional) — describes what is being copied, e.g. "NHS number" - used to build accessible labels: "Copy NHS number" / "Copied" + used for the accessible name ("Copy NHS number") and the + screen reader announcement ("NHS number copied to clipboard") classes (optional) — additional classes on the button #} +{% from "_components/icon/macro.njk" import appIcon %} + {% set type = params.type if params.type else "text" %} {% if params.label %} {% set defaultAriaLabel = "Copy " + params.label %} + {% set copiedAnnouncement = (params.label | sentenceCase) + " copied to clipboard" %} {% else %} {% set defaultAriaLabel = "Copy to clipboard" %} + {% set copiedAnnouncement = "Copied to clipboard" %} {% endif %} +{# The value variant has visible text, so its accessible name comes from its + content rather than an aria-label, keeping the visible value in the name #}