@ministryofjustice/hmpps-connect-dps-shared-items 4.3.1 → 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,403 +1,363 @@
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 = null
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
- }
1
+ import { abortError, abortTimeout } from './abortable-fetch.js';
77
2
 
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
- this.input.addEventListener('focus', this.handleFocus.bind(this))
84
- }
85
-
86
- handleKeydown(event) {
87
- switch (event.keyCode) {
88
- case 38: {
89
- // Up
90
- event.preventDefault()
91
- this.navigateResults(-1)
92
- break
93
- }
94
- case 40: {
95
- // Down
96
- event.preventDefault()
97
- this.navigateResults(1)
98
- break
99
- }
100
- case 27: {
101
- // Escape
102
- this.clearListbox()
103
- break
104
- }
105
- case 13: {
106
- // Enter
107
- if (this.highlightedResultIndex !== null) {
108
- event.preventDefault()
3
+ // Based on ONSDigital's https://github.com/ONSdigital/design-system/blob/main/src/components/autosuggest/autosuggest.ui.js
4
+ const jsBaseClass = 'hmpps-js-autosuggest';
5
+ const classSuffixInitialised = '--initialised';
6
+ const classSuffixOption = '__option';
7
+ const classSuffixOptionFocused = `${classSuffixOption}--focused`;
8
+ const classSuffixOptionNoResults = `${classSuffixOption}--no-results`;
9
+ const classSuffixHasResults = '--has-results';
10
+ const NoResults = {
11
+ type_more: 'TYPE_MORE',
12
+ no_match: 'NO_MATCH',
13
+ server_error: 'SERVER_ERROR',
14
+ };
15
+ class AutosuggestUi {
16
+ constructor({ context, onSelect, suggestionFunction }) {
17
+ // DOM Elements
18
+ this.context = context;
19
+ this.input = context.querySelector(`.${jsBaseClass}-input`);
20
+ this.resultsContainer = context.querySelector(`.${jsBaseClass}-results`);
21
+ this.resultsTitleContainer = this.resultsContainer.querySelector(`.${jsBaseClass}-results-title`);
22
+ this.listbox = this.resultsContainer.querySelector(`.${jsBaseClass}-listbox`);
23
+ this.instructions = context.querySelector(`.${jsBaseClass}-instructions`);
24
+ this.errorContainer = context.querySelector(`.${jsBaseClass}-error`);
25
+ this.errorTextContainer = this.errorContainer.querySelector('.moj-alert__content');
26
+ this.ariaStatus = context.querySelector(`.${jsBaseClass}-aria-status`);
27
+ // Settings
28
+ this.minChars = 3;
29
+ this.ariaYouHaveSelected = context.getAttribute('data-aria-you-have-selected');
30
+ this.ariaMinChars = context.getAttribute('data-aria-min-chars');
31
+ this.ariaOneResult = context.getAttribute('data-aria-one-result');
32
+ this.ariaNResults = context.getAttribute('data-aria-n-results');
33
+ this.noResults = context.getAttribute('data-no-results');
34
+ this.typeMore = context.getAttribute('data-type-more');
35
+ this.listboxId = this.listbox.getAttribute('id');
36
+ this.errorMessage = context.getAttribute('data-error-api');
37
+ this.stylingBaseClass = context.getAttribute('data-styling-base-class');
38
+ // Callbacks
39
+ this.onSelect = onSelect;
40
+ this.fetchSuggestions = suggestionFunction;
41
+ // State
42
+ this.query = '';
43
+ this.sanitisedQuery = '';
44
+ this.results = [];
45
+ this.resultOptions = [];
46
+ this.data = [];
47
+ this.numberOfResults = 0;
48
+ this.highlightedResultIndex = null;
49
+ this.blurring = false;
50
+ this.blurTimeout = null;
51
+ this.scrolling = false;
52
+ this.scrollTimeout = null;
53
+ this.initialiseUI();
54
+ }
55
+ initialiseUI() {
56
+ this.input.setAttribute('aria-autocomplete', 'list');
57
+ this.input.setAttribute('aria-controls', this.listbox.getAttribute('id'));
58
+ this.input.setAttribute('aria-describedby', this.instructions.getAttribute('id'));
59
+ this.input.setAttribute('aria-haspopup', true);
60
+ this.input.setAttribute('aria-owns', this.listbox.getAttribute('id'));
61
+ this.input.setAttribute('aria-expanded', false);
62
+ this.input.setAttribute('autocomplete', 'off');
63
+ this.input.setAttribute('role', 'combobox');
64
+ this.context.classList.add(`${this.stylingBaseClass}${classSuffixInitialised}`);
65
+ this.bindEventListeners();
66
+ }
67
+ bindEventListeners() {
68
+ this.input.addEventListener('keydown', this.handleKeydown.bind(this));
69
+ this.input.addEventListener('keyup', this.handleKeyup.bind(this));
70
+ this.input.addEventListener('input', this.handleChange.bind(this));
71
+ this.input.addEventListener('blur', this.handleBlur.bind(this));
72
+ this.input.addEventListener('focus', this.handleFocus.bind(this));
73
+ }
74
+ handleKeydown(event) {
75
+ switch (event.keyCode) {
76
+ case 38: {
77
+ // Up
78
+ event.preventDefault();
79
+ this.navigateResults(-1);
80
+ break;
81
+ }
82
+ case 40: {
83
+ // Down
84
+ event.preventDefault();
85
+ this.navigateResults(1);
86
+ break;
87
+ }
88
+ case 27: {
89
+ // Escape
90
+ this.clearListbox();
91
+ break;
92
+ }
93
+ case 13: {
94
+ // Enter
95
+ if (this.highlightedResultIndex !== null) {
96
+ event.preventDefault();
97
+ }
98
+ break;
99
+ }
109
100
  }
110
- break
111
- }
112
101
  }
113
- }
114
-
115
- handleKeyup(event) {
116
- switch (event.keyCode) {
117
- // Up and down
118
- case 40:
119
- case 38: {
120
- event.preventDefault()
121
- break
122
- }
123
- case 13: {
124
- // Enter
125
- if (this.highlightedResultIndex !== null) {
126
- this.selectResult()
102
+ handleKeyup(event) {
103
+ switch (event.keyCode) {
104
+ // Up and down
105
+ case 40:
106
+ case 38: {
107
+ event.preventDefault();
108
+ break;
109
+ }
110
+ case 13: {
111
+ // Enter
112
+ if (this.highlightedResultIndex !== null) {
113
+ this.selectResult();
114
+ }
115
+ break;
116
+ }
127
117
  }
128
- break
129
- }
130
118
  }
131
- }
132
-
133
- handleChange() {
134
- if (!this.blurring && this.input.value.trim()) {
135
- this.getSuggestions()
136
- } else {
137
- this.clearListbox()
119
+ handleChange() {
120
+ if (!this.blurring && this.input.value.trim()) {
121
+ this.getSuggestions();
122
+ }
123
+ else {
124
+ this.clearListbox();
125
+ }
138
126
  }
139
- }
140
-
141
- handleBlur() {
142
- clearTimeout(this.blurTimeout)
143
- this.blurring = true
144
-
145
- // Timeout required to allow user to click an option before clearing the listbox:
146
- this.blurTimeout = setTimeout(() => {
147
- this.blurring = false
148
-
149
- this.context.classList.remove(`${this.stylingBaseClass}${classSuffixHasResults}`)
150
- this.input.setAttribute('aria-expanded', false)
151
- this.setAriaStatus()
152
- }, 300)
153
- }
154
-
155
- handleFocus() {
156
- if (this.listbox.innerHTML) {
157
- this.context.classList.add(`${this.stylingBaseClass}${classSuffixHasResults}`)
158
- this.input.setAttribute('aria-expanded', true)
159
- this.setAriaStatus()
127
+ handleBlur() {
128
+ clearTimeout(this.blurTimeout);
129
+ this.blurring = true;
130
+ // Timeout required to allow user to click an option before clearing the listbox:
131
+ this.blurTimeout = setTimeout(() => {
132
+ this.blurring = false;
133
+ this.context.classList.remove(`${this.stylingBaseClass}${classSuffixHasResults}`);
134
+ this.input.setAttribute('aria-expanded', false);
135
+ this.setAriaStatus();
136
+ }, 300);
160
137
  }
161
- }
162
-
163
- handleScroll(option, scrollUp) {
164
- clearTimeout(this.scrollTimeout)
165
- this.scrolling = true
166
- option.scrollIntoView(scrollUp)
167
-
168
- // Timeout required to allow user to click an option before clearing the listbox:
169
- this.scrollTimeout = setTimeout(() => {
170
- this.scrolling = false
171
- }, 300)
172
- }
173
-
174
- checkCharCount() {
175
- if (this.input.value.length > 1 && this.input.value.length < this.minChars) {
176
- this.inputTimeout = setTimeout(() => {
177
- this.handleNoResults(NoResults.type_more)
178
- }, 2000)
179
- } else {
180
- clearTimeout(this.inputTimeout)
138
+ handleFocus() {
139
+ if (this.listbox.innerHTML) {
140
+ this.context.classList.add(`${this.stylingBaseClass}${classSuffixHasResults}`);
141
+ this.input.setAttribute('aria-expanded', true);
142
+ this.setAriaStatus();
143
+ }
181
144
  }
182
- }
183
-
184
- navigateResults(direction) {
185
- let index = 0
186
- if (this.highlightedResultIndex !== null) {
187
- index = this.highlightedResultIndex + direction
145
+ handleScroll(option, scrollUp) {
146
+ clearTimeout(this.scrollTimeout);
147
+ this.scrolling = true;
148
+ option.scrollIntoView(scrollUp);
149
+ // Timeout required to allow user to click an option before clearing the listbox:
150
+ this.scrollTimeout = setTimeout(() => {
151
+ this.scrolling = false;
152
+ }, 300);
188
153
  }
189
-
190
- if (index < this.numberOfResults) {
191
- if (index < 0) {
192
- index = null
193
- }
194
-
195
- this.setHighlightedResult(index)
154
+ checkCharCount() {
155
+ if (this.input.value.length > 1 && this.input.value.length < this.minChars) {
156
+ this.inputTimeout = setTimeout(() => {
157
+ this.handleNoResults(NoResults.type_more);
158
+ }, 2000);
159
+ }
160
+ else {
161
+ clearTimeout(this.inputTimeout);
162
+ }
196
163
  }
197
- }
198
-
199
- sanitiseAutosuggestText(string) {
200
- let sanitisedString = string.toLowerCase()
201
-
202
- sanitisedString = sanitisedString.trim()
203
- sanitisedString = sanitisedString.replace(/,/g, '')
204
- sanitisedString = sanitisedString.replace(/\s\s+/g, ' ')
205
- sanitisedString = sanitisedString.replace(/[&]/g, '%26')
206
- sanitisedString = sanitisedString.replace(/\d(?=[a-z]{3,})/gi, '$& ')
207
-
208
- return sanitisedString
209
- }
210
-
211
- getSuggestions() {
212
- this.query = this.input.value
213
-
214
- const sanitisedQuery = this.sanitiseAutosuggestText(this.query)
215
-
216
- if (sanitisedQuery !== this.sanitisedQuery) {
217
- this.sanitisedQuery = sanitisedQuery
218
- this.unsetResults()
219
- this.checkCharCount()
220
-
221
- if (this.sanitisedQuery.length >= this.minChars) {
222
- this.fetchSuggestions(this.sanitisedQuery, this.data)
223
- .then(this.handleResults.bind(this))
224
- .catch(error => {
225
- if (error.name !== abortError && error.reason !== abortTimeout) {
226
- console.log('error:', error)
227
- this.handleNoResults(NoResults.server_error)
164
+ navigateResults(direction) {
165
+ let index = 0;
166
+ if (this.highlightedResultIndex !== null) {
167
+ index = this.highlightedResultIndex + direction;
168
+ }
169
+ if (index < this.numberOfResults) {
170
+ if (index < 0) {
171
+ index = null;
228
172
  }
229
- })
230
- } else {
231
- this.clearListbox()
232
- }
173
+ this.setHighlightedResult(index);
174
+ }
233
175
  }
234
- }
235
-
236
- unsetResults() {
237
- this.results = []
238
- this.resultOptions = []
239
- }
240
-
241
- clearListbox() {
242
- this.listbox.innerHTML = ''
243
- this.context.classList.remove(`${this.stylingBaseClass}${classSuffixHasResults}`)
244
- this.input.removeAttribute('aria-activedescendant')
245
- this.input.setAttribute('aria-expanded', false)
246
- this.setHighlightedResult(null)
247
- this.setAriaStatus()
248
- }
249
-
250
- handleResults(result) {
251
- this.results = result.results
252
- this.numberOfResults = this.results?.length || 0
253
- this.setAriaStatus()
254
- this.listbox.innerHTML = ''
255
-
256
- this.resultOptions =
257
- this.results?.map((result, index) => {
258
- this.resultsTitleContainer?.classList?.remove('hmpps-display-none')
259
- const listElement = this.createListElement(result, index)
260
- this.listbox.appendChild(listElement)
261
- return listElement
262
- }) ?? []
263
-
264
- this.setHighlightedResult(null)
265
- this.input.setAttribute('aria-expanded', this.numberOfResults ? 'true' : 'false')
266
-
267
- if (!!this.numberOfResults && this.sanitisedQuery.length >= this.minChars) {
268
- this.context.classList.add(`${this.stylingBaseClass}${classSuffixHasResults}`)
269
- } else {
270
- this.context.classList.remove(`${this.stylingBaseClass}${classSuffixHasResults}`)
271
- this.clearListbox()
176
+ sanitiseAutosuggestText(string) {
177
+ let sanitisedString = string.toLowerCase();
178
+ sanitisedString = sanitisedString.trim();
179
+ sanitisedString = sanitisedString.replace(/,/g, '');
180
+ sanitisedString = sanitisedString.replace(/\s\s+/g, ' ');
181
+ sanitisedString = sanitisedString.replace(/[&]/g, '%26');
182
+ sanitisedString = sanitisedString.replace(/\d(?=[a-z]{3,})/gi, '$& ');
183
+ return sanitisedString;
272
184
  }
273
-
274
- if (this.numberOfResults === 0 && this.noResults) {
275
- this.handleNoResults(NoResults.no_match)
185
+ getSuggestions() {
186
+ this.query = this.input.value;
187
+ const sanitisedQuery = this.sanitiseAutosuggestText(this.query);
188
+ if (sanitisedQuery !== this.sanitisedQuery) {
189
+ this.sanitisedQuery = sanitisedQuery;
190
+ this.unsetResults();
191
+ this.checkCharCount();
192
+ if (this.sanitisedQuery.length >= this.minChars) {
193
+ this.fetchSuggestions(this.sanitisedQuery, this.data)
194
+ .then(this.handleResults.bind(this))
195
+ .catch(error => {
196
+ if (error.name !== abortError && error.reason !== abortTimeout) {
197
+ console.log('error:', error);
198
+ this.handleNoResults(NoResults.server_error);
199
+ }
200
+ });
201
+ }
202
+ else {
203
+ this.clearListbox();
204
+ }
205
+ }
276
206
  }
277
- }
278
-
279
- createListElement(result, index) {
280
- const innerHTML = this.emboldenMatch(result.displayText, this.query)
281
- const listElement = document.createElement('li')
282
- listElement.className = `${this.stylingBaseClass}${classSuffixOption}`
283
- listElement.setAttribute('id', `${this.listboxId}__option--${index}`)
284
- listElement.setAttribute('role', 'option')
285
- listElement.innerHTML = innerHTML
286
- listElement.addEventListener('click', () => {
287
- this.selectResult(index)
288
- })
289
- listElement.addEventListener('mouseenter', () => !this.scrolling && this.setHighlightedResult(index))
290
- return listElement
291
- }
292
-
293
- handleNoResults(reason) {
294
- if ([NoResults.type_more, NoResults.no_match].includes(reason)) {
295
- this.context.classList.add(`${this.stylingBaseClass}${classSuffixHasResults}`)
296
- this.resultsTitleContainer?.classList?.add('hmpps-display-none')
297
- this.input.setAttribute('aria-expanded', true)
298
-
299
- const message = reason === NoResults.type_more ? this.typeMore : this.noResults
300
- this.setAriaStatus(message)
301
- this.listbox.innerHTML = ''
302
- const noResultsItem = document.createElement('li')
303
- noResultsItem.className = `${this.stylingBaseClass}${classSuffixOption} ${this.stylingBaseClass}${classSuffixOptionNoResults}`
304
- noResultsItem.textContent = message
305
- this.listbox.appendChild(noResultsItem)
306
- } else {
307
- this.displayAPIError()
207
+ unsetResults() {
208
+ this.results = [];
209
+ this.resultOptions = [];
308
210
  }
309
- }
310
-
311
- displayAPIError() {
312
- this.input.value = ''
313
- this.input.setAttribute('disabled', true)
314
- this.clearListbox()
315
- this.errorContainer.classList.remove('hmpps-display-none')
316
- this.errorTextContainer.textContent = this.errorMessage
317
- this.ariaStatus.setAttribute('aria-hidden', 'true')
318
- this.setAriaStatus(this.errorMessage)
319
- }
320
-
321
- setHighlightedResult(index) {
322
- this.highlightedResultIndex = index
323
-
324
- if (this.highlightedResultIndex === null) {
325
- this.input.removeAttribute('aria-activedescendant')
211
+ clearListbox() {
212
+ this.listbox.innerHTML = '';
213
+ this.context.classList.remove(`${this.stylingBaseClass}${classSuffixHasResults}`);
214
+ this.input.removeAttribute('aria-activedescendant');
215
+ this.input.setAttribute('aria-expanded', false);
216
+ this.setHighlightedResult(null);
217
+ this.setAriaStatus();
326
218
  }
327
-
328
- if (this.numberOfResults) {
329
- this.resultOptions.forEach((option, optionIndex) => {
330
- if (index !== null && optionIndex === index) {
331
- if (this.isAboveViewport(option)) {
332
- this.handleScroll(option, true)
333
- }
334
- if (this.isBelowViewport(option)) {
335
- this.handleScroll(option, false)
336
- }
337
- option.classList.add(`${this.stylingBaseClass}${classSuffixOptionFocused}`)
338
- option.setAttribute('aria-selected', true)
339
- this.input.setAttribute('aria-activedescendant', option.getAttribute('id'))
340
- const optionText = option.innerHTML.replace('<b>', '').replace('</b>', '')
341
- this.setAriaStatus(optionText)
342
- } else {
343
- option.classList.remove(`${this.stylingBaseClass}${classSuffixOptionFocused}`)
344
- option.removeAttribute('aria-selected')
219
+ handleResults(result) {
220
+ this.results = result.results;
221
+ this.numberOfResults = this.results?.length || 0;
222
+ this.setAriaStatus();
223
+ this.listbox.innerHTML = '';
224
+ this.resultOptions =
225
+ this.results?.map((result, index) => {
226
+ this.resultsTitleContainer?.classList?.remove('hmpps-display-none');
227
+ const listElement = this.createListElement(result, index);
228
+ this.listbox.appendChild(listElement);
229
+ return listElement;
230
+ }) ?? [];
231
+ this.setHighlightedResult(null);
232
+ this.input.setAttribute('aria-expanded', this.numberOfResults ? 'true' : 'false');
233
+ if (!!this.numberOfResults && this.sanitisedQuery.length >= this.minChars) {
234
+ this.context.classList.add(`${this.stylingBaseClass}${classSuffixHasResults}`);
235
+ }
236
+ else {
237
+ this.context.classList.remove(`${this.stylingBaseClass}${classSuffixHasResults}`);
238
+ this.clearListbox();
239
+ }
240
+ if (this.numberOfResults === 0 && this.noResults) {
241
+ this.handleNoResults(NoResults.no_match);
345
242
  }
346
- })
347
243
  }
348
- }
349
-
350
- selectResult(index) {
351
- if (!this.results.length) return
352
-
353
- const selectedIndex = typeof index === 'number' ? index : this.highlightedResultIndex
354
- if (selectedIndex === null || selectedIndex === undefined) return
355
-
356
- const result = this.results[selectedIndex]
357
- if (!result) return
358
-
359
- this.onSelect(result)
360
- this.clearListbox()
361
- this.setAriaStatus(`${this.ariaYouHaveSelected}: ${result.displayText}.`)
362
- }
363
-
364
- setAriaStatus(content) {
365
- if (!content) {
366
- const queryTooShort = this.sanitisedQuery.length < this.minChars
367
- const noResults = this.numberOfResults === 0
368
- if (queryTooShort) {
369
- content = this.ariaMinChars
370
- } else if (noResults) {
371
- content = `${this.noResults}: "${this.query}"`
372
- } else if (this.numberOfResults === 1) {
373
- content = this.ariaOneResult
374
- } else {
375
- content = this.ariaNResults.replace('{n}', this.numberOfResults)
376
- }
244
+ createListElement(result, index) {
245
+ const innerHTML = this.emboldenMatch(result.displayText, this.query);
246
+ const listElement = document.createElement('li');
247
+ listElement.className = `${this.stylingBaseClass}${classSuffixOption}`;
248
+ listElement.setAttribute('id', `${this.listboxId}__option--${index}`);
249
+ listElement.setAttribute('role', 'option');
250
+ listElement.innerHTML = innerHTML;
251
+ listElement.addEventListener('click', () => {
252
+ this.selectResult(index);
253
+ });
254
+ listElement.addEventListener('mouseenter', () => !this.scrolling && this.setHighlightedResult(index));
255
+ return listElement;
256
+ }
257
+ handleNoResults(reason) {
258
+ if ([NoResults.type_more, NoResults.no_match].includes(reason)) {
259
+ this.context.classList.add(`${this.stylingBaseClass}${classSuffixHasResults}`);
260
+ this.resultsTitleContainer?.classList?.add('hmpps-display-none');
261
+ this.input.setAttribute('aria-expanded', true);
262
+ const message = reason === NoResults.type_more ? this.typeMore : this.noResults;
263
+ this.setAriaStatus(message);
264
+ this.listbox.innerHTML = '';
265
+ const noResultsItem = document.createElement('li');
266
+ noResultsItem.className = `${this.stylingBaseClass}${classSuffixOption} ${this.stylingBaseClass}${classSuffixOptionNoResults}`;
267
+ noResultsItem.textContent = message;
268
+ this.listbox.appendChild(noResultsItem);
269
+ }
270
+ else {
271
+ this.displayAPIError();
272
+ }
273
+ }
274
+ displayAPIError() {
275
+ this.input.value = '';
276
+ this.input.setAttribute('disabled', true);
277
+ this.clearListbox();
278
+ this.errorContainer.classList.remove('hmpps-display-none');
279
+ this.errorTextContainer.textContent = this.errorMessage;
280
+ this.ariaStatus.setAttribute('aria-hidden', 'true');
281
+ this.setAriaStatus(this.errorMessage);
282
+ }
283
+ setHighlightedResult(index) {
284
+ this.highlightedResultIndex = index;
285
+ if (this.highlightedResultIndex === null) {
286
+ this.input.removeAttribute('aria-activedescendant');
287
+ }
288
+ if (this.numberOfResults) {
289
+ this.resultOptions.forEach((option, optionIndex) => {
290
+ if (index !== null && optionIndex === index) {
291
+ if (this.isAboveViewport(option)) {
292
+ this.handleScroll(option, true);
293
+ }
294
+ if (this.isBelowViewport(option)) {
295
+ this.handleScroll(option, false);
296
+ }
297
+ option.classList.add(`${this.stylingBaseClass}${classSuffixOptionFocused}`);
298
+ option.setAttribute('aria-selected', true);
299
+ this.input.setAttribute('aria-activedescendant', option.getAttribute('id'));
300
+ const optionText = option.innerHTML.replace('<b>', '').replace('</b>', '');
301
+ this.setAriaStatus(optionText);
302
+ }
303
+ else {
304
+ option.classList.remove(`${this.stylingBaseClass}${classSuffixOptionFocused}`);
305
+ option.removeAttribute('aria-selected');
306
+ }
307
+ });
308
+ }
309
+ }
310
+ selectResult(index) {
311
+ if (!this.results.length)
312
+ return;
313
+ const selectedIndex = typeof index === 'number' ? index : this.highlightedResultIndex;
314
+ if (selectedIndex === null || selectedIndex === undefined)
315
+ return;
316
+ const result = this.results[selectedIndex];
317
+ if (!result)
318
+ return;
319
+ this.onSelect(result);
320
+ this.clearListbox();
321
+ this.setAriaStatus(`${this.ariaYouHaveSelected}: ${result.displayText}.`);
322
+ }
323
+ setAriaStatus(content) {
324
+ if (!content) {
325
+ const queryTooShort = this.sanitisedQuery.length < this.minChars;
326
+ const noResults = this.numberOfResults === 0;
327
+ if (queryTooShort) {
328
+ content = this.ariaMinChars;
329
+ }
330
+ else if (noResults) {
331
+ content = `${this.noResults}: "${this.query}"`;
332
+ }
333
+ else if (this.numberOfResults === 1) {
334
+ content = this.ariaOneResult;
335
+ }
336
+ else {
337
+ content = this.ariaNResults.replace('{n}', this.numberOfResults);
338
+ }
339
+ }
340
+ this.ariaStatus.textContent = content;
341
+ }
342
+ emboldenMatch(string, query) {
343
+ return this.escapeRegExp(query)
344
+ .trim()
345
+ .replace(/\s\s+/g, ' ')
346
+ .split(' ')
347
+ .reduce((accumulator, currentValue) => {
348
+ let reg = new RegExp(`(?<!<)${currentValue}(?![\\w\\s]*>)`, 'gi');
349
+ return accumulator.replace(reg, '<b>$&</b>');
350
+ }, string);
351
+ }
352
+ escapeRegExp(string) {
353
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
354
+ }
355
+ isAboveViewport(element) {
356
+ return element.getBoundingClientRect().top < 0;
357
+ }
358
+ isBelowViewport(element) {
359
+ return element.getBoundingClientRect().bottom > (window.innerHeight || document.documentElement.clientHeight);
377
360
  }
378
- this.ariaStatus.textContent = content
379
- }
380
-
381
- emboldenMatch(string, query) {
382
- return this.escapeRegExp(query)
383
- .trim()
384
- .replace(/\s\s+/g, ' ')
385
- .split(' ')
386
- .reduce((accumulator, currentValue) => {
387
- let reg = new RegExp(`(?<!<)${currentValue}(?![\\w\\s]*>)`, 'gi')
388
- return accumulator.replace(reg, '<b>$&</b>')
389
- }, string)
390
- }
391
-
392
- escapeRegExp(string) {
393
- return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
394
- }
395
-
396
- isAboveViewport(element) {
397
- return element.getBoundingClientRect().top < 0
398
- }
399
-
400
- isBelowViewport(element) {
401
- return element.getBoundingClientRect().bottom > (window.innerHeight || document.documentElement.clientHeight)
402
- }
403
361
  }
362
+
363
+ export { AutosuggestUi, NoResults };