diff --git a/app/assets/javascript/copy-to-clipboard.js b/app/assets/javascript/copy-to-clipboard.js new file mode 100644 index 000000000..1cf48229b --- /dev/null +++ b/app/assets/javascript/copy-to-clipboard.js @@ -0,0 +1,87 @@ +// 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 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() { + this.element.insertAdjacentElement('afterend', this.status) + 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.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) + } + } + + reset(delay = 0) { + // Cancel any in-progress reset so rapid clicks don't cause flicker + if (this.resetTimeout) { + clearTimeout(this.resetTimeout) + } + + this.resetTimeout = setTimeout(() => { + this.element.classList.remove('app-copy-to-clipboard--copied') + this.setLabel(this.defaultLabel) + this.status.textContent = '' + this.resetTimeout = null + }, 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) => { + new CopyToClipboard(element) + }) +}) diff --git a/app/assets/sass/_app-styles.scss b/app/assets/sass/_app-styles.scss index 69be4a445..ef843bf6b 100644 --- a/app/assets/sass/_app-styles.scss +++ b/app/assets/sass/_app-styles.scss @@ -29,6 +29,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/_typography.scss b/app/assets/sass/_typography.scss index f3d9df857..c21eccf58 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 new file mode 100644 index 000000000..5c0672fda --- /dev/null +++ b/app/assets/sass/components/_copy-to-clipboard.scss @@ -0,0 +1,140 @@ +// app/assets/sass/components/_copy-to-clipboard.scss + +@use "nhsuk-frontend/dist/nhsuk/core" as *; + +// 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; + color: inherit; + font-family: inherit; + line-height: 1; + vertical-align: middle; + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 2px; + cursor: pointer; +} + +.app-copy-to-clipboard:hover { + text-decoration-thickness: 3px; +} + +// 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; +} + +.app-copy-to-clipboard--icon:hover { + box-shadow: inset 0 -3px currentcolor; +} + +// The value variant wraps the value itself, so it takes the size of the +// surrounding text and is dashed at rest, as on an abbreviation. +// +// A button cannot really be display: inline — it keeps a box of its own — so +// the line height comes from the font metrics and the box sits on the +// baseline. That makes the box the same as a link's, which is what both the +// rule and the focus bar are measured against. +.app-copy-to-clipboard--value { + display: inline; + padding: 0; + font-size: inherit; + line-height: normal; + white-space: nowrap; + vertical-align: baseline; + 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 calc(100% - 2px); + background-size: 6px 1px; +} + +// Links drop their underline on hover, so the rule goes the same way +.app-copy-to-clipboard--value:hover { + background-image: 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); +} + +// 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; +} + +// 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/macro.njk b/app/views/_components/copy-to-clipboard/macro.njk new file mode 100644 index 000000000..f8bd17671 --- /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 000000000..bdef9cf75 --- /dev/null +++ b/app/views/_components/copy-to-clipboard/template.njk @@ -0,0 +1,62 @@ +{# 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 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 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 #} + diff --git a/app/views/_components/icon/macro.njk b/app/views/_components/icon/macro.njk index 825f9561a..819c5884b 100644 --- a/app/views/_components/icon/macro.njk +++ b/app/views/_components/icon/macro.njk @@ -17,6 +17,13 @@ {%- if params and params.title %}{{ params.title }}{% endif %} +{%- elseif name == "copy" -%} +{# Two-pages copy icon (GitHub Octicons, MIT). Drawn on a 16px grid, unlike the NHS icons #} + {%- else -%} {{- nhsukIcon(name, params) -}} {%- endif -%} diff --git a/app/views/_includes/appointment-status-bar.njk b/app/views/_includes/appointment-status-bar.njk index 2763741ec..09d250b18 100644 --- a/app/views/_includes/appointment-status-bar.njk +++ b/app/views/_includes/appointment-status-bar.njk @@ -40,9 +40,17 @@ {# Worklist accession number with inline worklist status #} {% set accessionNumberFormatted = appointment.accessionNumber | formatAccessionNumber %} +{% set accessionNumberHtml %} + {{ appCopyToClipboard({ + text: appointment.accessionNumber, + type: "value", + html: '' + accessionNumberFormatted + '', + label: "accession number" + }) }} +{% endset %} {% set appointmentRowItems = appointmentRowItems | push({ key: 'Accn:', - value: '' + accessionNumberFormatted + '' + worklistStatusHtml + '' + value: accessionNumberHtml + '' + worklistStatusHtml + '' }) %} {# Appointment type #} @@ -99,9 +107,17 @@ }) %} {# NHS Number #} +{% set nhsNumberHtml %} + {{ appCopyToClipboard({ + text: participant.medicalInformation.nhsNumber, + type: "value", + html: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '', + label: "NHS number" + }) }} +{% endset %} {% set participantRowItems = participantRowItems | push({ key: "NHS:", - value: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '' + value: nhsNumberHtml }) %} {{ appStatusBar({ diff --git a/app/views/_includes/reading/reading-status-bar.njk b/app/views/_includes/reading/reading-status-bar.njk index e5ada53d9..c90f25e44 100644 --- a/app/views/_includes/reading/reading-status-bar.njk +++ b/app/views/_includes/reading/reading-status-bar.njk @@ -91,19 +91,33 @@ }) %} {# NHS Number #} + {% set nhsNumberHtml %} + {{ appCopyToClipboard({ + text: participant.medicalInformation.nhsNumber, + type: "value", + html: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '', + label: "NHS number" + }) }} + {% endset %} {% set participantRowItems = participantRowItems | push({ key: "NHS:", - value: participant.medicalInformation.nhsNumber | formatNhsNumber + value: nhsNumberHtml }) %} {# SX Number #} + {% set sxNumberHtml %} + {{ appCopyToClipboard({ + text: participant.sxNumber, + type: "value", + html: '' + participant.sxNumber + '', + label: "SX number" + }) }} + {% endset %} {% set participantRowItems = participantRowItems | push({ key: "SX:", - value: participant.sxNumber + value: sxNumberHtml }) %} - - {% set statusBarRows = statusBarRows | push({ items: participantRowItems }) %} diff --git a/app/views/_includes/scripts.html b/app/views/_includes/scripts.html index bd1b6f683..36e0a9811 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/_includes/summary-lists/rows/nhs-number.njk b/app/views/_includes/summary-lists/rows/nhs-number.njk index ccd520b5a..cbbca7baf 100644 --- a/app/views/_includes/summary-lists/rows/nhs-number.njk +++ b/app/views/_includes/summary-lists/rows/nhs-number.njk @@ -1,11 +1,24 @@ {# /app/views/_includes/summary-lists/rows/nhs-number.njk #} +{% set nhsNumber = participant.medicalInformation.nhsNumber %} + +{% if nhsNumber %} + {% set nhsNumberValueHtml %} + {{ appCopyToClipboard({ + text: nhsNumber, + type: "value", + html: '' + (nhsNumber | formatNhsNumber) + '', + label: "NHS number" + }) }} + {% endset %} +{% endif %} + {% set nhsNumberRow = { key: { text: "NHS number" }, value: { - text: participant.medicalInformation.nhsNumber | formatNhsNumber + html: nhsNumberValueHtml }, actions: { items: [ diff --git a/app/views/_includes/summary-lists/rows/sx-number.njk b/app/views/_includes/summary-lists/rows/sx-number.njk index 8ae712784..843ee08d4 100644 --- a/app/views/_includes/summary-lists/rows/sx-number.njk +++ b/app/views/_includes/summary-lists/rows/sx-number.njk @@ -1,9 +1,22 @@ +{# /app/views/_includes/summary-lists/rows/sx-number.njk #} + +{% if participant.sxNumber %} + {% set sxNumberValueHtml %} + {{ appCopyToClipboard({ + text: participant.sxNumber, + type: "value", + html: '' + participant.sxNumber + '', + label: "SX number" + }) }} + {% endset %} +{% endif %} + {% set sxNumberRow = { key: { text: "SX number" }, value: { - text: participant.sxNumber + html: sxNumberValueHtml }, actions: { items: [ @@ -16,4 +29,4 @@ } } | handleSummaryListMissingInformation(showNotProvidedText) %} -{{ appSummaryListRow(sxNumberRow) }} \ No newline at end of file +{{ appSummaryListRow(sxNumberRow) }} diff --git a/app/views/_templates/layout-modal-form.html b/app/views/_templates/layout-modal-form.html index 3b5c039d1..27feaad57 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 dc506296b..65265616b 100755 --- a/app/views/_templates/layout.html +++ b/app/views/_templates/layout.html @@ -29,6 +29,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 5ff41b3f9..47625ce40 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') } }) diff --git a/app/views/appointments/appointment.html b/app/views/appointments/appointment.html index 29a39c0df..3e2322e77 100644 --- a/app/views/appointments/appointment.html +++ b/app/views/appointments/appointment.html @@ -167,6 +167,15 @@ {% endif %} {% endset %} + {% set accessionNumberHtml %} + {{ appCopyToClipboard({ + text: appointment.accessionNumber, + type: "value", + html: '' + (appointment.accessionNumber | formatAccessionNumber) + '', + label: "accession number" + }) }} + {% endset %} + {% set appointmentHtml %} {{ summaryList({ rows: [ @@ -275,7 +284,7 @@ text: "Accession number" }, value: { - html: '' + (appointment.accessionNumber | formatAccessionNumber) + '' + html: accessionNumberHtml }, actions: { items: [] diff --git a/app/views/appointments/images-manual.html b/app/views/appointments/images-manual.html index 2cb45ee53..18db5b9c3 100644 --- a/app/views/appointments/images-manual.html +++ b/app/views/appointments/images-manual.html @@ -108,15 +108,34 @@

Observations during mammogram

{% if showManualParticipantDetails %}

Manually add participant details

Set up an unscheduled appointment using the following information so mammograms can be assigned correctly:

+ + {% set accessionNumberHtml %} + {{ appCopyToClipboard({ + text: appointment.accessionNumber, + type: "value", + html: '' + (appointment.accessionNumber | formatAccessionNumber) + '', + label: "accession number" + }) }} + {% endset %} + + {% set nhsNumberHtml %} + {{ appCopyToClipboard({ + text: participant.medicalInformation.nhsNumber, + type: "value", + html: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '', + label: "NHS number" + }) }} + {% endset %} + {{ summaryList({ rows: [ { key: { html: "Accession number" }, - value: { html: '' + (appointment.accessionNumber | formatAccessionNumber) + '' } + value: { html: accessionNumberHtml } }, { key: { text: "NHS number" }, - value: { html: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '' } + value: { html: nhsNumberHtml } }, { key: { text: "First names" }, diff --git a/app/views/style-guide/_nav.njk b/app/views/style-guide/_nav.njk index 8de23f9bd..3f16c8a6d 100644 --- a/app/views/style-guide/_nav.njk +++ b/app/views/style-guide/_nav.njk @@ -12,6 +12,7 @@ { text: "Overview", href: "/style-guide", current: currentPage == "index" }, { text: "Autocomplete", href: "/style-guide/autocomplete", current: currentPage == "autocomplete" }, { text: "Button menu", href: "/style-guide/button-menu", current: currentPage == "button-menu" }, + { text: "Copy to clipboard", href: "/style-guide/copy-to-clipboard", current: currentPage == "copy-to-clipboard" }, { text: "Count", href: "/style-guide/count", current: currentPage == "count" }, { text: "Details", href: "/style-guide/details", current: currentPage == "details" }, { text: "Forward link", href: "/style-guide/forward-link", current: currentPage == "forward-link" }, diff --git a/app/views/style-guide/copy-to-clipboard.html b/app/views/style-guide/copy-to-clipboard.html new file mode 100644 index 000000000..cedb75d8b --- /dev/null +++ b/app/views/style-guide/copy-to-clipboard.html @@ -0,0 +1,123 @@ +{# app/views/style-guide/copy-to-clipboard.html #} + +{% extends 'layout-app.html' %} + +{% from '_components/copy-to-clipboard/macro.njk' import appCopyToClipboard %} +{% from '_components/status-bar/macro.njk' import appStatusBar %} + +{% set hideBackLink = true %} +{% set currentPage = "copy-to-clipboard" %} +{% set gridColumn = "nhsuk-grid-column-full" %} + +{% block pageNavigation %} + {% include "style-guide/_nav.njk" %} +{% endblock %} + +{% block pageContent %} + +

Copy to clipboard

+ +

A small button placed beside a value, such as an NHS number, that copies it to the clipboard. Whitespace is stripped from the copied text, so a formatted number is copied as digits only. Requires JavaScript; the button is not shown without it.

+ +

Macro: appCopyToClipboard(params)

+ +

Parameters

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
NameDescription
textRequired. The string to copy.
type"text" (default) shows a “Copy” label. "icon" shows a copy icon only.
labelWhat is being copied, for the accessible name, for example "NHS number" gives “Copy NHS number”.
classesAdditional classes for the button.
+ +

Text

+ +
+

NHS number: 485 777 3456 {{ appCopyToClipboard({ text: "4857773456", label: "NHS number" }) }}

+
+
{% raw %}{{ appCopyToClipboard({
+  text: "4857773456",
+  label: "NHS number"
+}) }}{% endraw %}
+ +

Icon

+ +
+

NHS number: 485 777 3456 {{ appCopyToClipboard({ text: "4857773456", type: "icon", label: "NHS number" }) }}

+
+
{% raw %}{{ appCopyToClipboard({
+  text: "4857773456",
+  type: "icon",
+  label: "NHS number"
+}) }}{% endraw %}
+ +

Value

+ +
+

NHS number: {{ appCopyToClipboard({ + text: "4857773456", + type: "value", + html: '485 777 3456', + label: "NHS number" + }) }}

+
+
{% raw %}{{ appCopyToClipboard({
+  text: "4857773456",
+  type: "value",
+  html: '485 777 3456',
+  label: "NHS number"
+}) }}{% endraw %}
+ +

In the status bar

+ +

The button inherits the colour of its surroundings, so it works on dark backgrounds without extra styling.

+ +{% set nhsNumberHtml %} + {{ appCopyToClipboard({ + text: "4857773456", + type: "value", + html: '485 777 3456', + label: "NHS number" + }) }} +{% endset %} + +{% set accessionHtml %} + ECX 20260917 68669{{ appCopyToClipboard({ text: "ECX2026091768669", label: "accession number" }) }} +{% endset %} + +
+ {{ appStatusBar({ + classes: "", + rows: [ + { + items: [ + { key: "Participant:", value: "Janet Williams" }, + { key: "NHS:", value: nhsNumberHtml }, + { key: "Accn:", value: accessionHtml } + ] + } + ] + }) }} +
+ +{% endblock %}