Skip to content
Merged
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
87 changes: 87 additions & 0 deletions app/assets/javascript/copy-to-clipboard.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
1 change: 1 addition & 0 deletions app/assets/sass/_app-styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
7 changes: 7 additions & 0 deletions app/assets/sass/_typography.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
140 changes: 140 additions & 0 deletions app/assets/sass/components/_copy-to-clipboard.scss
Original file line number Diff line number Diff line change
@@ -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;
}
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 %}
62 changes: 62 additions & 0 deletions app/views/_components/copy-to-clipboard/template.njk
Original file line number Diff line number Diff line change
@@ -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 #}
<button
class="app-copy-to-clipboard{% if type != "text" %} app-copy-to-clipboard--{{ type }}{% endif %}{% if params.classes %} {{ params.classes }}{% endif %}"
type="button"
data-module="app-copy-to-clipboard"
data-copy-text="{{ params.text }}"
data-copied-announcement="{{ copiedAnnouncement }}"
{% if type != "value" %}aria-label="{{ defaultAriaLabel }}"{% endif %}
hidden
>
{% if type == "icon" or type == "value" %}

{# Whitespace is stripped around the icons so the gap before them comes only
from the styles, and the copy and tick icons sit in the same place #}
{% if type == "value" %}
<span class="nhsuk-u-visually-hidden">{{ defaultAriaLabel }} </span>
<span class="app-copy-to-clipboard__value">{{ params.html | safe }}</span>
{%- endif -%}

<span class="app-copy-to-clipboard__default app-copy-to-clipboard__icon" aria-hidden="true">
{{ appIcon("copy") }}
</span>{#
#}<span class="app-copy-to-clipboard__copied app-copy-to-clipboard__icon" aria-hidden="true">
{{ appIcon("tick") }}
</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>
7 changes: 7 additions & 0 deletions app/views/_components/icon/macro.njk
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@
{%- if params and params.title %}<title>{{ params.title }}</title>{% endif %}
<path d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm0 3.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-1.5 6.5V17a1.5 1.5 0 0 0 3 0V12a1.5 1.5 0 0 0-3 0Z">
</svg>
{%- elseif name == "copy" -%}
{# Two-pages copy icon (GitHub Octicons, MIT). Drawn on a 16px grid, unlike the NHS icons #}
<svg class="nhsuk-icon nhsuk-icon--copy{% if params and params.classes %} {{ params.classes }}{% endif %}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" focusable="false"{% if params and params.title %} role="img" aria-label="{{ params.title }}"{% else %} aria-hidden="true"{% endif %}>
{%- if params and params.title %}<title>{{ params.title }}</title>{% endif %}
<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>
{%- else -%}
{{- nhsukIcon(name, params) -}}
{%- endif -%}
Expand Down
20 changes: 18 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,17 @@

{# Worklist accession number with inline worklist status #}
{% set accessionNumberFormatted = appointment.accessionNumber | formatAccessionNumber %}
{% set accessionNumberHtml %}
{{ appCopyToClipboard({
text: appointment.accessionNumber,
type: "value",
html: '<span class="nhsuk-u-font-code nhsuk-u-nowrap">' + accessionNumberFormatted + '</span>',
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: accessionNumberHtml + '<span class="app-status-bar__worklist-status">' + worklistStatusHtml + '</span>'
}) %}

{# Appointment type #}
Expand Down Expand Up @@ -99,9 +107,17 @@
}) %}

{# NHS Number #}
{% set nhsNumberHtml %}
{{ appCopyToClipboard({
text: participant.medicalInformation.nhsNumber,
type: "value",
html: '<span class="nhsuk-u-font-code nhsuk-u-nowrap">' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '</span>',
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: nhsNumberHtml
}) %}

{{ appStatusBar({
Expand Down
Loading