-
Notifications
You must be signed in to change notification settings - Fork 14
/
autocomplete.js
457 lines (421 loc) · 17.5 KB
/
autocomplete.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
// We use our own fork of accessible-autocomplete because the main package is not being actively maintained and has bugs which we needed to fix
// There is a changelog for the fixes we've added -- https://github.com/Financial-Times/accessible-autocomplete/blob/master/CHANGELOG.md#210---2021-05-24
// Below are the pull-requests to accessible-autocomplete which would fix the bugs:
// https://github.com/alphagov/accessible-autocomplete/pull/497
// https://github.com/alphagov/accessible-autocomplete/pull/491
// https://github.com/alphagov/accessible-autocomplete/pull/496
// If the above pull-requests are merged and published, then we can stop using our fork
import accessibleAutocomplete from '@financial-times/accessible-autocomplete';
/**
* @typedef CharacterHighlight - The character and whether it should be highlighted
* @type {Array}
* @property {string} 0 - the character in the suggestion
* @property {boolean} 1 - should it be highlighted?
*/
/**
* @param {string} suggestion - Text which is going to be suggested to the user
* @param {string} query - Text which was typed into the autocomplete by the user
* @returns {CharacterHighlight[]} An array of arrays which contain two items, the first is the character in the suggestion, the second is a boolean which indicates whether the character should be highlighted.
*/
function highlightSuggestion(suggestion, query) {
const result = suggestion.split('');
const matchIndex = suggestion.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
return result.map(function(character, index) {
let shouldHighlight = true;
const hasMatched = matchIndex > -1;
const characterIsWithinMatch = index >= matchIndex && index <= matchIndex + query.length - 1;
if (hasMatched && characterIsWithinMatch) {
shouldHighlight = false;
}
return [character, shouldHighlight];
});
}
/**
* Create DOM for the loading container.
*
* @returns {HTMLDivElement} The loading container.
*/
function createLoadingContainer() {
const fragment = document.createRange().createContextualFragment(`
<div class="o-autocomplete__menu-loading-container">
<div class="o-autocomplete__menu-loading"></div>
</div>
`);
return fragment.querySelector('*');
}
/**
* Show the loading panel
*
* @param {Autocomplete} instance The autocomplete instance whose loading panel should be shown
* @returns {void}
*/
function showLoadingPane(instance) {
instance.container.appendChild(instance.loadingContainer);
const menu = instance.container.querySelector('.o-autocomplete__menu');
if (menu) {
menu.classList.add('o-autocomplete__menu--loading');
}
}
/**
* Hide the loading panel
*
* @param {Autocomplete} instance The autocomplete instance whose loading panel should be hidden
* @returns {void}
*/
function hideLoadingPane(instance) {
if (instance.container.contains(instance.loadingContainer)) {
instance.container.removeChild(instance.loadingContainer);
}
const menu = instance.container.querySelector('.o-autocomplete__menu');
if (menu) {
menu.classList.remove('o-autocomplete__menu--loading');
}
}
/**
* Create the DOM tree which corresponds to
* <button class="o-autocomplete__clear" type="button" aria-controls=${autocompleteEl.id} title="Clear input">
* <span class="o-autocomplete__visually-hidden">Clear input</span>
* </button>
*
* @param {string} id The id of the autocomplete input to associate the clear button with
* @returns {HTMLButtonElement} The clear button DOM tree
*/
function createClearButton(id) {
const fragment = document.createRange().createContextualFragment(`
<button class="o-autocomplete__clear" type="button" aria-controls="${id}" title="Clear input">
<span class="o-autocomplete__visually-hidden">Clear input</span>
</button>
`);
return fragment.querySelector('*');
}
/**
* Attach the clear button and corresponding event listeners to the o-autocomplete instance
*
* @param {Autocomplete} instance The autocomplete instance to setup the clear button for
* @returns {void}
*/
function initClearButton(instance) {
const input = instance.autocompleteEl.querySelector('input');
const clearButton = createClearButton(input.id);
let timeout = null;
clearButton.addEventListener('click', () => {
// Remove the loading pane, in-case of a slow response.
hideLoadingPane(instance);
clearButton.parentElement.removeChild(clearButton);
// Clear the input
input.value = '';
// We need to wait longer than 100ms before focusing
// onto the input element because accessible-autocomplete
// only checks the value of the input every 100ms.
// If we modify input.value and then focus into the input in less
// than 100ms, accessible-autocomplete would not have the updated
// value and would instead write the old value back into the input.
// https://github.com/alphagov/accessible-autocomplete/blob/935f0d43aea1c606e6b38985e3fe7049ddbe98be/src/autocomplete.js#L107-L125
if (!timeout) {
// The user could press the button multiple times
// whilst the setTimeout handler has yet to execute
// We only want to call the handler once
timeout = setTimeout(() => {
input.focus();
timeout = null;
}, 110);
}
});
input.addEventListener('input', () => {
const textInInput = input.value.length > 0;
const clearButtonOnPage = instance.autocompleteEl.contains(clearButton);
if (textInInput) {
if (!clearButtonOnPage) {
instance.autocompleteEl.appendChild(clearButton);
}
} else {
if (clearButtonOnPage) {
clearButton.parentElement.removeChild(clearButton);
}
}
});
}
/**
* @callback PopulateOptions
* @param {Array<*>} options - The options which match the rext which was typed into the autocomplete by the user
* @returns {void}
*/
/**
* @callback Source
* @param {string} query - Text which was typed into the autocomplete by the user
* @param {PopulateOptions} populateOptions - Function to call when ready to update the suggestions dropdown
* @returns {void}
*/
/**
* @callback MapOptionToSuggestedValue
* @param {*} option - The option to transform into a suggestion string
* @returns {string} The string to display as the suggestions for this option
*/
/**
* @callback onConfirm
* @param {*} option - The option the user selected
* @returns {void}
*/
/**
* @callback SuggestionTemplate
* @param {*} option - The option to render
* @returns {string} The html string to render for this suggestion.
*/
/**
* @typedef {object} AutocompleteOptions
* @property {string} [defaultValue] - Specify a string to prefill the autocomplete with
* @property {Source} [source] - The function which retrieves the suggestions to display
* @property {MapOptionToSuggestedValue} [mapOptionToSuggestedValue] - Function which transforms a suggestion before rendering.
* @property {onConfirm} [onConfirm] - Function which is called when the user selects an option
* @property {SuggestionTemplate} [suggestionTemplate] - Function to override how a suggestion item is rendered.
* @property {boolean} [autoselect] - Boolean to specify whether first option in suggestions list is highlighted.
*/
class Autocomplete {
/**
* Class constructor.
*
* @param {HTMLElement} [autocompleteEl] - The component element in the DOM
* @param {AutocompleteOptions} [options={}] - An options object for configuring the component
*/
constructor (autocompleteEl, options) {
this.autocompleteEl = autocompleteEl;
const opts = options || Autocomplete.getDataAttributes(autocompleteEl);
this.options = {};
if (opts.source) {
this.options.source = opts.source;
this.options.defaultValue = opts.defaultValue;
}
if (opts.mapOptionToSuggestedValue) {
this.options.mapOptionToSuggestedValue = opts.mapOptionToSuggestedValue;
}
if (opts.onConfirm) {
this.options.onConfirm = opts.onConfirm;
}
if (opts.suggestionTemplate) {
this.options.suggestionTemplate = opts.suggestionTemplate;
}
if (opts.autoselect) {
this.options.autoselect = opts.autoselect;
}
const container = document.createElement('div');
container.classList.add('o-autocomplete__listbox-container');
this.container = container;
const selectInputElement = autocompleteEl.querySelector('select');
if (!this.options.source && !selectInputElement) {
throw new Error("Could not find a source for auto-completion options. Add a `select` element to your markup, or configure a `source` function to fetch autocomplete options.");
}
if (this.options.source) {
// If source is a string, then it is the name of a global function to use.
// If source is not a string, then it is a function to use.
/**
* @type {Source}
*/
const customSource = typeof this.options.source === 'string' ? window[this.options.source] : this.options.source;
// If mapOptionToSuggestedValue is a string, then it is the name of a global function to use.
// If mapOptionToSuggestedValue is not a string, then it is a function to use.
/**
* @type {MapOptionToSuggestedValue}
*/
this.mapOptionToSuggestedValue = typeof this.options.mapOptionToSuggestedValue === 'string' ? window[this.options.mapOptionToSuggestedValue] : this.options.mapOptionToSuggestedValue;
/**
* @param {string} query - Text which was typed into the autocomplete by the user
* @param {PopulateOptions} populateOptions - Function to call when ready to update the suggestions dropdown
* @returns {void}
*/
this.options.source = (query, populateOptions) => {
showLoadingPane(this);
/**
* @param {Array<string>} options - The options which match the rext which was typed into the autocomplete by the user
* @returns {void}
*/
const callback = (options) => {
hideLoadingPane(this);
populateOptions(options);
};
customSource(query, callback);
};
const input = autocompleteEl.querySelector('input');
const id = input.getAttribute('id');
const name = input.getAttribute('name');
const placeholder = input.getAttribute('placeholder');
const isRequired = input.hasAttribute('required');
if (!id) {
throw new Error("Missing `id` attribute on the o-autocomplete input. An `id` needs to be set as it is used within the o-autocomplete to implement the accessibility features.");
}
this.autocompleteEl.innerHTML = '';
this.autocompleteEl.appendChild(this.container);
accessibleAutocomplete({
element: this.container,
id: id,
name: name,
placeholder: placeholder,
required: isRequired,
onConfirm: (option) => {
if (option && this.options.onConfirm) {
this.options.onConfirm(option);
}
},
source: this.options.source,
cssNamespace: 'o-autocomplete',
displayMenu: 'overlay',
defaultValue: this.options.defaultValue || '',
showNoOptionsFound: false,
autoselect: this.options.autoselect || false,
templates: {
/**
* Used when rendering suggestions, the return value of this will be used as the innerHTML for a single suggestion.
*
* @param {*} option The suggestion to apply the template with.
* @returns {string|undefined} HTML string to represent a single suggestion.
*/
suggestion: (option) => {
// If the suggestionTemplate override option is provided,
// use that to render the suggestion.
if(typeof this.options.suggestionTemplate === 'function') {
return this.options.suggestionTemplate(option);
}
if (typeof option === 'object') {
// If the `mapOptionToSuggestedValue` function is defined
// Apply the function to the option. This is a way for the
// consuming application to decide what text should be
// shown for this option.
// This is usually defined when the option is not already a string.
// For example, if the option is an object which contains a property
// which should be used as the suggestion string.
if (typeof this.mapOptionToSuggestedValue === 'function') {
option = this.mapOptionToSuggestedValue(option);
}
}
if (typeof option !== 'string' && typeof option !== 'undefined') {
throw new Error(`The option trying to be displayed as a suggestion is not a string, it is "${typeof option}". o-autocomplete can only display strings as suggestions. Define a \`mapOptionToSuggestedValue\` function to convert the option into a string to be used as the suggestion.`);
}
return this.suggestionTemplate(option);
},
/**
* Used when a suggestion is selected, the return value of this will be used as the value for the input element.
*
* @param {*} option The suggestion which was selected.
* @returns {string|undefined} String to represent the suggestion.
*/
inputValue: (option) => {
if (typeof option === 'object') {
// If the `mapOptionToSuggestedValue` function is defined
// Apply the function to the option. This is a way for the
// consuming application to decide what text should be
// shown for this option.
// This is usually defined when the option is not already a string.
// For example, if the option is an object which contains a property
// which should be used as the suggestion string.
if (typeof this.mapOptionToSuggestedValue === 'function') {
option = this.mapOptionToSuggestedValue(option);
}
}
if (typeof option !== 'string' && typeof option !== 'undefined') {
throw new Error(`The option trying to be displayed as a suggestion is not a string, it is "${typeof option}". o-autocomplete can only display strings as suggestions. Define a \`mapOptionToSuggestedValue\` function to convert the option into a string to be used as the suggestion.`);
}
return option;
}
}
});
} else {
const id = selectInputElement.getAttribute('id');
const name = selectInputElement.getAttribute('name');
const isRequired = selectInputElement.hasAttribute('required');
if (!id) {
throw new Error("Missing `id` attribute on the o-autocomplete input. An `id` needs to be set as it is used within the o-autocomplete to implement the accessibility features.");
}
this.autocompleteEl.appendChild(this.container);
this.container.appendChild(selectInputElement);
accessibleAutocomplete.enhanceSelectElement({
selectElement: selectInputElement,
name: name,
required: isRequired,
onConfirm: (option) => {
if (option && this.options.onConfirm) {
this.options.onConfirm(option);
}
},
autoselect: this.options.autoselect || false,
// To fallback with JS an enhanced element's default value should
// be set using static html.
defaultValue: '',
placeholder: '',
cssNamespace: 'o-autocomplete',
displayMenu: 'overlay',
showNoOptionsFound: false,
templates: {
suggestion: this.suggestionTemplate.bind(this)
}
});
selectInputElement.parentElement.removeChild(selectInputElement); // Remove the original select element
}
this.loadingContainer = createLoadingContainer();
initClearButton(this);
}
/**
* Used when rendering suggestions, the return value of this will be used as the innerHTML for a single suggestion.
*
* @param {string} suggestedValue The suggestion to apply the template with.
* @returns {string} HTML string to be represent a single suggestion.
*/
suggestionTemplate (suggestedValue) {
// o-autocomplete has a UI design to highlight characters in the suggestions.
const input = this.autocompleteEl.querySelector('input');
/**
* @type {CharacterHighlight[]} An array of arrays which contain two items, the first is the character in the suggestion, the second is a boolean which indicates whether the character should be highlighted.
*/
const characters = highlightSuggestion(suggestedValue, input ? input.value : suggestedValue);
let output = '';
for (const [character, shoudHighlight] of characters) {
if (shoudHighlight) {
output += `<span class="o-autocomplete__option--highlight">${character}</span>`;
} else {
output += `${character}`;
}
}
const span = document.createElement('span');
span.setAttribute('aria-label', suggestedValue);
span.innerHTML = output;
return span.outerHTML;
}
/**
* Get the data attributes from the AutocompleteElement. If the element is being set up
* declaratively, this method is used to extract the data attributes from the DOM.
*
* @param {HTMLElement} autocompleteEl - The component element in the DOM
* @returns {object} An options object which can be used for configuring the component
*/
static getDataAttributes (autocompleteEl) {
if (!(autocompleteEl instanceof HTMLElement)) {
return {};
}
if (autocompleteEl.dataset.oAutocompleteSource) {
return {
source: autocompleteEl.dataset.oAutocompleteSource,
defaultValue: autocompleteEl.dataset.oAutocompleteDefaultValue
};
} else {
return {};
}
}
/**
* Initialise o-autocomplete component/s.
*
* @param {(HTMLElement | string)} rootElement - The root element to intialise the component in, or a CSS selector for the root element
* @param {object} [options={}] - An options object for configuring the component
* @returns {Autocomplete|Autocomplete[]} The newly constructed Autocomplete components
*/
static init (rootElement, options) {
if (!rootElement) {
rootElement = document.body;
}
if (!(rootElement instanceof HTMLElement)) {
rootElement = document.querySelector(rootElement);
}
if (rootElement instanceof HTMLElement && rootElement.matches('[data-o-component=o-autocomplete]')) {
return new Autocomplete(rootElement, options);
}
return Array.from(rootElement.querySelectorAll('[data-o-component="o-autocomplete"]'), rootEl => new Autocomplete(rootEl, options));
}
}
export default Autocomplete;