@ministryofjustice/hmpps-connect-dps-shared-items 5.1.0 → 5.2.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.
- package/dist/assets/dps/components/address-autosuggest/_address-autosuggest.scss +7 -0
- package/dist/assets/dps/components/address-autosuggest/abortable-fetch.d.ts +26 -0
- package/dist/assets/dps/components/address-autosuggest/abortable-fetch.js +20 -8
- package/dist/assets/dps/components/address-autosuggest/abortable-fetch.ts +76 -0
- package/dist/assets/dps/components/address-autosuggest/autosuggest-address.d.ts +18 -0
- package/dist/assets/dps/components/address-autosuggest/autosuggest-address.js +27 -13
- package/dist/assets/dps/components/address-autosuggest/autosuggest-address.ts +108 -0
- package/dist/assets/dps/components/address-autosuggest/autosuggest-ui.d.ts +82 -0
- package/dist/assets/dps/components/address-autosuggest/autosuggest-ui.js +83 -53
- package/dist/assets/dps/components/address-autosuggest/autosuggest-ui.ts +459 -0
- package/dist/assets/dps/components/address-autosuggest/index.d.ts +1 -0
- package/dist/assets/dps/components/address-autosuggest/index.js +4 -4
- package/dist/assets/dps/components/address-autosuggest/index.ts +9 -0
- package/dist/assets/dps/components/alert-flag/_alert-flag.scss +2 -1
- package/dist/assets/dps/components/modal/index.d.ts +34 -0
- package/dist/assets/dps/components/modal/macro.njk +2 -1
- package/dist/assets/dps/components/webcam-capture/_webcam-capture.scss +2 -2
- package/dist/assets/dps/components/webcam-capture/index.d.ts +1 -0
- package/dist/assets/dps/components/webcam-capture/index.js +14 -8
- package/dist/assets/dps/components/webcam-capture/index.ts +165 -0
- package/dist/assets/js/all.d.ts +1 -0
- package/dist/assets/js/all.ts +10 -0
- package/dist/assets/scss/all.css +66 -16
- package/dist/index.d.ts +10 -1
- package/dist/types/public/addressAutosuggest/addressAutosuggestResponse.d.ts +9 -0
- package/package.json +4 -4
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// Based on ONSDigital's https://github.com/ONSdigital/design-system/blob/main/src/components/autosuggest/autosuggest.ui.js
|
|
2
|
+
/* eslint-disable no-param-reassign, @stylistic/lines-between-class-members */
|
|
3
|
+
|
|
4
|
+
import { abortError, abortTimeout } from './abortable-fetch'
|
|
5
|
+
|
|
6
|
+
const jsBaseClass = 'hmpps-js-autosuggest'
|
|
7
|
+
|
|
8
|
+
const classSuffixInitialised = '--initialised'
|
|
9
|
+
const classSuffixOption = '__option'
|
|
10
|
+
const classSuffixOptionFocused = `${classSuffixOption}--focused`
|
|
11
|
+
const classSuffixOptionNoResults = `${classSuffixOption}--no-results`
|
|
12
|
+
const classSuffixHasResults = '--has-results'
|
|
13
|
+
|
|
14
|
+
export const NoResults = {
|
|
15
|
+
type_more: 'TYPE_MORE',
|
|
16
|
+
no_match: 'NO_MATCH',
|
|
17
|
+
server_error: 'SERVER_ERROR',
|
|
18
|
+
} as const
|
|
19
|
+
type NoResults = typeof NoResults
|
|
20
|
+
|
|
21
|
+
export interface AddressSuggestion {
|
|
22
|
+
displayText?: string
|
|
23
|
+
uprn?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AddressSuggestions {
|
|
27
|
+
status: number
|
|
28
|
+
results?: AddressSuggestion[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type AddressSuggestionFunction = (query: string) => Promise<AddressSuggestions>
|
|
32
|
+
type AddressSelectedCallback = (selectedResult: AddressSuggestion) => Promise<void>
|
|
33
|
+
|
|
34
|
+
interface AutosuggestUiOptions {
|
|
35
|
+
context: HTMLDivElement
|
|
36
|
+
suggestionFunction: AddressSuggestionFunction
|
|
37
|
+
onSelect: AddressSelectedCallback
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default class AutosuggestUi {
|
|
41
|
+
private readonly context: HTMLDivElement
|
|
42
|
+
public readonly input: HTMLInputElement
|
|
43
|
+
private readonly resultsContainer: HTMLDivElement
|
|
44
|
+
private readonly resultsTitleContainer: HTMLDivElement
|
|
45
|
+
private readonly listbox: HTMLUListElement
|
|
46
|
+
private readonly instructions: HTMLDivElement
|
|
47
|
+
private readonly errorContainer: HTMLDivElement
|
|
48
|
+
private readonly errorTextContainer: HTMLDivElement
|
|
49
|
+
private readonly ariaStatus: HTMLDivElement
|
|
50
|
+
|
|
51
|
+
private readonly minChars: number
|
|
52
|
+
private readonly ariaYouHaveSelected: string
|
|
53
|
+
private readonly ariaMinChars: string
|
|
54
|
+
private readonly ariaOneResult: string
|
|
55
|
+
private readonly ariaNResults: string
|
|
56
|
+
private readonly noResults: string
|
|
57
|
+
private readonly typeMore: string
|
|
58
|
+
private readonly listboxId: string
|
|
59
|
+
private readonly errorMessage: string
|
|
60
|
+
private readonly stylingBaseClass: string
|
|
61
|
+
|
|
62
|
+
private readonly fetchSuggestions: AddressSuggestionFunction
|
|
63
|
+
private readonly onSelect: AddressSelectedCallback
|
|
64
|
+
|
|
65
|
+
private query: string
|
|
66
|
+
private sanitisedQuery: string
|
|
67
|
+
private results: AddressSuggestion[] | undefined
|
|
68
|
+
private resultOptions: HTMLLIElement[]
|
|
69
|
+
private numberOfResults: number
|
|
70
|
+
|
|
71
|
+
private blurring: boolean
|
|
72
|
+
private blurTimeout: number
|
|
73
|
+
private scrolling: boolean
|
|
74
|
+
private scrollTimeout: number
|
|
75
|
+
private inputTimeout: number
|
|
76
|
+
private highlightedResultIndex: number | null
|
|
77
|
+
|
|
78
|
+
constructor({ context, onSelect, suggestionFunction }: AutosuggestUiOptions) {
|
|
79
|
+
// DOM Elements
|
|
80
|
+
this.context = context
|
|
81
|
+
this.input = context.querySelector<HTMLInputElement>(`.${jsBaseClass}-input`)!
|
|
82
|
+
this.resultsContainer = context.querySelector<HTMLDivElement>(`.${jsBaseClass}-results`)!
|
|
83
|
+
this.resultsTitleContainer = this.resultsContainer.querySelector<HTMLDivElement>(`.${jsBaseClass}-results-title`)!
|
|
84
|
+
this.listbox = this.resultsContainer.querySelector<HTMLUListElement>(`.${jsBaseClass}-listbox`)!
|
|
85
|
+
this.instructions = context.querySelector<HTMLDivElement>(`.${jsBaseClass}-instructions`)!
|
|
86
|
+
this.errorContainer = context.querySelector<HTMLDivElement>(`.${jsBaseClass}-error`)!
|
|
87
|
+
this.errorTextContainer = this.errorContainer.querySelector<HTMLDivElement>('.moj-alert__content')!
|
|
88
|
+
this.ariaStatus = context.querySelector<HTMLDivElement>(`.${jsBaseClass}-aria-status`)!
|
|
89
|
+
|
|
90
|
+
// Settings
|
|
91
|
+
this.minChars = 3
|
|
92
|
+
this.ariaYouHaveSelected = context.dataset.ariaYouHaveSelected!
|
|
93
|
+
this.ariaMinChars = context.dataset.ariaMinChars!
|
|
94
|
+
this.ariaOneResult = context.dataset.ariaOneResult!
|
|
95
|
+
this.ariaNResults = context.dataset.ariaNResults!
|
|
96
|
+
this.noResults = context.dataset.noResults!
|
|
97
|
+
this.typeMore = context.dataset.typeMore!
|
|
98
|
+
this.errorMessage = context.dataset.errorApi!
|
|
99
|
+
this.stylingBaseClass = context.dataset.stylingBaseClass!
|
|
100
|
+
this.listboxId = this.listbox.id
|
|
101
|
+
|
|
102
|
+
// Callbacks
|
|
103
|
+
this.onSelect = onSelect
|
|
104
|
+
this.fetchSuggestions = suggestionFunction
|
|
105
|
+
|
|
106
|
+
// State
|
|
107
|
+
this.query = ''
|
|
108
|
+
this.sanitisedQuery = ''
|
|
109
|
+
this.results = []
|
|
110
|
+
this.resultOptions = []
|
|
111
|
+
this.numberOfResults = 0
|
|
112
|
+
this.highlightedResultIndex = null
|
|
113
|
+
this.blurring = false
|
|
114
|
+
this.blurTimeout = 0
|
|
115
|
+
this.scrolling = false
|
|
116
|
+
this.scrollTimeout = 0
|
|
117
|
+
this.inputTimeout = 0
|
|
118
|
+
|
|
119
|
+
this.initialiseUI()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private initialiseUI(): void {
|
|
123
|
+
this.input.setAttribute('aria-autocomplete', 'list')
|
|
124
|
+
this.input.setAttribute('aria-controls', this.listbox.id)
|
|
125
|
+
this.input.setAttribute('aria-describedby', this.instructions.id)
|
|
126
|
+
this.input.setAttribute('aria-haspopup', 'true')
|
|
127
|
+
this.input.setAttribute('aria-owns', this.listbox.id)
|
|
128
|
+
this.input.setAttribute('aria-expanded', 'false')
|
|
129
|
+
this.input.setAttribute('autocomplete', 'off')
|
|
130
|
+
this.input.setAttribute('role', 'combobox')
|
|
131
|
+
|
|
132
|
+
this.context.classList.add(`${this.stylingBaseClass}${classSuffixInitialised}`)
|
|
133
|
+
|
|
134
|
+
this.bindEventListeners()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private bindEventListeners(): void {
|
|
138
|
+
this.input.addEventListener('keydown', this.handleKeydown.bind(this))
|
|
139
|
+
this.input.addEventListener('keyup', this.handleKeyup.bind(this))
|
|
140
|
+
this.input.addEventListener('input', this.handleChange.bind(this))
|
|
141
|
+
this.input.addEventListener('blur', this.handleBlur.bind(this))
|
|
142
|
+
this.input.addEventListener('focus', this.handleFocus.bind(this))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private handleKeydown(event: KeyboardEvent): void {
|
|
146
|
+
// eslint-disable-next-line default-case
|
|
147
|
+
switch (event.key) {
|
|
148
|
+
case 'ArrowUp': {
|
|
149
|
+
event.preventDefault()
|
|
150
|
+
this.navigateResults(-1)
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
case 'ArrowDown': {
|
|
154
|
+
event.preventDefault()
|
|
155
|
+
this.navigateResults(1)
|
|
156
|
+
break
|
|
157
|
+
}
|
|
158
|
+
case 'Escape': {
|
|
159
|
+
this.clearListbox()
|
|
160
|
+
break
|
|
161
|
+
}
|
|
162
|
+
case 'Enter': {
|
|
163
|
+
if (this.highlightedResultIndex !== null) {
|
|
164
|
+
event.preventDefault()
|
|
165
|
+
}
|
|
166
|
+
break
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private handleKeyup(event: KeyboardEvent): void {
|
|
172
|
+
// eslint-disable-next-line default-case
|
|
173
|
+
switch (event.key) {
|
|
174
|
+
case 'ArrowUp':
|
|
175
|
+
case 'ArrowDown': {
|
|
176
|
+
event.preventDefault()
|
|
177
|
+
break
|
|
178
|
+
}
|
|
179
|
+
case 'Enter': {
|
|
180
|
+
if (this.highlightedResultIndex !== null) {
|
|
181
|
+
this.selectResult()
|
|
182
|
+
}
|
|
183
|
+
break
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private handleChange(_event: InputEvent): void {
|
|
189
|
+
if (!this.blurring && this.input.value.trim()) {
|
|
190
|
+
this.getSuggestions()
|
|
191
|
+
} else {
|
|
192
|
+
this.clearListbox()
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private handleBlur(_event: FocusEvent): void {
|
|
197
|
+
clearTimeout(this.blurTimeout)
|
|
198
|
+
this.blurring = true
|
|
199
|
+
|
|
200
|
+
// Timeout required to allow user to click an option before clearing the listbox:
|
|
201
|
+
this.blurTimeout = setTimeout(() => {
|
|
202
|
+
this.blurring = false
|
|
203
|
+
|
|
204
|
+
this.context.classList.remove(`${this.stylingBaseClass}${classSuffixHasResults}`)
|
|
205
|
+
this.input.setAttribute('aria-expanded', 'false')
|
|
206
|
+
this.setAriaStatus()
|
|
207
|
+
}, 300)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private handleFocus(_event: FocusEvent): void {
|
|
211
|
+
if (this.listbox.innerHTML) {
|
|
212
|
+
this.context.classList.add(`${this.stylingBaseClass}${classSuffixHasResults}`)
|
|
213
|
+
this.input.setAttribute('aria-expanded', 'true')
|
|
214
|
+
this.setAriaStatus()
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private handleScroll(option: HTMLLIElement, scrollUp: boolean): void {
|
|
219
|
+
clearTimeout(this.scrollTimeout)
|
|
220
|
+
this.scrolling = true
|
|
221
|
+
option.scrollIntoView(scrollUp)
|
|
222
|
+
|
|
223
|
+
// Timeout required to allow user to click an option before clearing the listbox:
|
|
224
|
+
this.scrollTimeout = setTimeout(() => {
|
|
225
|
+
this.scrolling = false
|
|
226
|
+
}, 300)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private checkCharCount(): void {
|
|
230
|
+
if (this.input.value.length > 1 && this.input.value.length < this.minChars) {
|
|
231
|
+
this.inputTimeout = setTimeout(() => {
|
|
232
|
+
this.handleNoResults(NoResults.type_more)
|
|
233
|
+
}, 2000)
|
|
234
|
+
} else {
|
|
235
|
+
clearTimeout(this.inputTimeout)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private navigateResults(direction: number): void {
|
|
240
|
+
let index: number | null = 0
|
|
241
|
+
if (this.highlightedResultIndex !== null) {
|
|
242
|
+
index = this.highlightedResultIndex + direction
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (index < this.numberOfResults) {
|
|
246
|
+
if (index < 0) {
|
|
247
|
+
index = null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
this.setHighlightedResult(index)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private sanitiseAutosuggestText(string: string): string {
|
|
255
|
+
let sanitisedString = string.toLowerCase()
|
|
256
|
+
|
|
257
|
+
sanitisedString = sanitisedString.trim()
|
|
258
|
+
sanitisedString = sanitisedString.replace(/,/g, '')
|
|
259
|
+
sanitisedString = sanitisedString.replace(/\s\s+/g, ' ')
|
|
260
|
+
sanitisedString = sanitisedString.replace(/&/g, '%26')
|
|
261
|
+
sanitisedString = sanitisedString.replace(/\d(?=[a-z]{3,})/gi, '$& ')
|
|
262
|
+
|
|
263
|
+
return sanitisedString
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private getSuggestions(): void {
|
|
267
|
+
this.query = this.input.value
|
|
268
|
+
|
|
269
|
+
const sanitisedQuery = this.sanitiseAutosuggestText(this.query)
|
|
270
|
+
|
|
271
|
+
if (sanitisedQuery !== this.sanitisedQuery) {
|
|
272
|
+
this.sanitisedQuery = sanitisedQuery
|
|
273
|
+
this.unsetResults()
|
|
274
|
+
this.checkCharCount()
|
|
275
|
+
|
|
276
|
+
if (this.sanitisedQuery.length >= this.minChars) {
|
|
277
|
+
this.fetchSuggestions(this.sanitisedQuery)
|
|
278
|
+
.then(this.handleResults.bind(this))
|
|
279
|
+
.catch(error => {
|
|
280
|
+
if (error.name !== abortError && error.reason !== abortTimeout) {
|
|
281
|
+
// eslint-disable-next-line no-console
|
|
282
|
+
console.log('error:', error)
|
|
283
|
+
this.handleNoResults(NoResults.server_error)
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
} else {
|
|
287
|
+
this.clearListbox()
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private unsetResults(): void {
|
|
293
|
+
this.results = []
|
|
294
|
+
this.resultOptions = []
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private clearListbox(): void {
|
|
298
|
+
this.listbox.innerHTML = ''
|
|
299
|
+
this.context.classList.remove(`${this.stylingBaseClass}${classSuffixHasResults}`)
|
|
300
|
+
this.input.removeAttribute('aria-activedescendant')
|
|
301
|
+
this.input.setAttribute('aria-expanded', 'false')
|
|
302
|
+
this.setHighlightedResult(null)
|
|
303
|
+
this.setAriaStatus()
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private handleResults(response: AddressSuggestions): void {
|
|
307
|
+
this.results = response?.results
|
|
308
|
+
this.numberOfResults = this.results?.length || 0
|
|
309
|
+
this.setAriaStatus()
|
|
310
|
+
this.listbox.innerHTML = ''
|
|
311
|
+
|
|
312
|
+
this.resultOptions =
|
|
313
|
+
this.results?.map((result, index) => {
|
|
314
|
+
this.resultsTitleContainer?.classList?.remove('hmpps-display-none')
|
|
315
|
+
const listElement = this.createListElement(result, index)
|
|
316
|
+
this.listbox.appendChild(listElement)
|
|
317
|
+
return listElement
|
|
318
|
+
}) ?? []
|
|
319
|
+
|
|
320
|
+
this.setHighlightedResult(null)
|
|
321
|
+
this.input.setAttribute('aria-expanded', this.numberOfResults ? 'true' : 'false')
|
|
322
|
+
|
|
323
|
+
if (!!this.numberOfResults && this.sanitisedQuery.length >= this.minChars) {
|
|
324
|
+
this.context.classList.add(`${this.stylingBaseClass}${classSuffixHasResults}`)
|
|
325
|
+
} else {
|
|
326
|
+
this.context.classList.remove(`${this.stylingBaseClass}${classSuffixHasResults}`)
|
|
327
|
+
this.clearListbox()
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (this.numberOfResults === 0 && this.noResults) {
|
|
331
|
+
this.handleNoResults(NoResults.no_match)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private createListElement(result: AddressSuggestion, index: number): HTMLLIElement {
|
|
336
|
+
const innerHTML = this.emboldenMatch(result.displayText ?? '', this.query)
|
|
337
|
+
const listElement: HTMLLIElement = document.createElement('li')
|
|
338
|
+
listElement.className = `${this.stylingBaseClass}${classSuffixOption}`
|
|
339
|
+
listElement.setAttribute('id', `${this.listboxId}__option--${index}`)
|
|
340
|
+
listElement.setAttribute('role', 'option')
|
|
341
|
+
listElement.innerHTML = innerHTML
|
|
342
|
+
listElement.addEventListener('click', () => {
|
|
343
|
+
this.selectResult(index)
|
|
344
|
+
})
|
|
345
|
+
listElement.addEventListener('mouseenter', () => !this.scrolling && this.setHighlightedResult(index))
|
|
346
|
+
return listElement
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
public handleNoResults(reason: NoResults[keyof NoResults]): void {
|
|
350
|
+
if (reason === NoResults.type_more || reason === NoResults.no_match) {
|
|
351
|
+
this.context.classList.add(`${this.stylingBaseClass}${classSuffixHasResults}`)
|
|
352
|
+
this.resultsTitleContainer?.classList?.add('hmpps-display-none')
|
|
353
|
+
this.input.setAttribute('aria-expanded', 'true')
|
|
354
|
+
|
|
355
|
+
const message = reason === NoResults.type_more ? this.typeMore : this.noResults
|
|
356
|
+
this.setAriaStatus(message)
|
|
357
|
+
this.listbox.innerHTML = ''
|
|
358
|
+
const noResultsItem = document.createElement('li')
|
|
359
|
+
noResultsItem.className = `${this.stylingBaseClass}${classSuffixOption} ${this.stylingBaseClass}${classSuffixOptionNoResults}`
|
|
360
|
+
noResultsItem.textContent = message
|
|
361
|
+
this.listbox.appendChild(noResultsItem)
|
|
362
|
+
} else {
|
|
363
|
+
this.displayAPIError()
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private displayAPIError(): void {
|
|
368
|
+
this.input.value = ''
|
|
369
|
+
this.input.setAttribute('disabled', 'true')
|
|
370
|
+
this.clearListbox()
|
|
371
|
+
this.errorContainer.classList.remove('hmpps-display-none')
|
|
372
|
+
this.errorTextContainer.textContent = this.errorMessage
|
|
373
|
+
this.ariaStatus.setAttribute('aria-hidden', 'true')
|
|
374
|
+
this.setAriaStatus(this.errorMessage)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private setHighlightedResult(index: number | null): void {
|
|
378
|
+
this.highlightedResultIndex = index
|
|
379
|
+
|
|
380
|
+
if (this.highlightedResultIndex === null) {
|
|
381
|
+
this.input.removeAttribute('aria-activedescendant')
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (this.numberOfResults) {
|
|
385
|
+
this.resultOptions.forEach((option, optionIndex) => {
|
|
386
|
+
if (index !== null && optionIndex === index) {
|
|
387
|
+
if (this.isAboveViewport(option)) {
|
|
388
|
+
this.handleScroll(option, true)
|
|
389
|
+
}
|
|
390
|
+
if (this.isBelowViewport(option)) {
|
|
391
|
+
this.handleScroll(option, false)
|
|
392
|
+
}
|
|
393
|
+
option.classList.add(`${this.stylingBaseClass}${classSuffixOptionFocused}`)
|
|
394
|
+
option.setAttribute('aria-selected', 'true')
|
|
395
|
+
this.input.setAttribute('aria-activedescendant', option.id)
|
|
396
|
+
const optionText = option.innerHTML.replace('<b>', '').replace('</b>', '')
|
|
397
|
+
this.setAriaStatus(optionText)
|
|
398
|
+
} else {
|
|
399
|
+
option.classList.remove(`${this.stylingBaseClass}${classSuffixOptionFocused}`)
|
|
400
|
+
option.removeAttribute('aria-selected')
|
|
401
|
+
}
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private selectResult(index?: number): void {
|
|
407
|
+
if (!this.results?.length) return
|
|
408
|
+
|
|
409
|
+
const selectedIndex = typeof index === 'number' ? index : this.highlightedResultIndex
|
|
410
|
+
if (selectedIndex === null || selectedIndex === undefined) return
|
|
411
|
+
|
|
412
|
+
const result = this.results?.[selectedIndex]
|
|
413
|
+
if (!result) return
|
|
414
|
+
|
|
415
|
+
this.onSelect(result)
|
|
416
|
+
this.clearListbox()
|
|
417
|
+
this.setAriaStatus(`${this.ariaYouHaveSelected}: ${result.displayText}.`)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private setAriaStatus(content?: string): void {
|
|
421
|
+
if (!content) {
|
|
422
|
+
const queryTooShort = this.sanitisedQuery.length < this.minChars
|
|
423
|
+
const noResults = this.numberOfResults === 0
|
|
424
|
+
if (queryTooShort) {
|
|
425
|
+
content = this.ariaMinChars
|
|
426
|
+
} else if (noResults) {
|
|
427
|
+
content = `${this.noResults}: "${this.query}"`
|
|
428
|
+
} else if (this.numberOfResults === 1) {
|
|
429
|
+
content = this.ariaOneResult
|
|
430
|
+
} else {
|
|
431
|
+
content = this.ariaNResults.replace('{n}', `${this.numberOfResults}`)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
this.ariaStatus.textContent = content
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private emboldenMatch(string: string, query: string): string {
|
|
438
|
+
return this.escapeRegExp(query)
|
|
439
|
+
.trim()
|
|
440
|
+
.replace(/\s\s+/g, ' ')
|
|
441
|
+
.split(' ')
|
|
442
|
+
.reduce((accumulator, currentValue) => {
|
|
443
|
+
const reg = new RegExp(`(?<!<)${currentValue}(?![\\w\\s]*>)`, 'gi')
|
|
444
|
+
return accumulator.replace(reg, '<b>$&</b>')
|
|
445
|
+
}, string)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private escapeRegExp(string: string): string {
|
|
449
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private isAboveViewport(element: HTMLLIElement): boolean {
|
|
453
|
+
return element.getBoundingClientRect().top < 0
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private isBelowViewport(element: HTMLLIElement): boolean {
|
|
457
|
+
return element.getBoundingClientRect().bottom > (window.innerHeight || document.documentElement.clientHeight)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function init(): void;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { AutosuggestAddress } from './autosuggest-address.js';
|
|
2
2
|
|
|
3
|
+
// eslint-disable-next-line import/prefer-default-export
|
|
3
4
|
function init() {
|
|
4
5
|
// Setup all address autosuggest instances:
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
6
|
+
document
|
|
7
|
+
.querySelectorAll('.hmpps-js-address-autosuggest')
|
|
8
|
+
.forEach(addressAutosuggest => new AutosuggestAddress(addressAutosuggest));
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export { init };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { AutosuggestAddress } from './autosuggest-address'
|
|
2
|
+
|
|
3
|
+
// eslint-disable-next-line import/prefer-default-export
|
|
4
|
+
export function init() {
|
|
5
|
+
// Setup all address autosuggest instances:
|
|
6
|
+
document
|
|
7
|
+
.querySelectorAll<HTMLDivElement>('.hmpps-js-address-autosuggest')
|
|
8
|
+
.forEach(addressAutosuggest => new AutosuggestAddress(addressAutosuggest))
|
|
9
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
@use "sass:color";
|
|
2
2
|
@use "sass:selector";
|
|
3
3
|
@use "govuk-frontend/dist/govuk/helpers/colour";
|
|
4
|
+
@use "govuk-frontend/dist/govuk/helpers/typography";
|
|
4
5
|
|
|
5
6
|
@mixin unify-parent($child) {
|
|
6
7
|
@at-root #{selector.unify(&, $child)} {
|
|
@@ -14,11 +15,11 @@
|
|
|
14
15
|
border: 3px solid;
|
|
15
16
|
padding: 5px 10px;
|
|
16
17
|
height: initial;
|
|
17
|
-
font-size: 16px;
|
|
18
18
|
text-align: center;
|
|
19
19
|
line-height: 1;
|
|
20
20
|
user-select: none;
|
|
21
21
|
border-radius: 5px;
|
|
22
|
+
@include typography.govuk-font($size: 16);
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
@media print {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Modal dialogue box component */
|
|
2
|
+
export declare class Modal {
|
|
3
|
+
private readonly root;
|
|
4
|
+
static readonly showTriggerClassName = "hmpps-modal__trigger-show";
|
|
5
|
+
static readonly hideTriggerClassName = "hmpps-modal__trigger-hide";
|
|
6
|
+
private static readonly modals;
|
|
7
|
+
/** Initialise all modal components rendered in the document; none will automatically open */
|
|
8
|
+
static init(): void;
|
|
9
|
+
/** Find an instance of the `Modal` class to programmatically control it */
|
|
10
|
+
static getById(modalId: string): Modal | undefined;
|
|
11
|
+
/** Hide all showing modals */
|
|
12
|
+
static hideAll(): void;
|
|
13
|
+
private readonly dialog;
|
|
14
|
+
private readonly content;
|
|
15
|
+
private showing;
|
|
16
|
+
constructor(root: HTMLDivElement);
|
|
17
|
+
get id(): string;
|
|
18
|
+
get isShowing(): boolean;
|
|
19
|
+
private attachHideTriggers;
|
|
20
|
+
private handleKeyDown;
|
|
21
|
+
private previouslyFocused;
|
|
22
|
+
private takeFocus;
|
|
23
|
+
private maintainFocus;
|
|
24
|
+
private revertFocus;
|
|
25
|
+
private trapFocus;
|
|
26
|
+
private getFocusableChildren;
|
|
27
|
+
/** Show the modal dialog box */
|
|
28
|
+
show(): void;
|
|
29
|
+
/** Hide the modal dialog box */
|
|
30
|
+
hide(): void;
|
|
31
|
+
/** Show modal dialog box with loading graphic while loading HTML content from `url` */
|
|
32
|
+
load(url: string | URL): Promise<void>;
|
|
33
|
+
private replaceContentWithTemplate;
|
|
34
|
+
}
|
|
@@ -28,7 +28,8 @@ Parameters as JSDoc:
|
|
|
28
28
|
</dialog>
|
|
29
29
|
|
|
30
30
|
<template class="hmpps-modal__loading-graphic-template">
|
|
31
|
-
<
|
|
31
|
+
<span class="govuk-visually-hidden">Content is loading…</span>
|
|
32
|
+
<svg class="hmpps-modal__loading-graphic" aria-hidden="true" width="50" height="50" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
32
33
|
<g>
|
|
33
34
|
<rect x="11" y="1" width="2" height="5" opacity=".14"/>
|
|
34
35
|
<rect x="11" y="1" width="2" height="5" transform="rotate(30 12 12)" opacity=".29"/>
|
|
@@ -15,13 +15,13 @@ $hmpps-webcam-capture-height: 276px;
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
&__error {
|
|
18
|
-
border: 5px solid #
|
|
18
|
+
border: 5px solid #eb611c;
|
|
19
19
|
|
|
20
20
|
.hmpps-webcam-error-icon {
|
|
21
21
|
display: block;
|
|
22
22
|
width: 1.875rem;
|
|
23
23
|
height: 1.875rem;
|
|
24
|
-
color: #
|
|
24
|
+
color: #eb611c;
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function init(): void;
|
|
@@ -13,19 +13,20 @@ function hmppsWebcamCapture(component) {
|
|
|
13
13
|
// Permissions
|
|
14
14
|
const permissionRequested = document.getElementById('hmpps-webcam__permission-requested');
|
|
15
15
|
const permissionGranted = document.getElementById('hmpps-webcam__permission-granted');
|
|
16
|
-
const webcamError = document.getElementById('hmpps-webcam__webcam-error');
|
|
17
16
|
// Preview elements
|
|
18
17
|
const photoPreviewContainer = document.getElementById('hmpps-webcam__photo-preview-container');
|
|
19
18
|
const photoCaptureContainer = document.getElementById('hmpps-webcam__photo-capture-container');
|
|
20
19
|
const photoCaptureErrorContainer = document.getElementById('hmpps-webcam__photo-capture-container__error');
|
|
21
20
|
const mimetype = 'image/jpeg';
|
|
22
21
|
let stream = null;
|
|
23
|
-
function updateWebcamList(
|
|
22
|
+
function updateWebcamList(activeDeviceId) {
|
|
24
23
|
webcamSelect.querySelectorAll('option').forEach(option => {
|
|
25
24
|
if (option.value === activeDeviceId && option.label.indexOf('- Active') === -1) {
|
|
25
|
+
// eslint-disable-next-line no-param-reassign
|
|
26
26
|
option.label = `${option.label} - Active`;
|
|
27
27
|
}
|
|
28
28
|
else if (option.value !== activeDeviceId && option.label.indexOf('- Active') > -1) {
|
|
29
|
+
// eslint-disable-next-line no-param-reassign
|
|
29
30
|
option.label = option.label.replace(' - Active', '');
|
|
30
31
|
}
|
|
31
32
|
});
|
|
@@ -55,13 +56,16 @@ function hmppsWebcamCapture(component) {
|
|
|
55
56
|
photoCaptureContainer.style.display = 'none';
|
|
56
57
|
photoCaptureErrorContainer.style.display = 'block';
|
|
57
58
|
if (enableReporting) {
|
|
58
|
-
|
|
59
|
+
// @ts-expect-error error type is not known
|
|
60
|
+
const error = 'name' in e ? e.name : e.message;
|
|
61
|
+
// eslint-disable-next-line no-restricted-globals
|
|
62
|
+
await fetch(`/api/report-error?pageUrl=${encodeURIComponent(location.href)}&error=${error}`, {
|
|
59
63
|
method: 'GET',
|
|
60
64
|
});
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
|
-
|
|
68
|
+
function stopWebcam() {
|
|
65
69
|
if (stream) {
|
|
66
70
|
stream.getTracks().forEach(track => track.stop());
|
|
67
71
|
}
|
|
@@ -80,11 +84,13 @@ function hmppsWebcamCapture(component) {
|
|
|
80
84
|
webcamPlaceholder.style.display = 'none';
|
|
81
85
|
video.style.display = 'block';
|
|
82
86
|
captureBtn.disabled = false;
|
|
83
|
-
updateWebcamList(
|
|
87
|
+
updateWebcamList(deviceId);
|
|
84
88
|
}
|
|
85
89
|
catch (error) {
|
|
86
90
|
permissionRequested.style.display = 'none';
|
|
87
|
-
|
|
91
|
+
photoCaptureContainer.style.display = 'none';
|
|
92
|
+
photoCaptureErrorContainer.style.display = 'block';
|
|
93
|
+
// eslint-disable-next-line no-console
|
|
88
94
|
console.error('Webcam error:', error);
|
|
89
95
|
}
|
|
90
96
|
}
|
|
@@ -103,8 +109,7 @@ function hmppsWebcamCapture(component) {
|
|
|
103
109
|
canvas.height = video.videoHeight;
|
|
104
110
|
const ctx = canvas.getContext('2d');
|
|
105
111
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
106
|
-
|
|
107
|
-
snapshot.src = imageDataUrl;
|
|
112
|
+
snapshot.src = canvas.toDataURL(mimetype);
|
|
108
113
|
canvas.toBlob(blob => {
|
|
109
114
|
if (!blob)
|
|
110
115
|
return;
|
|
@@ -124,6 +129,7 @@ function hmppsWebcamCapture(component) {
|
|
|
124
129
|
}
|
|
125
130
|
return pageInit();
|
|
126
131
|
}
|
|
132
|
+
// eslint-disable-next-line import/prefer-default-export
|
|
127
133
|
function init() {
|
|
128
134
|
const webcamContainer = document.getElementById('hmpps-webcam-capture-component');
|
|
129
135
|
if (webcamContainer) {
|