@ministryofjustice/hmpps-connect-dps-shared-items 4.4.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,11 @@
1
- import { AutosuggestAddress } from './autosuggest-address'
1
+ import { AutosuggestAddress } from './autosuggest-address.js';
2
2
 
3
- export function init() {
4
- // Setup all address autosuggest instances:
5
- const addressAutosuggests = [...document.querySelectorAll('.hmpps-js-address-autosuggest')]
6
-
7
- if (addressAutosuggests.length) {
8
- addressAutosuggests.forEach(addressAutosuggest => new AutosuggestAddress(addressAutosuggest))
9
- }
3
+ function init() {
4
+ // Setup all address autosuggest instances:
5
+ const addressAutosuggests = [...document.querySelectorAll('.hmpps-js-address-autosuggest')];
6
+ if (addressAutosuggests.length) {
7
+ addressAutosuggests.forEach(addressAutosuggest => new AutosuggestAddress(addressAutosuggest));
8
+ }
10
9
  }
10
+
11
+ export { init };
@@ -1,4 +1,6 @@
1
+ @use "sass:color";
1
2
  @use "sass:selector";
3
+ @use "govuk-frontend/dist/govuk/helpers/colour";
2
4
 
3
5
  @mixin unify-parent($child) {
4
6
  @at-root #{selector.unify(&, $child)} {
@@ -20,7 +22,7 @@
20
22
  }
21
23
 
22
24
  @media print {
23
- margin-bottom: 0px !important;
25
+ margin-bottom: 0 !important;
24
26
  font-size: 12px;
25
27
  color: black !important;
26
28
  border: none !important;
@@ -44,7 +46,7 @@
44
46
  }
45
47
 
46
48
  &:focus {
47
- box-shadow: 0 0 0 3px $govuk-focus-colour;
49
+ box-shadow: 0 0 0 3px colour.govuk-functional-colour(focus);
48
50
  }
49
51
  }
50
52
  }
@@ -186,7 +188,7 @@
186
188
  }
187
189
 
188
190
  &--reverse-cohorting-unit {
189
- @include alertType(mix(white, #00703c, 20%));
191
+ @include alertType(color.mix(white, #00703c, 20%));
190
192
  }
191
193
 
192
194
  &--protective-isolation-unit {
@@ -220,4 +222,3 @@
220
222
  min-height: 30px;
221
223
  }
222
224
  }
223
-
@@ -13,4 +13,4 @@
13
13
  {{ alertData.alert.label }}
14
14
  </div>
15
15
  {% endif %}
16
- {% endmacro %}
16
+ {% endmacro %}
@@ -0,0 +1,148 @@
1
+ @use "sass:math";
2
+ @use "govuk-frontend/dist/govuk/helpers/colour";
3
+ @use "govuk-frontend/dist/govuk/helpers/typography";
4
+ @use "govuk-frontend/dist/govuk/settings/measurements";
5
+
6
+ .hmpps-modal {
7
+ display: flex;
8
+ z-index: 2;
9
+
10
+ &[hidden] {
11
+ display: none !important;
12
+ }
13
+ }
14
+
15
+ .hmpps-modal, .hmpps-modal__backdrop {
16
+ position: fixed;
17
+ top: 0;
18
+ right: 0;
19
+ bottom: 0;
20
+ left: 0;
21
+ }
22
+
23
+ .hmpps-modal__backdrop {
24
+ background: rgb(colour.govuk-colour("black"), 0.6);
25
+ }
26
+
27
+ .hmpps-modal__dialogue-box {
28
+ display: block;
29
+ padding: 0;
30
+ margin: auto;
31
+ min-width: 400px;
32
+ max-width: 800px;
33
+ min-height: 300px;
34
+ max-height: 90vh;
35
+ z-index: 2;
36
+ position: relative;
37
+ background: colour.govuk-colour("white");
38
+ border: 3px solid colour.govuk-colour("black");
39
+
40
+ &:focus {
41
+ outline: none;
42
+ box-shadow: 0 0 0 3px colour.govuk-functional-colour(focus);
43
+ }
44
+
45
+ header {
46
+ display: flex;
47
+ gap: 20px;
48
+ justify-content: space-between;
49
+ align-items: center;
50
+ background: colour.govuk-colour("black");
51
+ color: colour.govuk-colour("white");
52
+ margin-top: 0;
53
+ padding: 0 0 0 measurements.$govuk-gutter;
54
+ height: 45px;
55
+
56
+ h1 {
57
+ @include typography.govuk-font($size: 19, $weight: "bold");
58
+ margin: 0;
59
+ }
60
+ }
61
+ }
62
+
63
+ .hmpps-modal__close-button {
64
+ all: unset;
65
+ appearance: none;
66
+ width: 40px;
67
+ height: 40px;
68
+ text-align: center;
69
+ padding-top: 3px;
70
+ margin-top: -3px;
71
+ cursor: pointer;
72
+ background: colour.govuk-colour("black");
73
+ color: colour.govuk-colour("white");
74
+
75
+ &:hover, &:focus {
76
+ background: colour.govuk-functional-colour(focus);
77
+ color: colour.govuk-colour("black");
78
+
79
+ @media (forced-colors: active) and (prefers-color-scheme: dark) {
80
+ background: colour.govuk-colour("black");
81
+ color: colour.govuk-colour("yellow");
82
+ }
83
+ }
84
+ }
85
+
86
+ .hmpps-modal__content {
87
+ $height: 300px;
88
+ $buffer: 70px;
89
+ padding: measurements.$govuk-gutter-half measurements.$govuk-gutter 0 measurements.$govuk-gutter;
90
+ max-height: calc(90vh - #{$buffer});
91
+ min-height: #{$height - $buffer};
92
+ overflow: auto;
93
+
94
+ .hmpps-modal__loading-graphic {
95
+ width: 50px;
96
+ display: block;
97
+ margin: #{math.div($height, 2) - $buffer} auto auto;
98
+
99
+ g {
100
+ animation: hmpps-modal__loading-graphic-animation 1s step-end infinite;
101
+ transform-origin: center;
102
+ fill: colour.govuk-colour("black");
103
+
104
+ @media (forced-colors: active) and (prefers-color-scheme: dark) {
105
+ fill: colour.govuk-colour("white");
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ @keyframes hmpps-modal__loading-graphic-animation {
112
+ 8.3% {
113
+ transform: rotate(30deg)
114
+ }
115
+ 16.6% {
116
+ transform: rotate(60deg)
117
+ }
118
+ 25% {
119
+ transform: rotate(90deg)
120
+ }
121
+ 33.3% {
122
+ transform: rotate(120deg)
123
+ }
124
+ 41.6% {
125
+ transform: rotate(150deg)
126
+ }
127
+ 50% {
128
+ transform: rotate(180deg)
129
+ }
130
+ 58.3% {
131
+ transform: rotate(210deg)
132
+ }
133
+ 66.6% {
134
+ transform: rotate(240deg)
135
+ }
136
+ 75% {
137
+ transform: rotate(270deg)
138
+ }
139
+ 83.3% {
140
+ transform: rotate(300deg)
141
+ }
142
+ 91.6% {
143
+ transform: rotate(330deg)
144
+ }
145
+ 100% {
146
+ transform: rotate(360deg)
147
+ }
148
+ }
@@ -0,0 +1,171 @@
1
+ /* eslint-disable import/prefer-default-export, @stylistic/lines-between-class-members */
2
+ /** Modal dialogue box component */
3
+ class Modal {
4
+ root;
5
+ static showTriggerClassName = 'hmpps-modal__trigger-show';
6
+ static hideTriggerClassName = 'hmpps-modal__trigger-hide';
7
+ static modals = new Map();
8
+ /** Initialise all modal components rendered in the document; none will automatically open */
9
+ static init() {
10
+ document.querySelectorAll('[data-module="hmpps-modal"]').forEach(element => {
11
+ const modal = new this(element);
12
+ this.modals.set(modal.id, modal);
13
+ });
14
+ document.querySelectorAll(`.${this.showTriggerClassName}`).forEach(element => {
15
+ const modalId = element.dataset.hmppsModalId;
16
+ if (modalId) {
17
+ element.addEventListener('click', event => {
18
+ const modal = this.modals.get(modalId);
19
+ if (modal) {
20
+ event.preventDefault();
21
+ if (element.dataset.hmppsModalUrl) {
22
+ modal.load(element.dataset.hmppsModalUrl);
23
+ }
24
+ else {
25
+ modal.show();
26
+ }
27
+ }
28
+ });
29
+ }
30
+ });
31
+ }
32
+ /** Find an instance of the `Modal` class to programmatically control it */
33
+ static getById(modalId) {
34
+ return this.modals.get(modalId);
35
+ }
36
+ /** Hide all showing modals */
37
+ static hideAll() {
38
+ this.modals.forEach(modal => {
39
+ if (modal.isShowing) {
40
+ modal.hide();
41
+ }
42
+ });
43
+ }
44
+ dialog;
45
+ content;
46
+ showing;
47
+ constructor(root) {
48
+ this.root = root;
49
+ this.root.hidden = true;
50
+ this.showing = false;
51
+ this.dialog = root.querySelector('.hmpps-modal__dialogue-box');
52
+ this.content = root.querySelector('.hmpps-modal__content');
53
+ this.show = this.show.bind(this);
54
+ this.hide = this.hide.bind(this);
55
+ this.maintainFocus = this.maintainFocus.bind(this);
56
+ this.handleKeyDown = this.handleKeyDown.bind(this);
57
+ this.attachHideTriggers(this.root.querySelectorAll(`.${Modal.hideTriggerClassName}`));
58
+ }
59
+ get id() {
60
+ return this.root.id;
61
+ }
62
+ get isShowing() {
63
+ return this.showing;
64
+ }
65
+ attachHideTriggers(elements) {
66
+ elements.forEach(element => element.addEventListener('click', event => {
67
+ this.hide();
68
+ event.preventDefault();
69
+ }));
70
+ }
71
+ handleKeyDown(event) {
72
+ if (event.key === 'Escape') {
73
+ this.hide();
74
+ }
75
+ else if (event.key === 'Tab') {
76
+ this.trapFocus(event);
77
+ }
78
+ }
79
+ previouslyFocused;
80
+ takeFocus() {
81
+ if (document.activeElement) {
82
+ this.previouslyFocused = document.activeElement;
83
+ }
84
+ this.dialog.focus();
85
+ }
86
+ maintainFocus(event) {
87
+ const isInModal = event.target?.closest('[aria-modal="true"]');
88
+ if (!isInModal) {
89
+ this.takeFocus();
90
+ }
91
+ }
92
+ revertFocus() {
93
+ if (this.previouslyFocused && this.previouslyFocused.focus) {
94
+ this.previouslyFocused.focus();
95
+ }
96
+ }
97
+ trapFocus(event) {
98
+ const focusableChildren = this.getFocusableChildren();
99
+ if (focusableChildren.length === 0) {
100
+ event.preventDefault();
101
+ return;
102
+ }
103
+ const focusedItemIndex = focusableChildren.indexOf(document.activeElement);
104
+ const lastIndex = focusableChildren.length - 1;
105
+ if (event.shiftKey && focusedItemIndex === 0) {
106
+ focusableChildren[lastIndex].focus();
107
+ event.preventDefault();
108
+ }
109
+ else if (!event.shiftKey && focusedItemIndex === lastIndex) {
110
+ focusableChildren[0].focus();
111
+ event.preventDefault();
112
+ }
113
+ }
114
+ getFocusableChildren() {
115
+ return Array.from(this.root.querySelectorAll(`
116
+ a[href]:not([disabled]),
117
+ button:not([disabled]),
118
+ textarea:not([disabled]),
119
+ input[type="text"]:not([disabled]),
120
+ input[type="radio"]:not([disabled]),
121
+ input[type="checkbox"]:not([disabled]),
122
+ select:not([disabled])
123
+ `)).filter(element => element.offsetWidth || element.offsetHeight || element.getClientRects().length);
124
+ }
125
+ /** Show the modal dialog box */
126
+ show() {
127
+ Modal.hideAll();
128
+ this.root.removeAttribute('hidden');
129
+ this.dialog.ariaModal = 'true';
130
+ this.takeFocus();
131
+ document.addEventListener('keydown', this.handleKeyDown);
132
+ document.body.addEventListener('focus', this.maintainFocus, true);
133
+ document.documentElement.style.overflowY = 'hidden';
134
+ this.showing = true;
135
+ }
136
+ /** Hide the modal dialog box */
137
+ hide() {
138
+ this.root.hidden = true;
139
+ this.dialog.removeAttribute('aria-modal');
140
+ document.removeEventListener('keydown', this.handleKeyDown);
141
+ document.body.removeEventListener('focus', this.maintainFocus, true);
142
+ this.revertFocus();
143
+ document.documentElement.style.overflowY = '';
144
+ this.showing = false;
145
+ }
146
+ /** Show modal dialog box with loading graphic while loading HTML content from `url` */
147
+ async load(url) {
148
+ this.replaceContentWithTemplate('loading-graphic');
149
+ this.show();
150
+ try {
151
+ const response = await fetch(url);
152
+ if (response?.ok) {
153
+ this.content.innerHTML = await response.text();
154
+ this.attachHideTriggers(this.content.querySelectorAll(`.${Modal.hideTriggerClassName}`));
155
+ }
156
+ else {
157
+ this.replaceContentWithTemplate('error-message');
158
+ }
159
+ }
160
+ catch {
161
+ this.replaceContentWithTemplate('error-message');
162
+ }
163
+ }
164
+ replaceContentWithTemplate(template) {
165
+ this.content.replaceChildren();
166
+ const loadingGraphic = document.importNode(this.root.querySelector(`.hmpps-modal__${template}-template`).content, true);
167
+ this.content.appendChild(loadingGraphic);
168
+ }
169
+ }
170
+
171
+ export { Modal };
@@ -0,0 +1,203 @@
1
+ /* eslint-disable import/prefer-default-export, @stylistic/lines-between-class-members */
2
+
3
+ /** Modal dialogue box component */
4
+ export class Modal {
5
+ public static readonly showTriggerClassName = 'hmpps-modal__trigger-show'
6
+ public static readonly hideTriggerClassName = 'hmpps-modal__trigger-hide'
7
+
8
+ private static readonly modals = new Map<string, Modal>()
9
+
10
+ /** Initialise all modal components rendered in the document; none will automatically open */
11
+ public static init(): void {
12
+ document.querySelectorAll<HTMLDivElement>('[data-module="hmpps-modal"]').forEach(element => {
13
+ const modal = new this(element)
14
+ this.modals.set(modal.id, modal)
15
+ })
16
+ document.querySelectorAll<HTMLElement>(`.${this.showTriggerClassName}`).forEach(element => {
17
+ const modalId = element.dataset.hmppsModalId
18
+ if (modalId) {
19
+ element.addEventListener('click', event => {
20
+ const modal = this.modals.get(modalId)
21
+ if (modal) {
22
+ event.preventDefault()
23
+ if (element.dataset.hmppsModalUrl) {
24
+ modal.load(element.dataset.hmppsModalUrl)
25
+ } else {
26
+ modal.show()
27
+ }
28
+ }
29
+ })
30
+ }
31
+ })
32
+ }
33
+
34
+ /** Find an instance of the `Modal` class to programmatically control it */
35
+ public static getById(modalId: string): Modal | undefined {
36
+ return this.modals.get(modalId)
37
+ }
38
+
39
+ /** Hide all showing modals */
40
+ public static hideAll(): void {
41
+ this.modals.forEach(modal => {
42
+ if (modal.isShowing) {
43
+ modal.hide()
44
+ }
45
+ })
46
+ }
47
+
48
+ private readonly dialog: HTMLDialogElement
49
+ private readonly content: HTMLElement
50
+ private showing: boolean
51
+
52
+ constructor(private readonly root: HTMLDivElement) {
53
+ this.root.hidden = true
54
+ this.showing = false
55
+
56
+ this.dialog = root.querySelector('.hmpps-modal__dialogue-box')!
57
+ this.content = root.querySelector('.hmpps-modal__content')!
58
+
59
+ this.show = this.show.bind(this)
60
+ this.hide = this.hide.bind(this)
61
+ this.maintainFocus = this.maintainFocus.bind(this)
62
+ this.handleKeyDown = this.handleKeyDown.bind(this)
63
+
64
+ this.attachHideTriggers(this.root.querySelectorAll(`.${Modal.hideTriggerClassName}`))
65
+ }
66
+
67
+ public get id(): string {
68
+ return this.root.id
69
+ }
70
+
71
+ public get isShowing(): boolean {
72
+ return this.showing
73
+ }
74
+
75
+ private attachHideTriggers(elements: { forEach: (callback: (element: HTMLElement) => void) => void }): void {
76
+ elements.forEach(element =>
77
+ element.addEventListener('click', event => {
78
+ this.hide()
79
+ event.preventDefault()
80
+ }),
81
+ )
82
+ }
83
+
84
+ private handleKeyDown(event: KeyboardEvent): void {
85
+ if (event.key === 'Escape') {
86
+ this.hide()
87
+ } else if (event.key === 'Tab') {
88
+ this.trapFocus(event)
89
+ }
90
+ }
91
+
92
+ private previouslyFocused: HTMLElement | undefined
93
+
94
+ private takeFocus(): void {
95
+ if (document.activeElement) {
96
+ this.previouslyFocused = document.activeElement as HTMLElement
97
+ }
98
+ this.dialog.focus()
99
+ }
100
+
101
+ private maintainFocus(event: FocusEvent): void {
102
+ const isInModal = (event.target as HTMLElement)?.closest('[aria-modal="true"]')
103
+ if (!isInModal) {
104
+ this.takeFocus()
105
+ }
106
+ }
107
+
108
+ private revertFocus(): void {
109
+ if (this.previouslyFocused && this.previouslyFocused.focus) {
110
+ this.previouslyFocused.focus()
111
+ }
112
+ }
113
+
114
+ private trapFocus(event: KeyboardEvent): void {
115
+ const focusableChildren = this.getFocusableChildren()
116
+ if (focusableChildren.length === 0) {
117
+ event.preventDefault()
118
+ return
119
+ }
120
+ const focusedItemIndex = focusableChildren.indexOf(document.activeElement as HTMLElement)
121
+ const lastIndex = focusableChildren.length - 1
122
+
123
+ if (event.shiftKey && focusedItemIndex === 0) {
124
+ focusableChildren[lastIndex].focus()
125
+ event.preventDefault()
126
+ } else if (!event.shiftKey && focusedItemIndex === lastIndex) {
127
+ focusableChildren[0].focus()
128
+ event.preventDefault()
129
+ }
130
+ }
131
+
132
+ private getFocusableChildren(): HTMLElement[] {
133
+ return Array.from(
134
+ this.root.querySelectorAll<HTMLElement>(`
135
+ a[href]:not([disabled]),
136
+ button:not([disabled]),
137
+ textarea:not([disabled]),
138
+ input[type="text"]:not([disabled]),
139
+ input[type="radio"]:not([disabled]),
140
+ input[type="checkbox"]:not([disabled]),
141
+ select:not([disabled])
142
+ `),
143
+ ).filter(element => element.offsetWidth || element.offsetHeight || element.getClientRects().length)
144
+ }
145
+
146
+ /** Show the modal dialog box */
147
+ public show(): void {
148
+ Modal.hideAll()
149
+
150
+ this.root.removeAttribute('hidden')
151
+ this.dialog.ariaModal = 'true'
152
+
153
+ this.takeFocus()
154
+
155
+ document.addEventListener('keydown', this.handleKeyDown)
156
+ document.body.addEventListener('focus', this.maintainFocus, true)
157
+
158
+ document.documentElement.style.overflowY = 'hidden'
159
+
160
+ this.showing = true
161
+ }
162
+
163
+ /** Hide the modal dialog box */
164
+ public hide(): void {
165
+ this.root.hidden = true
166
+ this.dialog.removeAttribute('aria-modal')
167
+
168
+ document.removeEventListener('keydown', this.handleKeyDown)
169
+ document.body.removeEventListener('focus', this.maintainFocus, true)
170
+
171
+ this.revertFocus()
172
+
173
+ document.documentElement.style.overflowY = ''
174
+
175
+ this.showing = false
176
+ }
177
+
178
+ /** Show modal dialog box with loading graphic while loading HTML content from `url` */
179
+ public async load(url: string | URL): Promise<void> {
180
+ this.replaceContentWithTemplate('loading-graphic')
181
+ this.show()
182
+ try {
183
+ const response = await fetch(url)
184
+ if (response?.ok) {
185
+ this.content.innerHTML = await response.text()
186
+ this.attachHideTriggers(this.content.querySelectorAll(`.${Modal.hideTriggerClassName}`))
187
+ } else {
188
+ this.replaceContentWithTemplate('error-message')
189
+ }
190
+ } catch {
191
+ this.replaceContentWithTemplate('error-message')
192
+ }
193
+ }
194
+
195
+ private replaceContentWithTemplate(template: 'error-message' | 'loading-graphic'): void {
196
+ this.content.replaceChildren()
197
+ const loadingGraphic = document.importNode(
198
+ this.root.querySelector<HTMLTemplateElement>(`.hmpps-modal__${template}-template`)!.content,
199
+ true,
200
+ )
201
+ this.content.appendChild(loadingGraphic)
202
+ }
203
+ }
@@ -0,0 +1,48 @@
1
+ {#
2
+ Parameters as JSDoc:
3
+ /**
4
+ * @param {Object} [params]
5
+ * @param {string} params.id - The id attribute added to the component’s wrapper element.
6
+ * This id is needed to lookup or trigger the modal to show.
7
+ * @param {string} params.title - The dialogue box’s title.
8
+ * @param {string} [params.classes] - Optional classes to add to the component’s container element.
9
+ */
10
+ #}
11
+ {% macro hmppsModal(params) %}
12
+ <div id="{{ params.id }}" class="hmpps-modal {{ params.classes }}" data-module="hmpps-modal" hidden>
13
+ <div class="hmpps-modal__backdrop hmpps-modal__trigger-hide"></div>
14
+ <dialog class="hmpps-modal__dialogue-box" tabindex="0">
15
+ <header>
16
+ <h1 id="{{ params.id }}-modal-title">{{ params.title }}</h1>
17
+ <button class="hmpps-modal__close-button hmpps-modal__trigger-hide" aria-label="Close">
18
+ <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
19
+ <path d="M18 3.405 14.595 0 9 5.595 3.405 0 0 3.406 5.595 9 0 14.594 3.406 18 9 12.406 14.595 18 18 14.595 12.405 9Z" fill="currentColor"/>
20
+ </svg>
21
+ </button>
22
+ </header>
23
+ <section class="hmpps-modal__content">
24
+ {% if caller %}
25
+ {{ caller() }}
26
+ {% endif %}
27
+ </section>
28
+ </dialog>
29
+
30
+ <template class="hmpps-modal__loading-graphic-template">
31
+ <svg class="hmpps-modal__loading-graphic" width="50" height="50" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
32
+ <g>
33
+ <rect x="11" y="1" width="2" height="5" opacity=".14"/>
34
+ <rect x="11" y="1" width="2" height="5" transform="rotate(30 12 12)" opacity=".29"/>
35
+ <rect x="11" y="1" width="2" height="5" transform="rotate(60 12 12)" opacity=".43"/>
36
+ <rect x="11" y="1" width="2" height="5" transform="rotate(90 12 12)" opacity=".57"/>
37
+ <rect x="11" y="1" width="2" height="5" transform="rotate(120 12 12)" opacity=".71"/>
38
+ <rect x="11" y="1" width="2" height="5" transform="rotate(150 12 12)" opacity=".86"/>
39
+ <rect x="11" y="1" width="2" height="5" transform="rotate(180 12 12)"/>
40
+ </g>
41
+ </svg>
42
+ </template>
43
+ <template class="hmpps-modal__error-message-template">
44
+ <h2 class="govuk-heading-s">Something went wrong</h2>
45
+ <p>The error has been logged. Please try again.</p>
46
+ </template>
47
+ </div>
48
+ {% endmacro %}