@radarlabs/plugin-autocomplete 5.0.0-beta.5
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/README.md +128 -0
- package/cdn/radar-autocomplete.css +1 -0
- package/cdn/radar-autocomplete.js +546 -0
- package/cdn/radar-autocomplete.min.js +1 -0
- package/dist/autocomplete.d.ts +42 -0
- package/dist/cdn-entry.d.ts +1 -0
- package/dist/errors.d.ts +4 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +544 -0
- package/dist/index.js.map +1 -0
- package/dist/radar-autocomplete.css +1 -0
- package/dist/types.d.ts +28 -0
- package/dist/version.d.ts +2 -0
- package/package.json +41 -0
- package/src/autocomplete.ts +607 -0
- package/src/cdn-entry.ts +4 -0
- package/src/errors.ts +9 -0
- package/src/index.ts +33 -0
- package/src/types.ts +30 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { RadarAutocompleteContainerNotFound } from './errors';
|
|
2
|
+
import type { RadarAutocompleteUIOptions, RadarAutocompleteConfig } from './types';
|
|
3
|
+
import type { RadarAutocompleteParams, Location, RadarPluginContext } from 'radar-sdk-js';
|
|
4
|
+
|
|
5
|
+
const CLASSNAMES = {
|
|
6
|
+
WRAPPER: 'radar-autocomplete-wrapper',
|
|
7
|
+
INPUT: 'radar-autocomplete-input',
|
|
8
|
+
SEARCH_ICON: 'radar-autocomplete-search-icon',
|
|
9
|
+
RESULTS_LIST: 'radar-autocomplete-results-list',
|
|
10
|
+
RESULTS_ITEM: 'radar-autocomplete-results-item',
|
|
11
|
+
RESULTS_MARKER: 'radar-autocomplete-results-marker',
|
|
12
|
+
SELECTED_ITEM: 'radar-autocomplete-results-item-selected',
|
|
13
|
+
POWERED_BY_RADAR: 'radar-powered',
|
|
14
|
+
NO_RESULTS: 'radar-no-results',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const defaultAutocompleteOptions: RadarAutocompleteUIOptions = {
|
|
18
|
+
container: 'autocomplete',
|
|
19
|
+
debounceMS: 200, // Debounce time in milliseconds
|
|
20
|
+
minCharacters: 3, // Minimum number of characters to trigger autocomplete
|
|
21
|
+
limit: 8, // Maximum number of autocomplete results
|
|
22
|
+
placeholder: 'Search address', // Placeholder text for the input field
|
|
23
|
+
responsive: true,
|
|
24
|
+
disabled: false,
|
|
25
|
+
showMarkers: true,
|
|
26
|
+
hideResultsOnBlur: true,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// determine whether to use px or CSS string
|
|
30
|
+
const formatCSSValue = (value: string | number) => {
|
|
31
|
+
if (typeof value === 'number') {
|
|
32
|
+
return `${value}px`;
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const DEFAULT_WIDTH = 400;
|
|
38
|
+
const setWidth = (input: HTMLElement, options: RadarAutocompleteUIOptions) => {
|
|
39
|
+
// if responsive and width is provided, treat it as maxWidth
|
|
40
|
+
if (options.responsive) {
|
|
41
|
+
input.style.width = '100%';
|
|
42
|
+
if (options.width) {
|
|
43
|
+
input.style.maxWidth = formatCSSValue(options.width);
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// if not responsive, set fixed width and unset maxWidth
|
|
49
|
+
input.style.width = formatCSSValue(options.width || DEFAULT_WIDTH);
|
|
50
|
+
input.style.removeProperty('max-width');
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const setHeight = (resultsList: HTMLElement, options: RadarAutocompleteUIOptions) => {
|
|
54
|
+
if (options.maxHeight) {
|
|
55
|
+
resultsList.style.maxHeight = formatCSSValue(options.maxHeight);
|
|
56
|
+
resultsList.style.overflowY = 'auto'; /* allow overflow when maxHeight is applied */
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const getMarkerIcon = (color: string = "#ACBDC8") => {
|
|
61
|
+
const fill = color.replace('#', '%23');
|
|
62
|
+
const svg = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
63
|
+
<path d="M12.5704 6.57036C12.5704 4.11632 10.6342 2.11257 8.21016 2C8.14262 2 8.06757 2 8.00003 2C7.93249 2 7.85744 2 7.7899 2C5.35838 2.11257 3.42967 4.11632 3.42967 6.57036C3.42967 6.60037 3.42967 6.6379 3.42967 6.66792C3.42967 6.69794 3.42967 6.73546 3.42967 6.76548C3.42967 9.46717 7.09196 13.3621 7.4672 13.7598C7.61729 13.9174 7.84994 14 8.00003 14C8.15012 14 8.38277 13.9174 8.53286 13.7598C8.9156 13.3621 12.5704 9.46717 12.5704 6.76548C12.5704 6.72795 12.5704 6.69794 12.5704 6.66792C12.5704 6.6379 12.5704 6.60037 12.5704 6.57036ZM7.99252 8.28893C7.04693 8.28893 6.27395 7.52345 6.27395 6.57036C6.27395 5.61726 7.03943 4.85178 7.99252 4.85178C8.94562 4.85178 9.7111 5.61726 9.7111 6.57036C9.7111 7.52345 8.94562 8.28893 7.99252 8.28893Z" fill="${fill}"/>
|
|
64
|
+
</svg>`.trim();
|
|
65
|
+
return `data:image/svg+xml;charset=utf-8,${svg}`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AutocompleteUI {
|
|
70
|
+
private ctx: RadarPluginContext;
|
|
71
|
+
config: RadarAutocompleteConfig;
|
|
72
|
+
isOpen: boolean;
|
|
73
|
+
results: any[];
|
|
74
|
+
highlightedIndex: number;
|
|
75
|
+
debouncedFetchResults: (...args: any[]) => Promise<any>;
|
|
76
|
+
near?: string;
|
|
77
|
+
|
|
78
|
+
// DOM elements
|
|
79
|
+
container: HTMLElement;
|
|
80
|
+
inputField: HTMLInputElement;
|
|
81
|
+
resultsList: HTMLElement;
|
|
82
|
+
wrapper: HTMLElement;
|
|
83
|
+
poweredByLink?: HTMLElement;
|
|
84
|
+
|
|
85
|
+
constructor(options: Partial<RadarAutocompleteUIOptions>, ctx: RadarPluginContext) {
|
|
86
|
+
this.ctx = ctx;
|
|
87
|
+
const { Logger } = this.ctx;
|
|
88
|
+
this.config = Object.assign({}, defaultAutocompleteOptions, options) as RadarAutocompleteConfig;
|
|
89
|
+
|
|
90
|
+
// setup state
|
|
91
|
+
this.isOpen = false;
|
|
92
|
+
this.debouncedFetchResults = this.debounce(this.fetchResults, this.config.debounceMS);
|
|
93
|
+
this.results = [];
|
|
94
|
+
this.highlightedIndex = -1;
|
|
95
|
+
|
|
96
|
+
// set threshold alias
|
|
97
|
+
if (this.config.threshold !== undefined) {
|
|
98
|
+
this.config.minCharacters = this.config.threshold;
|
|
99
|
+
Logger.warn('AutocompleteUI option "threshold" is deprecated, use "minCharacters" instead.');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (options.near) {
|
|
103
|
+
if (typeof options.near === 'string') {
|
|
104
|
+
this.near = options.near;
|
|
105
|
+
} else {
|
|
106
|
+
this.near = `${options.near.latitude},${options.near.longitude}`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// get container element
|
|
111
|
+
let containerEL;
|
|
112
|
+
if (typeof this.config.container === 'string') { // lookup container element by ID
|
|
113
|
+
containerEL = document.getElementById(this.config.container);
|
|
114
|
+
} else { // use provided element
|
|
115
|
+
containerEL = this.config.container; // HTMLElement
|
|
116
|
+
}
|
|
117
|
+
if (!containerEL) {
|
|
118
|
+
throw new RadarAutocompleteContainerNotFound(`Could not find container element: ${this.config.container}`);
|
|
119
|
+
}
|
|
120
|
+
this.container = containerEL;
|
|
121
|
+
|
|
122
|
+
// create wrapper for input and result list
|
|
123
|
+
this.wrapper = document.createElement('div');
|
|
124
|
+
this.wrapper.classList.add(CLASSNAMES.WRAPPER);
|
|
125
|
+
this.wrapper.style.display = this.config.responsive ? 'block' : 'inline-block';
|
|
126
|
+
setWidth(this.wrapper, this.config);
|
|
127
|
+
|
|
128
|
+
// result list element
|
|
129
|
+
this.resultsList = document.createElement('ul');
|
|
130
|
+
this.resultsList.classList.add(CLASSNAMES.RESULTS_LIST);
|
|
131
|
+
this.resultsList.setAttribute('id', CLASSNAMES.RESULTS_LIST);
|
|
132
|
+
this.resultsList.setAttribute('role', 'listbox');
|
|
133
|
+
this.resultsList.setAttribute('aria-live', 'polite');
|
|
134
|
+
this.resultsList.setAttribute('aria-label', 'Search results');
|
|
135
|
+
setHeight(this.resultsList, this.config);
|
|
136
|
+
|
|
137
|
+
if (containerEL.nodeName === 'INPUT') {
|
|
138
|
+
// if an <input> element is provided, use that as the inputField,
|
|
139
|
+
// and append the resultList to it's parent container
|
|
140
|
+
this.inputField = containerEL as HTMLInputElement;
|
|
141
|
+
|
|
142
|
+
// append to dom
|
|
143
|
+
this.wrapper.appendChild(this.resultsList);
|
|
144
|
+
(containerEL.parentNode as any).appendChild(this.wrapper);
|
|
145
|
+
|
|
146
|
+
} else {
|
|
147
|
+
// if container is not an input, create new input and append to container
|
|
148
|
+
|
|
149
|
+
// create new input
|
|
150
|
+
this.inputField = document.createElement('input');
|
|
151
|
+
this.inputField.classList.add(CLASSNAMES.INPUT);
|
|
152
|
+
this.inputField.placeholder = this.config.placeholder;
|
|
153
|
+
this.inputField.type = 'text';
|
|
154
|
+
this.inputField.disabled = this.config.disabled;
|
|
155
|
+
|
|
156
|
+
// search icon
|
|
157
|
+
const searchIcon = document.createElement('div');
|
|
158
|
+
searchIcon.classList.add(CLASSNAMES.SEARCH_ICON);
|
|
159
|
+
|
|
160
|
+
// append to DOM
|
|
161
|
+
this.wrapper.appendChild(this.inputField);
|
|
162
|
+
this.wrapper.appendChild(this.resultsList);
|
|
163
|
+
this.wrapper.appendChild(searchIcon);
|
|
164
|
+
this.container.appendChild(this.wrapper);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// disable browser autofill
|
|
168
|
+
this.inputField.setAttribute('autocomplete', 'off');
|
|
169
|
+
|
|
170
|
+
// set aria roles
|
|
171
|
+
this.inputField.setAttribute('role', 'combobox');
|
|
172
|
+
this.inputField.setAttribute('aria-controls', CLASSNAMES.RESULTS_LIST);
|
|
173
|
+
this.inputField.setAttribute('aria-expanded', 'false');
|
|
174
|
+
this.inputField.setAttribute('aria-haspopup', 'listbox');
|
|
175
|
+
this.inputField.setAttribute('aria-autocomplete', 'list');
|
|
176
|
+
this.inputField.setAttribute('aria-activedescendant', '');
|
|
177
|
+
|
|
178
|
+
// setup event listeners
|
|
179
|
+
this.inputField.addEventListener('input', this.handleInput.bind(this));
|
|
180
|
+
this.inputField.addEventListener('keydown', this.handleKeyboardNavigation.bind(this));
|
|
181
|
+
if (this.config.hideResultsOnBlur) {
|
|
182
|
+
this.inputField.addEventListener('blur', this.close.bind(this), true);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
Logger.debug('AutocompleteUI initialized with options', this.config);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
public handleInput() {
|
|
189
|
+
const { Logger } = this.ctx;
|
|
190
|
+
|
|
191
|
+
// Fetch autocomplete results and display them
|
|
192
|
+
const query = this.inputField.value;
|
|
193
|
+
if (query.length < this.config.minCharacters) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.debouncedFetchResults(query)
|
|
198
|
+
.then((results: any[]) => {
|
|
199
|
+
const onResults = this.config.onResults;
|
|
200
|
+
if (onResults) {
|
|
201
|
+
onResults(results);
|
|
202
|
+
}
|
|
203
|
+
this.displayResults(results);
|
|
204
|
+
})
|
|
205
|
+
.catch((error) => {
|
|
206
|
+
Logger.warn(`Autocomplete ui error: ${error.message}`);
|
|
207
|
+
const onError = this.config.onError;
|
|
208
|
+
if (onError) {
|
|
209
|
+
onError(error);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
public debounce(fn: Function, delay: number) {
|
|
215
|
+
let timeoutId: any;
|
|
216
|
+
let resolveFn: any;
|
|
217
|
+
let rejectFn: any;
|
|
218
|
+
|
|
219
|
+
return (...args: any[]) => {
|
|
220
|
+
clearTimeout(timeoutId);
|
|
221
|
+
|
|
222
|
+
timeoutId = setTimeout(() => {
|
|
223
|
+
const result = fn.apply(this, args);
|
|
224
|
+
|
|
225
|
+
if (result instanceof Promise) {
|
|
226
|
+
result
|
|
227
|
+
.then((value) => {
|
|
228
|
+
if (resolveFn) {
|
|
229
|
+
resolveFn(value);
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
.catch((error) => {
|
|
233
|
+
if (rejectFn) {
|
|
234
|
+
rejectFn(error);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}, delay);
|
|
239
|
+
|
|
240
|
+
return new Promise((resolve, reject) => {
|
|
241
|
+
resolveFn = resolve;
|
|
242
|
+
rejectFn = reject;
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
public async fetchResults(query: string) {
|
|
248
|
+
const { apis } = this.ctx;
|
|
249
|
+
const { limit, layers, countryCode, expandUnits, mailable, lang, postalCode, onRequest } = this.config;
|
|
250
|
+
|
|
251
|
+
const params: RadarAutocompleteParams = {
|
|
252
|
+
query,
|
|
253
|
+
limit,
|
|
254
|
+
layers,
|
|
255
|
+
countryCode,
|
|
256
|
+
expandUnits,
|
|
257
|
+
mailable,
|
|
258
|
+
lang,
|
|
259
|
+
postalCode,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (this.near) {
|
|
263
|
+
params.near = this.near;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (onRequest) {
|
|
267
|
+
onRequest(params);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const { addresses } = await apis.Search.autocomplete(params, 'autocomplete-ui');
|
|
271
|
+
return addresses;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
public displayResults(results: any[]) {
|
|
275
|
+
// Clear the previous results
|
|
276
|
+
this.clearResultsList();
|
|
277
|
+
this.results = results;
|
|
278
|
+
|
|
279
|
+
let marker: HTMLElement;
|
|
280
|
+
if (this.config.showMarkers) {
|
|
281
|
+
marker = document.createElement('img');
|
|
282
|
+
marker.classList.add(CLASSNAMES.RESULTS_MARKER);
|
|
283
|
+
marker.setAttribute('src', getMarkerIcon(this.config.markerColor));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Create and append list items for each result
|
|
287
|
+
results.forEach((result, index) => {
|
|
288
|
+
const li = document.createElement('li');
|
|
289
|
+
li.classList.add(CLASSNAMES.RESULTS_ITEM);
|
|
290
|
+
li.setAttribute('role', 'option');
|
|
291
|
+
li.setAttribute('id', `${CLASSNAMES.RESULTS_ITEM}}-${index}`);
|
|
292
|
+
|
|
293
|
+
// construct result with bolded label
|
|
294
|
+
let listContent;
|
|
295
|
+
if (result.formattedAddress.includes(result.addressLabel) && result.layer !== 'postalCode') {
|
|
296
|
+
// if addressLabel is contained in the formatted address, bold the address label
|
|
297
|
+
const regex = new RegExp(`(${result.addressLabel}),?`);
|
|
298
|
+
listContent = result.formattedAddress.replace(regex, '<b>$1</b>');
|
|
299
|
+
} else {
|
|
300
|
+
// otherwise append the address or place label to formatted address
|
|
301
|
+
const label = result.placeLabel || result.addressLabel;
|
|
302
|
+
listContent = `<b>${label}</b> ${result.formattedAddress}`;
|
|
303
|
+
}
|
|
304
|
+
li.innerHTML = listContent;
|
|
305
|
+
|
|
306
|
+
// prepend marker if enabled
|
|
307
|
+
if (marker) {
|
|
308
|
+
li.prepend(marker.cloneNode());
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// add click handler to each result, use mousedown to fire before blur event
|
|
312
|
+
li.addEventListener('mousedown', () => {
|
|
313
|
+
this.select(index);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
this.resultsList.appendChild(li);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
this.open();
|
|
320
|
+
|
|
321
|
+
if (results.length > 0) {
|
|
322
|
+
const link = document.createElement('a');
|
|
323
|
+
link.href = 'https://radar.com?ref=powered_by_radar';
|
|
324
|
+
link.target = '_blank';
|
|
325
|
+
this.poweredByLink = link;
|
|
326
|
+
|
|
327
|
+
const poweredByText = document.createElement('span');
|
|
328
|
+
poweredByText.textContent = 'Powered by';
|
|
329
|
+
link.appendChild(poweredByText);
|
|
330
|
+
|
|
331
|
+
const radarLogo = document.createElement('span');
|
|
332
|
+
radarLogo.id = 'radar-powered-logo';
|
|
333
|
+
radarLogo.textContent = 'Radar';
|
|
334
|
+
link.appendChild(radarLogo);
|
|
335
|
+
|
|
336
|
+
const poweredByContainer = document.createElement('div');
|
|
337
|
+
poweredByContainer.classList.add(CLASSNAMES.POWERED_BY_RADAR);
|
|
338
|
+
poweredByContainer.appendChild(link);
|
|
339
|
+
this.resultsList.appendChild(poweredByContainer);
|
|
340
|
+
} else {
|
|
341
|
+
const noResultsText = document.createElement('div');
|
|
342
|
+
noResultsText.classList.add(CLASSNAMES.NO_RESULTS);
|
|
343
|
+
noResultsText.textContent = 'No results';
|
|
344
|
+
|
|
345
|
+
this.resultsList.appendChild(noResultsText);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
public open() {
|
|
350
|
+
if (this.isOpen) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
this.inputField.setAttribute('aria-expanded', 'true');
|
|
355
|
+
this.resultsList.removeAttribute('hidden');
|
|
356
|
+
this.isOpen = true;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
public close(e?: FocusEvent) {
|
|
360
|
+
if (!this.isOpen) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// run this code async to allow link click to propagate before blur
|
|
365
|
+
// (add 100ms delay if closed from link click)
|
|
366
|
+
const linkClick = e && (e.relatedTarget === this.poweredByLink);
|
|
367
|
+
setTimeout(() => {
|
|
368
|
+
this.inputField.setAttribute('aria-expanded', 'false');
|
|
369
|
+
this.inputField.setAttribute('aria-activedescendant', '');
|
|
370
|
+
this.resultsList.setAttribute('hidden', '');
|
|
371
|
+
this.highlightedIndex = -1;
|
|
372
|
+
this.isOpen = false;
|
|
373
|
+
this.clearResultsList();
|
|
374
|
+
}, linkClick ? 100 : 0);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
public goTo(index: number) {
|
|
378
|
+
if (!this.isOpen || !this.results.length) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// wrap around
|
|
383
|
+
if (index < 0) {
|
|
384
|
+
index = this.results.length - 1;
|
|
385
|
+
} else if (index >= this.results.length) {
|
|
386
|
+
index = 0;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const resultItems = this.resultsList.getElementsByTagName('li');
|
|
390
|
+
|
|
391
|
+
if (this.highlightedIndex > -1) {
|
|
392
|
+
// clear class names on previously highlighted item
|
|
393
|
+
resultItems[this.highlightedIndex].classList.remove(CLASSNAMES.SELECTED_ITEM);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// add class name to newly highlighted item
|
|
397
|
+
resultItems[index].classList.add(CLASSNAMES.SELECTED_ITEM);
|
|
398
|
+
|
|
399
|
+
// set aria active descendant
|
|
400
|
+
this.inputField.setAttribute('aria-activedescendant', `${CLASSNAMES.RESULTS_ITEM}-${index}`);
|
|
401
|
+
|
|
402
|
+
this.highlightedIndex = index;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
public handleKeyboardNavigation(event: KeyboardEvent) {
|
|
406
|
+
let key = event.key;
|
|
407
|
+
|
|
408
|
+
// allow event to propagate if result list is not open
|
|
409
|
+
if (!this.isOpen) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// treat shift+tab as up key
|
|
414
|
+
if (key === 'Tab' && event.shiftKey) {
|
|
415
|
+
key = 'ArrowUp';
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
switch (key) {
|
|
419
|
+
// Next item
|
|
420
|
+
case 'Tab':
|
|
421
|
+
case 'ArrowDown':
|
|
422
|
+
event.preventDefault();
|
|
423
|
+
this.goTo(this.highlightedIndex + 1);
|
|
424
|
+
break;
|
|
425
|
+
|
|
426
|
+
// Prev item
|
|
427
|
+
case 'ArrowUp':
|
|
428
|
+
event.preventDefault();
|
|
429
|
+
this.goTo(this.highlightedIndex - 1);
|
|
430
|
+
break;
|
|
431
|
+
|
|
432
|
+
// Select
|
|
433
|
+
case 'Enter':
|
|
434
|
+
this.select(this.highlightedIndex);
|
|
435
|
+
break;
|
|
436
|
+
|
|
437
|
+
// Close
|
|
438
|
+
case 'Esc':
|
|
439
|
+
this.close();
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
public select(index: number) {
|
|
445
|
+
const { Logger } = this.ctx;
|
|
446
|
+
const result = this.results[index];
|
|
447
|
+
if (!result) {
|
|
448
|
+
Logger.warn(`No autocomplete result found at index: ${index}`);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
let inputValue;
|
|
453
|
+
if (result.formattedAddress.includes(result.addressLabel)) {
|
|
454
|
+
inputValue = result.formattedAddress;
|
|
455
|
+
} else {
|
|
456
|
+
const label = result.placeLabel || result.addressLabel;
|
|
457
|
+
inputValue = `${label}, ${result.formattedAddress}`;
|
|
458
|
+
}
|
|
459
|
+
this.inputField.value = inputValue;
|
|
460
|
+
|
|
461
|
+
const onSelection = this.config.onSelection;
|
|
462
|
+
if (onSelection) {
|
|
463
|
+
onSelection(result);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// clear results list
|
|
467
|
+
this.close();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
public clearResultsList() {
|
|
471
|
+
this.resultsList.innerHTML = '';
|
|
472
|
+
this.results = [];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// remove elements from DOM
|
|
476
|
+
public remove() {
|
|
477
|
+
const { Logger } = this.ctx;
|
|
478
|
+
Logger.debug('AutocompleteUI removed.');
|
|
479
|
+
this.inputField.remove();
|
|
480
|
+
this.resultsList.remove();
|
|
481
|
+
this.wrapper.remove();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
public setNear(near: string | Location | undefined | null) {
|
|
485
|
+
if (near === undefined || near === null) {
|
|
486
|
+
this.near = undefined;
|
|
487
|
+
} else if (typeof near === 'string') {
|
|
488
|
+
this.near = near;
|
|
489
|
+
} else {
|
|
490
|
+
this.near = `${near.latitude},${near.longitude}`;
|
|
491
|
+
}
|
|
492
|
+
return this;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
public setPlaceholder(placeholder: string) {
|
|
496
|
+
this.config.placeholder = placeholder;
|
|
497
|
+
this.inputField.placeholder = placeholder;
|
|
498
|
+
return this;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
public setDisabled(disabled: boolean) {
|
|
502
|
+
this.config.disabled = disabled;
|
|
503
|
+
this.inputField.disabled = disabled;
|
|
504
|
+
return this;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
public setResponsive(responsive: boolean) {
|
|
508
|
+
this.config.responsive = responsive;
|
|
509
|
+
setWidth(this.wrapper, this.config);
|
|
510
|
+
return this;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
public setWidth(width: number | string | null) {
|
|
514
|
+
if (width === null) {
|
|
515
|
+
this.config.width = undefined;
|
|
516
|
+
} else if (typeof width === 'string' || typeof width === 'number') {
|
|
517
|
+
this.config.width = width;
|
|
518
|
+
}
|
|
519
|
+
setWidth(this.wrapper, this.config);
|
|
520
|
+
return this;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
public setMaxHeight(height: number | string | null) {
|
|
524
|
+
if (height === null) {
|
|
525
|
+
this.config.maxHeight = undefined;
|
|
526
|
+
} else if (typeof height === 'string' || typeof height === 'number') {
|
|
527
|
+
this.config.maxHeight = height;
|
|
528
|
+
}
|
|
529
|
+
setHeight(this.resultsList, this.config);
|
|
530
|
+
return this;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
public setMinCharacters(minCharacters: number) {
|
|
534
|
+
this.config.minCharacters = minCharacters;
|
|
535
|
+
this.config.threshold = minCharacters;
|
|
536
|
+
return this;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
public setLimit(limit: number) {
|
|
540
|
+
this.config.limit = limit;
|
|
541
|
+
return this;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
public setLang(lang: string | null) {
|
|
545
|
+
if (lang === null) {
|
|
546
|
+
this.config.lang = undefined;
|
|
547
|
+
} else if (typeof lang === 'string') {
|
|
548
|
+
this.config.lang = lang;
|
|
549
|
+
}
|
|
550
|
+
return this;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
public setPostalCode(postalCode: string | null) {
|
|
554
|
+
if (postalCode === null) {
|
|
555
|
+
this.config.postalCode = undefined;
|
|
556
|
+
} else if (typeof postalCode === 'string') {
|
|
557
|
+
this.config.postalCode = postalCode;
|
|
558
|
+
}
|
|
559
|
+
return this;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
public setShowMarkers(showMarkers: boolean) {
|
|
563
|
+
this.config.showMarkers = showMarkers;
|
|
564
|
+
if (showMarkers) {
|
|
565
|
+
const marker = document.createElement('img');
|
|
566
|
+
marker.classList.add(CLASSNAMES.RESULTS_MARKER);
|
|
567
|
+
marker.setAttribute('src', getMarkerIcon(this.config.markerColor));
|
|
568
|
+
const resultItems = this.resultsList.getElementsByTagName('li');
|
|
569
|
+
for (let i = 0; i < resultItems.length; i++) {
|
|
570
|
+
const currentMarker = resultItems[i].getElementsByClassName(CLASSNAMES.RESULTS_MARKER)[0];
|
|
571
|
+
if (!currentMarker) {
|
|
572
|
+
resultItems[i].prepend(marker.cloneNode());
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
} else {
|
|
576
|
+
const resultItems = this.resultsList.getElementsByTagName('li');
|
|
577
|
+
for (let i = 0; i < resultItems.length; i++) {
|
|
578
|
+
const marker = resultItems[i].getElementsByClassName(CLASSNAMES.RESULTS_MARKER)[0];
|
|
579
|
+
if (marker) {
|
|
580
|
+
marker.remove();
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return this;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
public setMarkerColor(color: string) {
|
|
588
|
+
this.config.markerColor = color;
|
|
589
|
+
const marker = this.resultsList.getElementsByClassName(CLASSNAMES.RESULTS_MARKER);
|
|
590
|
+
for (let i = 0; i < marker.length; i++) {
|
|
591
|
+
marker[i].setAttribute('src', getMarkerIcon(color));
|
|
592
|
+
}
|
|
593
|
+
return this;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
public setHideResultsOnBlur(hideResultsOnBlur: boolean) {
|
|
597
|
+
this.config.hideResultsOnBlur = hideResultsOnBlur;
|
|
598
|
+
if (hideResultsOnBlur) {
|
|
599
|
+
this.inputField.addEventListener('blur', this.close.bind(this), true);
|
|
600
|
+
} else {
|
|
601
|
+
this.inputField.removeEventListener('blur', this.close.bind(this), true);
|
|
602
|
+
}
|
|
603
|
+
return this;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export default AutocompleteUI;
|
package/src/cdn-entry.ts
ADDED
package/src/errors.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { RadarError } from 'radar-sdk-js';
|
|
2
|
+
|
|
3
|
+
export class RadarAutocompleteContainerNotFound extends RadarError {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'RadarAutocompleteContainerNotFound';
|
|
7
|
+
this.status = 'CONTAINER_NOT_FOUND';
|
|
8
|
+
}
|
|
9
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RadarPlugin } from 'radar-sdk-js';
|
|
2
|
+
|
|
3
|
+
import AutocompleteUI from './autocomplete';
|
|
4
|
+
import version from './version';
|
|
5
|
+
import type { RadarAutocompleteUIOptions } from './types';
|
|
6
|
+
|
|
7
|
+
import '../styles/radar-autocomplete.css';
|
|
8
|
+
|
|
9
|
+
export type { RadarAutocompleteUIOptions, RadarAutocompleteConfig } from './types';
|
|
10
|
+
|
|
11
|
+
declare module 'radar-sdk-js' {
|
|
12
|
+
interface RadarUI {
|
|
13
|
+
autocomplete(options: Partial<RadarAutocompleteUIOptions>): AutocompleteUI;
|
|
14
|
+
}
|
|
15
|
+
namespace Radar {
|
|
16
|
+
let ui: RadarUI;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createAutocompletePlugin(): RadarPlugin {
|
|
21
|
+
return {
|
|
22
|
+
name: 'autocomplete',
|
|
23
|
+
version,
|
|
24
|
+
install(ctx) {
|
|
25
|
+
const existing = ctx.Radar.ui || {};
|
|
26
|
+
// NOTE(jasonl): we're merging with the existing ui namespace since other plugins also add to it like maps
|
|
27
|
+
ctx.Radar.ui = {
|
|
28
|
+
...existing,
|
|
29
|
+
autocomplete: (options: Partial<RadarAutocompleteUIOptions>) => new AutocompleteUI(options, ctx),
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { RadarAutocompleteParams } from 'radar-sdk-js';
|
|
2
|
+
|
|
3
|
+
export interface RadarAutocompleteUIOptions extends Omit<RadarAutocompleteParams, 'query'> {
|
|
4
|
+
container: string | HTMLElement;
|
|
5
|
+
debounceMS?: number, // Debounce time in milliseconds
|
|
6
|
+
/** @deprecated use minCharacters instead */
|
|
7
|
+
threshold?: number,
|
|
8
|
+
minCharacters?: number, // Minimum number of characters to trigger autocomplete
|
|
9
|
+
placeholder?: string, // Placeholder text for the input field
|
|
10
|
+
onSelection?: (selection: any) => void,
|
|
11
|
+
onRequest?: (params: RadarAutocompleteParams) => void,
|
|
12
|
+
onResults?: (results: any[]) => void,
|
|
13
|
+
onError?: (error: any) => void,
|
|
14
|
+
disabled?: boolean,
|
|
15
|
+
responsive?: boolean;
|
|
16
|
+
width?: string | number;
|
|
17
|
+
maxHeight?: string | number;
|
|
18
|
+
showMarkers?: boolean;
|
|
19
|
+
markerColor?: string;
|
|
20
|
+
hideResultsOnBlur?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RadarAutocompleteConfig extends RadarAutocompleteUIOptions {
|
|
24
|
+
debounceMS: number, // Debounce time in milliseconds
|
|
25
|
+
threshold: number, // DEPRECATED(use minCharacters instead)
|
|
26
|
+
minCharacters: number, // Minimum number of characters to trigger autocomplete
|
|
27
|
+
limit: number, // Maximum number of autocomplete results
|
|
28
|
+
placeholder: string, // Placeholder text for the input field
|
|
29
|
+
disabled: boolean,
|
|
30
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default '5.0.0-beta.5';
|