Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions app/assets/javascript/copy-to-clipboard.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
1 change: 1 addition & 0 deletions app/assets/sass/_app-styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
64 changes: 64 additions & 0 deletions app/assets/sass/components/_copy-to-clipboard.scss
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions app/views/_components/copy-to-clipboard/macro.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{# app/views/_components/copy-to-clipboard/macro.njk #}

{% macro appCopyToClipboard(params) %}
{%- include "./template.njk" -%}
{% endmacro %}
51 changes: 51 additions & 0 deletions app/views/_components/copy-to-clipboard/template.njk
Original file line number Diff line number Diff line change
@@ -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 %}

<button
class="app-copy-to-clipboard{% if type == "icon" %} app-copy-to-clipboard--icon{% endif %}{% if params.classes %} {{ params.classes }}{% endif %}"
type="button"
data-module="app-copy-to-clipboard"
data-copy-text="{{ params.text }}"
aria-label="{{ defaultAriaLabel }}"
hidden
>
{% if type == "icon" %}

<span class="app-copy-to-clipboard__default" aria-hidden="true">
{# Two-pages copy icon (GitHub Octicons, MIT) #}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" focusable="false" aria-hidden="true">
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/>
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>
</svg>
</span>

<span class="app-copy-to-clipboard__copied" aria-hidden="true">
{# Checkmark icon (GitHub Octicons, MIT) #}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" focusable="false" aria-hidden="true">
<path fill-rule="evenodd" d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/>
</svg>
</span>

{% else %}

<span class="app-copy-to-clipboard__default" aria-hidden="true">Copy</span>
<span class="app-copy-to-clipboard__copied" aria-hidden="true">Copied</span>

{% endif %}
</button>
10 changes: 8 additions & 2 deletions app/views/_includes/appointment-status-bar.njk
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<abbr title="Accession number">Accn</abbr>:',
value: '<span class="nhsuk-u-font-code nhsuk-u-nowrap">' + accessionNumberFormatted + '</span><span class="app-status-bar__worklist-status">' + worklistStatusHtml + '</span>'
value: '<span class="nhsuk-u-font-code nhsuk-u-nowrap">' + accessionNumberFormatted + '</span>' + accessionCopyButtonHtml + '<span class="app-status-bar__worklist-status">' + worklistStatusHtml + '</span>'
}) %}

{# Appointment type #}
Expand Down Expand Up @@ -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: '<span class="nhsuk-u-font-code nhsuk-u-nowrap">' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '</span>'
value: '<span class="nhsuk-u-font-code nhsuk-u-nowrap">' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '</span>' + nhsCopyButtonHtml
}) %}

{{ appStatusBar({
Expand Down
1 change: 1 addition & 0 deletions app/views/_includes/scripts.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<!-- Add any custom scripts -->
{% if currentPage.indexOf("/prototype-admin/") === -1 %}
<script type="module" src="/assets/javascript/is-sticky.js"></script>
<script type="module" src="/assets/javascript/copy-to-clipboard.js"></script>
<script type="module" src="/assets/javascript/button-menu.js"></script>
<script type="module" src="/assets/javascript/modal.js"></script>
<script type="module" src="/assets/javascript/scroll-to-section.js"></script>
Expand Down
1 change: 1 addition & 0 deletions app/views/_templates/layout-modal-form.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down
1 change: 1 addition & 0 deletions app/views/_templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down
4 changes: 2 additions & 2 deletions app/views/admin/mammogram-sets.html
Original file line number Diff line number Diff line change
Expand Up @@ -1220,7 +1220,7 @@ <h3>Annotations</h3>
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(() => {
Expand All @@ -1236,7 +1236,7 @@ <h3>Annotations</h3>
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
alert('Copied!')
alert('Copied')
}
})

Expand Down