@ministryofjustice/hmpps-connect-dps-shared-items 1.2.1 → 2.1.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.
Files changed (40) hide show
  1. package/README.md +34 -11
  2. package/dist/addressAutosuggest/osPlacesAddressService.d.ts +14 -0
  3. package/dist/addressAutosuggest/osPlacesAddressService.test.d.ts +1 -0
  4. package/dist/addressAutosuggest/osPlacesApiClient.d.ts +13 -0
  5. package/dist/addressAutosuggest/osPlacesApiClient.test.d.ts +1 -0
  6. package/dist/addressAutosuggest/testMocks/osPlacesAddressQueryResponse.d.ts +12 -0
  7. package/dist/addressAutosuggest/testMocks/osPlacesApiClientMock.d.ts +2 -0
  8. package/dist/alertFlags/getAlertFlagLabelsForAlerts.d.ts +3 -4
  9. package/dist/alertFlags/getAlertFlagLabelsForAlerts.test.d.ts +1 -0
  10. package/dist/assets/dps/components/address-autosuggest/_address-autosuggest.scss +98 -0
  11. package/dist/assets/dps/components/address-autosuggest/abortable-fetch.js +43 -0
  12. package/dist/assets/dps/components/address-autosuggest/autosuggest-address.js +90 -0
  13. package/dist/assets/dps/components/address-autosuggest/autosuggest-ui.js +369 -0
  14. package/dist/assets/dps/components/address-autosuggest/index.js +10 -0
  15. package/dist/assets/dps/components/address-autosuggest/macro.njk +61 -0
  16. package/dist/assets/js/all.js +5 -0
  17. package/dist/assets/scss/all.scss +2 -1
  18. package/dist/index.cjs +277 -0
  19. package/dist/index.cjs.map +1 -0
  20. package/dist/index.d.ts +237 -5
  21. package/dist/index.esm.js +273 -0
  22. package/dist/index.esm.js.map +1 -0
  23. package/dist/types/public/ConnectDpsComponentLogger.d.ts +2 -0
  24. package/dist/types/public/addressAutosuggest/index.d.ts +2 -0
  25. package/dist/types/public/addressAutosuggest/osAddress.d.ts +13 -0
  26. package/dist/types/public/addressAutosuggest/osPlaces.d.ts +124 -0
  27. package/dist/{alertFlags/types → types/public/alertFlags}/Alert.d.ts +1 -3
  28. package/dist/{alertFlags/types → types/public/alertFlags}/AlertFlagLabel.d.ts +1 -1
  29. package/dist/types/public/alertFlags/index.d.ts +1 -0
  30. package/dist/utils/utils.d.ts +8 -0
  31. package/package.json +51 -52
  32. package/readme/publishing.md +21 -0
  33. package/dist/alertFlags/getAlertFlagLabelsForAlerts.js +0 -146
  34. package/dist/alertFlags/getAlertFlagLabelsForAlerts.js.map +0 -1
  35. package/dist/alertFlags/types/Alert.js +0 -7
  36. package/dist/alertFlags/types/Alert.js.map +0 -1
  37. package/dist/alertFlags/types/AlertFlagLabel.js +0 -3
  38. package/dist/alertFlags/types/AlertFlagLabel.js.map +0 -1
  39. package/dist/index.js +0 -12
  40. package/dist/index.js.map +0 -1
@@ -0,0 +1,369 @@
1
+ // Based on ONSDigital's https://github.com/ONSdigital/design-system/blob/main/src/components/autosuggest/autosuggest.ui.js
2
+ import { abortError, abortTimeout } from './abortable-fetch'
3
+
4
+ const jsBaseClass = 'hmpps-js-autosuggest'
5
+
6
+ const classSuffixInitialised = '--initialised'
7
+ const classSuffixOption = '__option'
8
+ const classSuffixOptionFocused = `${classSuffixOption}--focused`
9
+ const classSuffixOptionNoResults = `${classSuffixOption}--no-results`
10
+ const classSuffixHasResults = '--has-results'
11
+
12
+ export const NoResults = {
13
+ type_more: 'TYPE_MORE',
14
+ no_match: 'NO_MATCH',
15
+ server_error: 'SERVER_ERROR',
16
+ }
17
+
18
+ export class AutosuggestUi {
19
+ constructor({ context, onSelect, suggestionFunction }) {
20
+ // DOM Elements
21
+ this.context = context
22
+ this.input = context.querySelector(`.${jsBaseClass}-input`)
23
+ this.resultsContainer = context.querySelector(`.${jsBaseClass}-results`)
24
+ this.resultsTitleContainer = this.resultsContainer.querySelector(`.${jsBaseClass}-results-title`)
25
+ this.listbox = this.resultsContainer.querySelector(`.${jsBaseClass}-listbox`)
26
+ this.instructions = context.querySelector(`.${jsBaseClass}-instructions`)
27
+ this.errorContainer = context.querySelector(`.${jsBaseClass}-error`)
28
+ this.errorTextContainer = this.errorContainer.querySelector('.moj-alert__content')
29
+ this.ariaStatus = context.querySelector(`.${jsBaseClass}-aria-status`)
30
+
31
+ // Settings
32
+ this.minChars = 3
33
+ this.ariaYouHaveSelected = context.getAttribute('data-aria-you-have-selected')
34
+ this.ariaMinChars = context.getAttribute('data-aria-min-chars')
35
+ this.ariaOneResult = context.getAttribute('data-aria-one-result')
36
+ this.ariaNResults = context.getAttribute('data-aria-n-results')
37
+ this.noResults = context.getAttribute('data-no-results')
38
+ this.typeMore = context.getAttribute('data-type-more')
39
+ this.listboxId = this.listbox.getAttribute('id')
40
+ this.errorMessage = context.getAttribute('data-error-api')
41
+ this.stylingBaseClass = context.getAttribute('data-styling-base-class')
42
+
43
+ // Callbacks
44
+ this.onSelect = onSelect
45
+ this.fetchSuggestions = suggestionFunction
46
+
47
+ // State
48
+ this.query = ''
49
+ this.sanitisedQuery = ''
50
+ this.results = []
51
+ this.resultOptions = []
52
+ this.data = []
53
+ this.numberOfResults = 0
54
+ this.highlightedResultIndex = 0
55
+ this.blurring = false
56
+ this.blurTimeout = null
57
+ this.scrolling = false
58
+ this.scrollTimeout = null
59
+
60
+ this.initialiseUI()
61
+ }
62
+
63
+ initialiseUI() {
64
+ this.input.setAttribute('aria-autocomplete', 'list')
65
+ this.input.setAttribute('aria-controls', this.listbox.getAttribute('id'))
66
+ this.input.setAttribute('aria-describedby', this.instructions.getAttribute('id'))
67
+ this.input.setAttribute('aria-haspopup', true)
68
+ this.input.setAttribute('aria-owns', this.listbox.getAttribute('id'))
69
+ this.input.setAttribute('aria-expanded', false)
70
+ this.input.setAttribute('autocomplete', 'off')
71
+ this.input.setAttribute('role', 'combobox')
72
+
73
+ this.context.classList.add(`${this.stylingBaseClass}${classSuffixInitialised}`)
74
+
75
+ this.bindEventListeners()
76
+ }
77
+
78
+ bindEventListeners() {
79
+ this.input.addEventListener('keydown', this.handleKeydown.bind(this))
80
+ this.input.addEventListener('keyup', this.handleKeyup.bind(this))
81
+ this.input.addEventListener('input', this.handleChange.bind(this))
82
+ this.input.addEventListener('blur', this.handleBlur.bind(this))
83
+ }
84
+
85
+ handleKeydown(event) {
86
+ switch (event.keyCode) {
87
+ case 38: {
88
+ // Up
89
+ event.preventDefault()
90
+ this.navigateResults(-1)
91
+ break
92
+ }
93
+ case 40: {
94
+ // Down
95
+ event.preventDefault()
96
+ this.navigateResults(1)
97
+ break
98
+ }
99
+ case 13: {
100
+ // Enter
101
+ if (this.highlightedResultIndex !== null) {
102
+ event.preventDefault()
103
+ }
104
+ break
105
+ }
106
+ }
107
+ }
108
+
109
+ handleKeyup(event) {
110
+ switch (event.keyCode) {
111
+ // Up and down
112
+ case 40:
113
+ case 38: {
114
+ event.preventDefault()
115
+ break
116
+ }
117
+ case 13: {
118
+ // Enter
119
+ if (this.highlightedResultIndex !== null) {
120
+ this.selectResult()
121
+ }
122
+ break
123
+ }
124
+ }
125
+ }
126
+
127
+ handleChange() {
128
+ if (!this.blurring && this.input.value.trim()) {
129
+ this.getSuggestions()
130
+ } else {
131
+ this.clearListbox()
132
+ }
133
+ }
134
+
135
+ handleBlur() {
136
+ clearTimeout(this.blurTimeout)
137
+ this.blurring = true
138
+
139
+ // Timeout required to allow user to click an option before clearing the listbox:
140
+ this.blurTimeout = setTimeout(() => {
141
+ this.blurring = false
142
+ this.clearListbox()
143
+ }, 300)
144
+ }
145
+
146
+ handleScroll(option, scrollUp) {
147
+ clearTimeout(this.scrollTimeout)
148
+ this.scrolling = true
149
+ option.scrollIntoView(scrollUp)
150
+
151
+ // Timeout required to allow user to click an option before clearing the listbox:
152
+ this.scrollTimeout = setTimeout(() => {
153
+ this.scrolling = false
154
+ }, 300)
155
+ }
156
+
157
+ checkCharCount() {
158
+ if (this.input.value.length > 1 && this.input.value.length < this.minChars) {
159
+ this.inputTimeout = setTimeout(() => {
160
+ this.handleNoResults(NoResults.type_more)
161
+ }, 2000)
162
+ } else {
163
+ clearTimeout(this.inputTimeout)
164
+ }
165
+ }
166
+
167
+ navigateResults(direction) {
168
+ let index = 0
169
+ if (this.highlightedResultIndex !== null) {
170
+ index = this.highlightedResultIndex + direction
171
+ }
172
+
173
+ if (index < this.numberOfResults) {
174
+ if (index < 0) {
175
+ index = null
176
+ }
177
+
178
+ this.setHighlightedResult(index)
179
+ }
180
+ }
181
+
182
+ sanitiseAutosuggestText(string) {
183
+ let sanitisedString = string.toLowerCase()
184
+
185
+ sanitisedString = sanitisedString.trim()
186
+ sanitisedString = sanitisedString.replace(/,/g, '')
187
+ sanitisedString = sanitisedString.replace(/\s\s+/g, ' ')
188
+ sanitisedString = sanitisedString.replace(/[&]/g, '%26')
189
+ sanitisedString = sanitisedString.replace(/\d(?=[a-z]{3,})/gi, '$& ')
190
+
191
+ return sanitisedString
192
+ }
193
+
194
+ getSuggestions() {
195
+ this.query = this.input.value
196
+
197
+ const sanitisedQuery = this.sanitiseAutosuggestText(this.query)
198
+
199
+ if (sanitisedQuery !== this.sanitisedQuery) {
200
+ this.sanitisedQuery = sanitisedQuery
201
+ this.unsetResults()
202
+ this.checkCharCount()
203
+
204
+ if (this.sanitisedQuery.length >= this.minChars) {
205
+ this.fetchSuggestions(this.sanitisedQuery, this.data)
206
+ .then(this.handleResults.bind(this))
207
+ .catch(error => {
208
+ if (error.name !== abortError && error.reason !== abortTimeout) {
209
+ console.log('error:', error)
210
+ this.handleNoResults(NoResults.server_error)
211
+ }
212
+ })
213
+ } else {
214
+ this.clearListbox()
215
+ }
216
+ }
217
+ }
218
+
219
+ unsetResults() {
220
+ this.results = []
221
+ this.resultOptions = []
222
+ }
223
+
224
+ clearListbox() {
225
+ this.listbox.innerHTML = ''
226
+ this.context.classList.remove(`${this.stylingBaseClass}${classSuffixHasResults}`)
227
+ this.input.removeAttribute('aria-activedescendant')
228
+ this.input.setAttribute('aria-expanded', false)
229
+ this.setAriaStatus()
230
+ }
231
+
232
+ handleResults(result) {
233
+ this.results = result.results
234
+ this.numberOfResults = this.results?.length || 0
235
+ this.setAriaStatus()
236
+ this.listbox.innerHTML = ''
237
+
238
+ this.resultOptions =
239
+ this.results?.map((result, index) => {
240
+ this.resultsTitleContainer?.classList?.remove('hmpps-display-none')
241
+ const listElement = this.createListElement(result, index)
242
+ this.listbox.appendChild(listElement)
243
+ return listElement
244
+ }) ?? []
245
+
246
+ this.setHighlightedResult(0)
247
+ this.input.setAttribute('aria-expanded', this.numberOfResults ? 'true' : 'false')
248
+
249
+ if (!!this.numberOfResults && this.sanitisedQuery.length >= this.minChars) {
250
+ this.context.classList.add(`${this.stylingBaseClass}${classSuffixHasResults}`)
251
+ } else {
252
+ this.context.classList.remove(`${this.stylingBaseClass}${classSuffixHasResults}`)
253
+ this.clearListbox()
254
+ }
255
+
256
+ if (this.numberOfResults === 0 && this.noResults) {
257
+ this.handleNoResults(NoResults.no_match)
258
+ }
259
+ }
260
+
261
+ createListElement(result, index) {
262
+ const innerHTML = this.emboldenMatch(result.displayText, this.query)
263
+ const listElement = document.createElement('li')
264
+ listElement.className = `${this.stylingBaseClass}${classSuffixOption}`
265
+ listElement.setAttribute('id', `${this.listboxId}__option--${index}`)
266
+ listElement.setAttribute('role', 'option')
267
+ listElement.innerHTML = innerHTML
268
+ listElement.addEventListener('click', () => {
269
+ this.selectResult(index)
270
+ })
271
+ listElement.addEventListener('mouseenter', () => !this.scrolling && this.setHighlightedResult(index))
272
+ return listElement
273
+ }
274
+
275
+ handleNoResults(reason) {
276
+ if ([NoResults.type_more, NoResults.no_match].includes(reason)) {
277
+ this.context.classList.add(`${this.stylingBaseClass}${classSuffixHasResults}`)
278
+ this.resultsTitleContainer?.classList?.add('hmpps-display-none')
279
+ this.input.setAttribute('aria-expanded', true)
280
+
281
+ const message = reason === NoResults.type_more ? this.typeMore : this.noResults
282
+ this.setAriaStatus(message)
283
+ this.listbox.innerHTML = `<li class="${this.stylingBaseClass}${classSuffixOption} ${this.stylingBaseClass}${classSuffixOptionNoResults}">${message}</li>`
284
+ } else {
285
+ this.displayAPIError()
286
+ }
287
+ }
288
+
289
+ displayAPIError() {
290
+ this.input.value = ''
291
+ this.input.setAttribute('disabled', true)
292
+ this.clearListbox()
293
+ this.errorContainer.classList.remove('hmpps-display-none')
294
+ this.errorTextContainer.innerHTML = this.errorMessage
295
+ this.ariaStatus.setAttribute('aria-hidden', 'true')
296
+ this.setAriaStatus(this.errorMessage)
297
+ }
298
+
299
+ setHighlightedResult(index) {
300
+ this.highlightedResultIndex = index
301
+
302
+ if (this.highlightedResultIndex === null) {
303
+ this.input.removeAttribute('aria-activedescendant')
304
+ } else if (this.numberOfResults) {
305
+ this.resultOptions.forEach((option, optionIndex) => {
306
+ if (optionIndex === index) {
307
+ if (this.isAboveViewport(option)) {
308
+ this.handleScroll(option, true)
309
+ }
310
+ if (this.isBelowViewport(option)) {
311
+ this.handleScroll(option, false)
312
+ }
313
+ option.classList.add(`${this.stylingBaseClass}${classSuffixOptionFocused}`)
314
+ option.setAttribute('aria-selected', true)
315
+ this.input.setAttribute('aria-activedescendant', option.getAttribute('id'))
316
+ const optionText = option.innerHTML.replace('<strong>', '').replace('</strong>', '')
317
+ this.setAriaStatus(optionText)
318
+ } else {
319
+ option.classList.remove(`${this.stylingBaseClass}${classSuffixOptionFocused}`)
320
+ option.removeAttribute('aria-selected')
321
+ }
322
+ })
323
+ }
324
+ }
325
+
326
+ selectResult(index) {
327
+ if (!this.results.length) return
328
+
329
+ const result = this.results[index || this.highlightedResultIndex || 0]
330
+ this.onSelect(result)
331
+ this.clearListbox()
332
+ this.setAriaStatus(`${this.ariaYouHaveSelected}: ${result.displayText}.`)
333
+ }
334
+
335
+ setAriaStatus(content) {
336
+ if (!content) {
337
+ const queryTooShort = this.sanitisedQuery.length < this.minChars
338
+ const noResults = this.numberOfResults === 0
339
+ if (queryTooShort) {
340
+ content = this.ariaMinChars
341
+ } else if (noResults) {
342
+ content = `${this.noResults}: "${this.query}"`
343
+ } else if (this.numberOfResults === 1) {
344
+ content = this.ariaOneResult
345
+ } else {
346
+ content = this.ariaNResults.replace('{n}', this.numberOfResults)
347
+ }
348
+ }
349
+ this.ariaStatus.innerHTML = content
350
+ }
351
+
352
+ emboldenMatch(string, query) {
353
+ let reg = new RegExp(this.escapeRegExp(query).split(' ').join('[\\s,]*'), 'gi')
354
+
355
+ return string.replace(reg, '<strong>$&</strong>')
356
+ }
357
+
358
+ escapeRegExp(string) {
359
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
360
+ }
361
+
362
+ isAboveViewport(element) {
363
+ return element.getBoundingClientRect().top < 0
364
+ }
365
+
366
+ isBelowViewport(element) {
367
+ return element.getBoundingClientRect().bottom > (window.innerHeight || document.documentElement.clientHeight)
368
+ }
369
+ }
@@ -0,0 +1,10 @@
1
+ import { AutosuggestAddress } from './autosuggest-address'
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
+ }
10
+ }
@@ -0,0 +1,61 @@
1
+ {% from "govuk/components/input/macro.njk" import govukInput %}
2
+ {% from "moj/components/alert/macro.njk" import mojAlert %}
3
+
4
+ {% macro addressAutosuggest(options) %}
5
+
6
+ <div class="hmpps-js-address-autosuggest">
7
+ <input type="hidden" id="uprn" class="hmpps-js-uprn hmpps-display-none" name="uprn"/>
8
+ <div
9
+ class="hmpps-address-autosuggest{% if options.classes %} {{ options.classes }}{% endif %}"
10
+ data-aria-you-have-selected="You have selected"
11
+ data-aria-min-chars="Enter 3 or more characters for suggestions."
12
+ data-aria-one-result="There is one suggestion available."
13
+ data-aria-n-results="There are {n} suggestions available."
14
+ data-no-results="No results found. Try entering a different part of the address"
15
+ data-type-more="Enter more of the address to get results"
16
+ data-error-api="The address search is not working right now. Enter the address manually to continue."
17
+ data-styling-base-class="hmpps-address-autosuggest"
18
+ data-find-url="{{ options.findUrl or "/api/addresses/find" }}"
19
+ >
20
+ {{ govukInput({
21
+ id: 'address-autosuggest-input',
22
+ name: 'address-autosuggest-input',
23
+ classes: 'hmpps-js-autosuggest-input',
24
+ value: options.inputValue,
25
+ attributes: {
26
+ 'autocomplete': 'off',
27
+ 'aria-describedby': 'address-hint'
28
+ },
29
+ errorMessage: options.errorMessage
30
+ }) }}
31
+ <div class="hmpps-address-autosuggest__results hmpps-js-autosuggest-results">
32
+ <div id="address-results" class="hmpps-address-autosuggest__results-title hmpps-js-autosuggest-results-title">
33
+ Select an address
34
+ </div>
35
+ <ul
36
+ class="hmpps-address-autosuggest__listbox hmpps-js-autosuggest-listbox"
37
+ title="Select an address"
38
+ role="listbox"
39
+ id="address-autosuggest-listbox"
40
+ tabindex="-1"></ul>
41
+ </div>
42
+ <div
43
+ class="hmpps-address-autosuggest__instructions hmpps-js-autosuggest-instructions govuk-visually-hidden"
44
+ id="address-autosuggest-instructions"
45
+ tabindex="-1">
46
+ Use up and down keys to navigate suggestions once you have typed more than two characters. Use the enter key to select a
47
+ suggestion. Touch device users, explore by touch or with swipe gestures.
48
+ </div>
49
+ <div
50
+ class="hmpps-address-autosuggest__status hmpps-js-autosuggest-aria-status govuk-visually-hidden "
51
+ aria-live="assertive"
52
+ aria-atomic="true"
53
+ role="status"
54
+ tabindex="-1"></div>
55
+ {{ mojAlert({
56
+ variant: "warning",
57
+ classes: 'hmpps-address-autosuggest__error hmpps-js-autosuggest-error hmpps-display-none'
58
+ }) }}
59
+ </div>
60
+ </div>
61
+ {% endmacro %}
@@ -0,0 +1,5 @@
1
+ import * as addressAutosuggest from '../dps/components/address-autosuggest'
2
+
3
+ export function initAll() {
4
+ addressAutosuggest.init()
5
+ }
@@ -1 +1,2 @@
1
- @import '../dps/components/alert-flag/alert-flag';
1
+ @import '../dps/components/alert-flag/alert-flag';
2
+ @import '../dps/components/address-autosuggest/address-autosuggest';