├── .eslintrc.yml
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .prettierrc.yml
├── LICENSE
├── README.md
├── package-lock.json
├── package.json
├── rollup.config.js
└── src
├── placekit-autocomplete.css
├── placekit-autocomplete.d.ts
└── placekit-autocomplete.js
/.eslintrc.yml:
--------------------------------------------------------------------------------
1 | root: true
2 | env:
3 | browser: true,
4 | es2022: true
5 | extends:
6 | - prettier
7 | plugins:
8 | - prettier
9 | parserOptions:
10 | ecmaVersion: latest
11 | sourceType: module
12 | rules:
13 | prettier/prettier: error
14 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | jobs:
10 | run:
11 | name: Lint & Build
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v3
15 | - uses: actions/setup-node@v3
16 | with:
17 | node-version: 18
18 | cache: 'npm'
19 | cache-dependency-path: '**/package-lock.json'
20 | - run: npm ci --ignore-scripts
21 | - run: npm run lint
22 | - run: npm run build
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # misc
4 | .DS_Store
5 | /NOTES.md
6 | /demo
7 | /*.sh
8 |
9 | # dependencies
10 | node_modules
11 |
12 | # build
13 | /dist
14 |
15 | # log files
16 | *.log*
--------------------------------------------------------------------------------
/.prettierrc.yml:
--------------------------------------------------------------------------------
1 | singleQuote: true
2 | printWidth: 100
3 | proseWrap: never
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 PlaceKit
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | PlaceKit Autocomplete JS Library
3 |
4 |
5 |
6 | All-in-one address autocomplete experience for your web apps
7 |
8 |
9 |
10 |
11 | [](https://www.npmjs.com/package/@placekit/autocomplete-js?activeTab=readme) [](./LICENSE)
12 |
13 |
14 |
15 |
16 | Quick start •
17 | Features •
18 | Reference •
19 | Customize •
20 | Additional notes •
21 | License •
22 | Examples
23 |
24 |
25 | ---
26 |
27 | PlaceKit Autocomplete JavaScript Library is a standalone address autocomplete field for your application, built on top of our [PlaceKit JS client](https://github.com/placekit/client-js). Under the hood it relies on [Popper](https://popper.js.org) to position the suggestions list, and on [Flagpedia](https://flagpedia.net) to display flags on country results.
28 |
29 | If you already use a components library with an autocomplete field, or need a more advanced usage, please refer to our [PlaceKit JS client](https://github.com/placekit/client-js) reference.
30 |
31 | Framework specific implementations:
32 |
33 | - [PlaceKit Autocomplete React](https://github.com/placekit/autocomplete-react) - [Vue.js example](https://github.com/placekit/examples/tree/main/examples/autocomplete-js-vue/).
34 | - [Svelte example](https://github.com/placekit/examples/tree/main/examples/autocomplete-js-svelte/).
35 |
36 | ## ✨ Features
37 |
38 | - **Standalone** and **lightweight**: about 14kb of JS, and 5kb of CSS, gzipped
39 | - **Cross browser**: compatible across all major modern browsers
40 | - **Non-invasive**: use and style your own input element
41 | - **Customizable** and **extensible** with events and hooks
42 | - **TypeScript** compatible
43 | - [**W3C WAI compliant**](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/)
44 |
45 | ## 🎯 Quick start
46 |
47 | ### CDN
48 |
49 | First, import the library and the default stylesheet into the `` tag in your HTML:
50 |
51 | ```html
52 |
56 |
57 | ```
58 |
59 | After importing the library, `placekitAutocomplete` becomes available as a global:
60 |
61 | ```html
62 |
63 |
69 | ```
70 |
71 | Or if you are using native ES Modules:
72 |
73 | ```html
74 |
79 | ```
80 |
81 | **NOTE:** Make sure to call `placekitAutocomplete` after the DOM is loaded so the input can be found.
82 |
83 | ### NPM
84 |
85 | First, install PlaceKit Autocomplete using [npm](https://docs.npmjs.com/getting-started) package manager:
86 |
87 | ```sh
88 | npm install --save @placekit/autocomplete-js
89 | ```
90 |
91 | Then import the package and perform your first address search:
92 |
93 | ```js
94 | // CommonJS syntax:
95 | const placekitAutocomplete = require('@placekit/autocomplete-js');
96 |
97 | // ES6 Modules syntax:
98 | import placekit from '@placekit/autocomplete-js';
99 |
100 | const pka = placekitAutocomplete('', {
101 | target: '#placekit',
102 | // other options...
103 | });
104 | ```
105 |
106 | Don't forget to import `@placekit/autocomplete-js/dist/placekit-autocomplete.css` if you want to use our default style. If you have trouble importing CSS from `node_modules`, copy/paste [its content](./src/placekit.css) into your own CSS.
107 |
108 | 👉 **Check out our [examples](https://github.com/placekit/examples) for different use cases!**
109 |
110 | ## 🧰 Reference
111 |
112 | - [`placekitAutocomplete()`](#placekitautocomplete)
113 | - [`pka.input`](#pkainput)
114 | - [`pka.options`](#pkaoptions)
115 | - [`pka.configure()`](#pkaconfigure)
116 | - [`pka.state`](#pkastate)
117 | - [`pka.on()`](#pkaon)
118 | - [`pka.handlers`](#pkahandlers)
119 | - [`pka.requestGeolocation()`](#pkarequestgeolocation)
120 | - [`pka.clearGeolocation()`](#pkacleargeolocation)
121 | - [`pka.open()`](#pkaopen)
122 | - [`pka.close()`](#pkaclose)
123 | - [`pka.clear()`](#pkaclear)
124 | - [`pka.setValue()`](#pkasetvalue)
125 | - [`pka.destroy()`](#pkadestroy)
126 |
127 | ### `placekitAutocomplete()`
128 |
129 | PlaceKit Autocomplete initialization function returns a PlaceKit Autocomplete client, named `pka` in all snippets below.
130 |
131 | ```js
132 | const pka = placekitAutocomplete('', {
133 | target: '#placekit',
134 | countries: ['fr'],
135 | maxResults: 10,
136 | });
137 | ```
138 |
139 | | Parameter | Type | Description |
140 | | --------- | ------------------------------ | ---------------------------------------------- |
141 | | `apiKey` | `string` | API key |
142 | | `options` | `key-value mapping` (optional) | Global parameters (see [options](#pkaoptions)) |
143 |
144 | ⚠️ `target` must be set when instanciating `placekitAutocomplete`, all other options can be set later with [`pka.configure()`](#pkaconfigure).
145 |
146 | ### `pka.input`
147 |
148 | Input field element passed as `target` option, read-only.
149 |
150 | ```js
151 | console.log(pka.input); //
152 | ```
153 |
154 | ### `pka.options`
155 |
156 | Options from both PlaceKit Autocomplete and PlaceKit JS client, read-only.
157 |
158 | ```js
159 | console.log(pka.options); // { "target": , "language": "en", "maxResults": 10, ... }
160 | ```
161 |
162 | | Option | From | Type | Default | Description |
163 | | --- | --- | --- | --- | --- |
164 | | `target` | AutoComplete | `string\|Element` | `-` | Target input element or (unique) selector. |
165 | | `panel` | AutoComplete | `object?` | `(...)` | Suggestions panel options, see [Panel options](#panel-options). |
166 | | `format` | AutoComplete | `object?` | `(...)` | Formatting options, see [Format options](#format-options). |
167 | | `countryAutoFill` | AutoComplete | `boolean?` | `true` | Automatically detect current country by IP and fill input when `types: ['country']`. |
168 | | `countrySelect` | AutoComplete | `boolean?` | `true` | Show/hide country selector[(1)](#ft1)[(2)](#ft2) . |
169 | | `maxResults` | JS client | `integer` | `5` | Number of results per page. |
170 | | `language` | JS client | `string?` | `undefined` | Preferred language for the results[(3)](#ft3) , [two-letter ISO](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) language code. Supported languages are `en` and `fr`. By default the results are displayed in their country's language. |
171 | | `types` | JS client | `string[]?` | `undefined` | Type of results to show. Array of accepted values: `street`, `city`, `country`, `administrative`, `county`, `airport`, `bus`, `train`, `townhall`, `tourism`. Prepend `-` to omit a type like `['-bus']`. Unset to return all. |
172 | | `countries` | JS client | `string[]?` | `undefined` | Restrict search in specific countries. Array of [two-letter ISO](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) country codes[(3)](#ft3) . |
173 | | `coordinates` | JS client | `string?` | `undefined` | Coordinates to search around. Automatically set when calling [`pka.requestGeolocation()`](#pkarequestgeolocation). |
174 |
175 | - [1] : Ignored if `countries` option is set (Country selector is hidden).
176 | - [2] : When `types: ['city']` is set, setting `countrySelect: false` enables a worldwide city search.
177 | - [3] : See [Coverage](https://placekit.io/terms/coverage) for more details.
178 |
179 |
180 | Panel options
181 |
182 | | Option | Type | Default | Description |
183 | | --- | --- | --- | --- |
184 | | `className` | `string` | `undefined` | Additional panel CSS class(es). |
185 | | `offset` | `integer` | `4` | Gap between input and panel in pixels. |
186 | | `strategy` | `'absolute' \| 'fixed'` | `absolute` | [Popper positioning strategy](https://popper.js.org/docs/v2/constructors/#strategy) |
187 | | `flip` | `boolean` | `false` | Flip position top when overflowing. |
188 |
189 |
190 |
191 |
192 |
193 | | Option | Type | Default | Description |
194 | | --- | --- | --- | --- |
195 | | `flag` | `(countrycode: string) => string` | [see placekit-autocomplete.js](./src/placekit-autocomplete.js#L56) | DOM for flags. |
196 | | `icon` | `(name: string, label: string) => string` | [see placekit-autocomplete.js](./src/placekit-autocomplete.js#L57) | DOM for icons. |
197 | | `sub` | `(item: object) => string` | [see placekit-autocomplete.js](./src/placekit-autocomplete.js#L58-L60) | Format suggestion secondary text |
198 | | `noResults` | `(query: string) => string` | [see placekit-autocomplete.js](./src/placekit-autocomplete.js#L61) | Format "no results" text. |
199 | | `value` | `(item: object) => string` | `item.name` | Format input value when user picks a suggestion. |
200 | | `applySuggestion` | `string` | `"Apply suggestion"` | ARIA label for "insert" icon. |
201 | | `suggestions` | `string` | `"Address suggestions"` | ARIA label for `role="listbox"` suggestions list. |
202 | | `changeCountry` | `string` | `"Change country"` | ARIA label for entering country selection mode. |
203 | | `cancel` | `string` | `"Cancel"` | Label for cancelling country selection mode. |
204 |
205 |
206 |
207 | ### `pka.configure()`
208 |
209 | Updates all parameters (**except `target`**). Returns the instance so you can chain methods.
210 |
211 | ```js
212 | pka.configure({
213 | panel: {
214 | className: 'my-suggestions',
215 | flip: true,
216 | },
217 | format: {
218 | value: (item) => `${item.name} ${item.city}`,
219 | },
220 | language: 'fr',
221 | maxResults: 5,
222 | });
223 | ```
224 |
225 | | Parameter | Type | Description |
226 | | --------- | ------------------------------ | ---------------------------------------------- |
227 | | `opts` | `key-value mapping` (optional) | Global parameters (see [options](#pkaoptions)) |
228 |
229 | ### `pka.state`
230 |
231 | Read-only object of input state.
232 |
233 | ```js
234 | console.log(pka.state); // {dirty: false, empty: false, freeForm: true, geolocation: false}
235 |
236 | // `true` after the user modifies the input value.
237 | console.log(pka.state.dirty); // true or false
238 |
239 | // `true` whenever the input value is not empty.
240 | console.log(pka.state.empty); // true or false
241 |
242 | // `true` if the input has a free form value or `false` if value is selected from the suggestions.
243 | console.log(pka.state.freeForm); // true or false
244 |
245 | // `true` if device geolocation has been granted.
246 | console.log(pka.state.geolocation); // true or false
247 |
248 | // `true` if panel is in country selection mode.
249 | console.log(pka.state.countryMode); // true or false
250 | ```
251 |
252 | The `freeForm` value comes handy if you need to implement a strict validation of the address, but we don't interfere with how to implement it as input validation is always very specific to the project's stack.
253 |
254 | ### `pka.on()`
255 |
256 | Register event handlers, methods can be chained.
257 |
258 | ```js
259 | pka.on('open', () => {})
260 | .on('close', () => {})
261 | .on('results', (query, results) => {})
262 | .on('pick', (value, item, index) => {})
263 | .on('error', (error) => {})
264 | .on('dirty', (bool) => {})
265 | .on('empty', (bool) => {})
266 | .on('freeForm', (bool) => {})
267 | .on('geolocation', (bool, position, error) => {});
268 | .on('countryMode', (bool) => {});
269 | .on('state', (state) => {})
270 | .on('countryChange', (item) => {});
271 | ```
272 |
273 | If you register a same event twice, the first one will be replaced. So, to remove an handler, simply assign `undefined`:
274 |
275 | ```js
276 | pka.on('open'); // clears handler for 'open' event
277 | ```
278 |
279 | #### Events
280 |
281 | ##### `open`
282 |
283 | Triggered when panel opens.
284 |
285 | ##### `close`
286 |
287 | Triggered when panel closes.
288 |
289 | ##### `results`
290 |
291 | Triggered when suggestions list gets updated, same as when the user types or enables geolocation.
292 |
293 | | Parameter | Type | Description |
294 | | --------- | ---------- | -------------------------- |
295 | | `query` | `string` | Input value. |
296 | | `results` | `object[]` | Results returned from API. |
297 |
298 | ##### `pick`
299 |
300 | Triggered when user selects an item from the suggestion list by clicking on it or pressing ENTER after using the keyboard navigation.
301 |
302 | | Parameter | Type | Description |
303 | | --------- | -------- | ------------------------------------------------------------------ |
304 | | `value` | `string` | Input value (value returned by `options.formatValue()`). |
305 | | `item` | `object` | All item details returned from API. |
306 | | `index` | `number` | Position of the selected item in the suggestions list, zero-based. |
307 |
308 | ##### `error`
309 |
310 | Triggered on server error.
311 |
312 | | Parameter | Type | Description |
313 | | --------- | -------- | -------------- |
314 | | `error` | `object` | Error details. |
315 |
316 | ##### `dirty`
317 |
318 | Triggered when the input value changes.
319 |
320 | | Parameter | Type | Description |
321 | | --------- | --------- | ----------------------------------------- |
322 | | `dirty` | `boolean` | `true` after the user modifies the value. |
323 |
324 | ##### `empty`
325 |
326 | Triggered when input value changes.
327 |
328 | | Parameter | Type | Description |
329 | | --------- | --------- | ------------------------- |
330 | | `empty` | `boolean` | `true` if input is empty. |
331 |
332 | ##### `freeForm`
333 |
334 | Triggered when input value changes.
335 |
336 | | Parameter | Type | Description |
337 | | ---------- | --------- | ---------------------------------------------- |
338 | | `freeForm` | `boolean` | `true` on user input, `false` on `pick` event. |
339 |
340 | ##### `geolocation`
341 |
342 | Triggered when `state.geolocation` value changes (a.k.a. when `pka.requestGeolocation` is called).
343 |
344 | | Parameter | Type | Description |
345 | | --- | --- | --- |
346 | | `geolocation` | `boolean` | `true` if granted, `false` if denied. |
347 | | `position` | [`GeolocationPosition \| undefined`](https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPosition) | Passed when `geolocation` is `true`. |
348 | | `error` | [`string \| undefined`](https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPosition) | Geolocation request error message. |
349 |
350 | ##### `countryMode`
351 |
352 | Triggered when the user toggles the country selection mode. Always `false` if `countries` option is set, or if `countrySelect` is `false`.
353 |
354 | | Parameter | Type | Description |
355 | | --------- | --------- | ---------------------------------- |
356 | | `bool` | `boolean` | `true` if open, `false` if closed. |
357 |
358 | ##### `state`
359 |
360 | Triggered when one of the input states changes.
361 |
362 | | Parameter | Type | Description |
363 | | --------- | -------- | ------------------------ |
364 | | `state` | `object` | The current input state. |
365 |
366 | ##### `countryChange`
367 |
368 | Triggered when the current search country changes (either detected by IP, or selected by the user in the country selection mode).
369 |
370 | | Parameter | Type | Description |
371 | | --------- | -------- | ---------------------------------- |
372 | | `item` | `object` | Country details returned from API. |
373 |
374 | ### `pka.handlers`
375 |
376 | Reads registered event handlers, read-only.
377 |
378 | ```js
379 | pka.on('open', () => {});
380 | console.log(pka.handlers); // { open: ... }
381 | ```
382 |
383 | ### `pka.requestGeolocation()`
384 |
385 | Requests device's geolocation (browser-only). Returns a Promise with a [`GeolocationPosition`](https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPosition) object.
386 |
387 | ```js
388 | pka.requestGeolocation({ timeout: 10000 }).then((pos) => console.log(pos.coords));
389 | ```
390 |
391 | | Parameter | Type | Description |
392 | | --- | --- | --- |
393 | | `opts` | `key-value mapping` (optional) | `navigator.geolocation.getCurrentPosition` [options](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/getCurrentPosition) |
394 | | `cancelUpdate` | `boolean` (optional) | If `false` (default), suggestions list updates immediately based on device location. |
395 |
396 | The location will be store in the `coordinates` global options, you can still manually override it. `state.geolocation` will be set to `true`, dispatching both the `geolocation` and `state` events.
397 |
398 | ### `pka.clearGeolocation()`
399 |
400 | Clear device's geolocation stored with [`pka.requestGeolocation`](#pkarequestGeolocation).
401 |
402 | ```js
403 | pka.clearGeolocation();
404 | ```
405 |
406 | The global option `coordinates` will be deleted and the `state.geolocation` will be set to `false`, dispatching both the `geolocation` and `state` events.
407 |
408 | ### `pka.open()`
409 |
410 | Opens the suggestion panel.
411 |
412 | ```js
413 | pka.open();
414 | ```
415 |
416 | ### `pka.close()`
417 |
418 | Closes the suggestion panel.
419 |
420 | ```js
421 | pka.close();
422 | ```
423 |
424 | ### `pka.clear()`
425 |
426 | Clears the input value, focus the field, closes the suggestion panel and clear suggestions if `state.geolocation` is false or perform an empty search to reset geolocated suggestions otherwise.
427 |
428 | ```js
429 | pka.clear();
430 | ```
431 |
432 | ### `pka.setValue()`
433 |
434 | Manually set the input value. Useful for third-party wrappers like React.
435 |
436 | | Parameter | Type | Description |
437 | | --- | --- | --- |
438 | | `value` | `string \| null` (optional) | New input value, operation ignored if `undefined` or `null`. |
439 | | `notify` | `boolean` (optional) | Pass `true` to dispatch `change` and `input` events and update state (default `false`). |
440 |
441 | ```js
442 | pka.setValue('new value');
443 | pka.setValue('new value', true); // dispatch `change` and `input` event
444 | ```
445 |
446 | **NOTE**: `state.empty` will automatically be updated based on the input value if `notify: true`. `state.dirty` and `state.freeForm` remain unchanged until the user focuses the input.
447 |
448 | ### `pka.destroy()`
449 |
450 | Removes the suggestions panel from the DOM and clears all event handlers from `window` and `input` that were added by PlaceKit Autocomplete.
451 |
452 | ```js
453 | pka.destroy();
454 | ```
455 |
456 | ## 💅 Customize
457 |
458 | You have full control over the input element as PlaceKit Autocomplete doesn't style nor alter it by default. We still provide a style that you can apply by adding the `.pka-input` class to your input element.
459 |
460 | ### Dark mode
461 |
462 | THe whole autocomplete automatically switches to dark mode if `` has a `.dark` class or a `data-theme="dark"` attribute.
463 |
464 | You can also activate the dark mode on-demand by wrapping the input with a `.dark` class or a `data-theme="dark"` attribute, like `...
`. The suggestions panel is appended to the body directly and doesn't share any ancestor with the input field other than ``, so to activate the dark mode on-demand, pass the `.dark` class to the panel options:
465 |
466 | ```js
467 | const pka = placekitAutocomplete('', {
468 | target: '#placekit',
469 | panel: {
470 | className: 'dark',
471 | },
472 | });
473 | ```
474 |
475 | ### CSS Variables
476 |
477 | Colors, border-radius, font and overall scale (in `rem`) and even icons are accessible over variables:
478 |
479 | ```css
480 | :root {
481 | --pka-scale: 1rem;
482 | --pka-color-accent: 1, 73, 200;
483 | --pka-color-black: 29, 41, 57;
484 | --pka-color-darker: 52, 64, 84;
485 | --pka-color-dark: 152, 162, 179;
486 | --pka-color-light: 207, 213, 221;
487 | --pka-color-lighter: 243, 244, 246;
488 | --pka-color-white: 255, 255, 255;
489 | --pka-border-radius: 6px;
490 | --pka-font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
491 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
492 | --pka-z-index: 9999;
493 |
494 | --pka-icon-pin: url('...');
495 | --pka-icon-street: var(--pka-icon-pin);
496 | --pka-icon-administrative: var(--pka-icon-pin);
497 | --pka-icon-county: var(--pka-icon-pin);
498 | --pka-icon-city: url('...');
499 | --pka-icon-airport: url('...');
500 | --pka-icon-bus: url('...');
501 | --pka-icon-train: url('...');
502 | --pka-icon-townhall: url('...');
503 | --pka-icon-tourism: url('...');
504 | --pka-icon-noresults: url('...');
505 | --pka-icon-clear: url('...');
506 | --pka-icon-cancel: var(--pka-icon-clear);
507 | --pka-icon-insert: url('...');
508 | --pka-icon-check: url('...');
509 | --pka-icon-switch: url('...');
510 | --pka-icon-geo-off: url('...');
511 | --pka-icon-geo-on: url('...');
512 | --pka-icon-loading: url('...');
513 | }
514 |
515 | /* dark mode overrides */
516 | .dark,
517 | [data-theme='dark'] {
518 | --pka-color-accent: 55, 131, 249;
519 | }
520 | ```
521 |
522 | You also have full control over flags and icons DOM with `format.flag` and `format.icon` options (see [Format options](#format-options)).
523 |
524 | For advanced customization, refer to our [stylesheet](./src/placekit-autocomplete.css) to learn about the available classes if you need to either override some or start a theme from scratch.
525 |
526 | ## ⚠️ Additional notes
527 |
528 | - Setting a non-empty `value` attribute on the ` ` will automatically trigger a first search request when the user focuses the input.
529 |
530 | ## ⚖️ License
531 |
532 | PlaceKit Autocomplete JS Library is an open-sourced software licensed under the [MIT license](./LICENSE).
533 |
534 | ⚠️ **NOTE:** you are **not** allowed to hide the PlaceKit logo unless we've delivered a special authorization. To request one, please contact us using [our contact form](https://placekit.io/about#contact).
535 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@placekit/autocomplete-js",
3 | "version": "2.2.1",
4 | "author": "PlaceKit ",
5 | "description": "PlaceKit Autocomplete JavaScript library",
6 | "keywords": [
7 | "addresses",
8 | "autocomplete",
9 | "geocoder",
10 | "geocoding",
11 | "locations",
12 | "search"
13 | ],
14 | "license": "MIT",
15 | "homepage": "https://github.com/placekit/autocomplete-js#readme",
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/placekit/autocomplete-js.git"
19 | },
20 | "bugs": {
21 | "url": "https://github.com/placekit/autocomplete-js/issues"
22 | },
23 | "type": "module",
24 | "main": "./dist/placekit-autocomplete.cjs.js",
25 | "module": "./dist/placekit-autocomplete.esm.mjs",
26 | "browser": "./dist/placekit-autocomplete.umd.js",
27 | "types": "./dist/placekit-autocomplete.d.ts",
28 | "exports": {
29 | ".": {
30 | "types": "./dist/placekit-autocomplete.d.ts",
31 | "require": "./dist/placekit-autocomplete.cjs.js",
32 | "import": "./dist/placekit-autocomplete.esm.mjs"
33 | },
34 | "./dist/placekit-autocomplete.css": "./dist/placekit-autocomplete.css"
35 | },
36 | "files": [
37 | "dist",
38 | "LICENSE"
39 | ],
40 | "watch": {
41 | "build": "src/*.*"
42 | },
43 | "scripts": {
44 | "clear": "rimraf ./dist",
45 | "dev": "npm-watch build",
46 | "build": "rollup -c",
47 | "lint": "eslint ./src",
48 | "format": "prettier --write ./src"
49 | },
50 | "devDependencies": {
51 | "@rollup/plugin-commonjs": "^25.0.7",
52 | "@rollup/plugin-node-resolve": "^15.2.3",
53 | "autoprefixer": "^10.4.17",
54 | "eslint": "^8.57.0",
55 | "eslint-config-prettier": "^9.1.0",
56 | "eslint-plugin-prettier": "^5.1.3",
57 | "npm-watch": "^0.11.0",
58 | "postcss": "^8.4.35",
59 | "postcss-banner": "^4.0.1",
60 | "prettier": "^3.2.5",
61 | "rimraf": "^5.0.5",
62 | "rollup": "^4.12.0",
63 | "rollup-plugin-cleanup": "^3.2.1",
64 | "rollup-plugin-copy": "^3.5.0",
65 | "rollup-plugin-postcss": "^4.0.2"
66 | },
67 | "dependencies": {
68 | "@placekit/client-js": "^2.3.0",
69 | "@popperjs/core": "^2.11.8"
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 |
3 | import autoprefixer from 'autoprefixer';
4 | import { nodeResolve } from '@rollup/plugin-node-resolve';
5 | import cleanup from 'rollup-plugin-cleanup';
6 | import copy from 'rollup-plugin-copy';
7 | import postcss from 'rollup-plugin-postcss';
8 | import postcssBanner from 'postcss-banner';
9 |
10 | import pkg from './package.json' assert { type: 'json' };
11 | const banner = [
12 | `/*! ${pkg.name} v${pkg.version}`,
13 | '© placekit.io',
14 | `${pkg.license} license`,
15 | `${pkg.homepage} */`,
16 | ].join(' | ');
17 |
18 | export default {
19 | input: 'src/placekit-autocomplete.js',
20 | output: [
21 | {
22 | file: pkg.module,
23 | format: 'es',
24 | banner,
25 | },
26 | {
27 | file: pkg.main,
28 | format: 'cjs',
29 | exports: 'auto',
30 | banner,
31 | },
32 | {
33 | file: pkg.browser,
34 | format: 'umd',
35 | name: 'placekitAutocomplete',
36 | banner,
37 | },
38 | ],
39 | plugins: [
40 | nodeResolve({
41 | browser: true,
42 | }),
43 | cleanup({
44 | comments: 'none',
45 | }),
46 | postcss({
47 | extract: path.resolve('./dist/placekit-autocomplete.css'),
48 | minimize: false,
49 | plugins: [
50 | autoprefixer(),
51 | postcssBanner({
52 | banner: banner.replace(/^\/\*\!\s+(.+)\s+\*\/$/, '$1'),
53 | inline: true,
54 | important: true,
55 | }),
56 | ],
57 | }),
58 | copy({
59 | targets: [
60 | {
61 | src: 'src/placekit-autocomplete.d.ts',
62 | dest: path.dirname(pkg.types),
63 | rename: path.basename(pkg.types),
64 | transform: (content) => [banner, content].join("\n"),
65 | },
66 | ]
67 | })
68 | ],
69 | };
70 |
--------------------------------------------------------------------------------
/src/placekit-autocomplete.css:
--------------------------------------------------------------------------------
1 | /*
2 | * 1. Variables
3 | * 2. Globals
4 | * 3. Input
5 | * 4. Panel
6 | * 5. Suggestions
7 | * 6. Footer
8 | */
9 |
10 | /*
11 | * ----------------------------------------
12 | * 1. Variables
13 | * ----------------------------------------
14 | */
15 | :root {
16 | --pka-scale: 1rem;
17 | --pka-color-accent: 1, 73, 200;
18 | --pka-color-black: 29, 41, 57;
19 | --pka-color-darker: 52, 64, 84;
20 | --pka-color-dark: 152, 162, 179;
21 | --pka-color-light: 207, 213, 221;
22 | --pka-color-lighter: 243, 244, 246;
23 | --pka-color-white: 255, 255, 255;
24 | --pka-border-radius: 6px;
25 | --pka-font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
26 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
27 | --pka-z-index: 9999;
28 |
29 | --pka-icon-pin: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M24 23.5c.97 0 1.8-.34 2.48-1.03A3.39 3.39 0 0 0 27.5 20c0-.97-.34-1.8-1.03-2.48A3.39 3.39 0 0 0 24 16.5c-.97 0-1.8.34-2.48 1.03A3.39 3.39 0 0 0 20.5 20c0 .97.34 1.8 1.03 2.48A3.39 3.39 0 0 0 24 23.5Zm0 16.55c4.43-4.03 7.7-7.7 9.83-10.97C35.94 25.79 37 22.9 37 20.4c0-3.93-1.26-7.14-3.77-9.64A12.57 12.57 0 0 0 24 7c-3.65 0-6.73 1.25-9.23 3.76-2.51 2.5-3.77 5.71-3.77 9.64 0 2.5 1.08 5.4 3.25 8.68 2.17 3.28 5.42 6.94 9.75 10.97ZM24 44c-5.37-4.57-9.38-8.8-12.03-12.73C9.32 27.36 8 23.73 8 20.4c0-5 1.6-8.98 4.82-11.95A15.98 15.98 0 0 1 24 4c4.23 0 7.96 1.48 11.17 4.45C38.4 11.42 40 15.4 40 20.4c0 3.33-1.33 6.96-3.98 10.88C33.38 35.19 29.38 39.42 24 44Z'/%3E%3C/svg%3E");
30 | --pka-icon-street: var(--pka-icon-pin);
31 | --pka-icon-administrative: var(--pka-icon-pin);
32 | --pka-icon-county: var(--pka-icon-pin);
33 | --pka-icon-city: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M6 42V14.1h12.35V9.5L24 4l5.65 5.5v12.9H42V42H6Zm3-3h5.3v-5.3H9V39Zm0-8.3h5.3v-5.3H9v5.3Zm0-8.3h5.3v-5.3H9v5.3ZM21.35 39h5.3v-5.3h-5.3V39Zm0-8.3h5.3v-5.3h-5.3v5.3Zm0-8.3h5.3v-5.3h-5.3v5.3Zm0-8.3h5.3V8.8h-5.3v5.3ZM33.7 39H39v-5.3h-5.3V39Zm0-8.3H39v-5.3h-5.3v5.3Z'/%3E%3C/svg%3E");
34 | --pka-icon-airport: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M17.5 44v-2.1l4-3V26.35L4 31.5v-2.9l17.5-10.3V6.5c0-.7.24-1.3.73-1.78A2.41 2.41 0 0 1 24 4c.7 0 1.3.24 1.77.72.49.49.73 1.08.73 1.78v11.8L44 28.6v2.9l-17.5-5.15V38.9l4 3V44L24 42.15 17.5 44Z'/%3E%3C/svg%3E");
35 | --pka-icon-bus: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M10.8 42v-5.55a4.82 4.82 0 0 1-2.13-2.3 7.7 7.7 0 0 1-.67-3.2V11.1c0-2.46 1.28-4.25 3.82-5.4C14.38 4.58 18.45 4 24.05 4c5.53 0 9.57.57 12.12 1.7C38.72 6.83 40 8.63 40 11.1v19.85a7.7 7.7 0 0 1-.67 3.2 4.82 4.82 0 0 1-2.13 2.3V42h-4.3v-4.1H15.1V42h-4.3ZM11 9.8h26-26Zm26 14.65H11h26Zm-26-3h26V12.8H11v8.65Zm5.3 10.95c.77 0 1.42-.27 1.95-.8.53-.54.8-1.19.8-1.95 0-.77-.27-1.42-.8-1.95a2.66 2.66 0 0 0-1.95-.8c-.77 0-1.42.27-1.95.8-.53.54-.8 1.19-.8 1.95 0 .77.27 1.42.8 1.95.54.53 1.19.8 1.95.8Zm15.4 0c.77 0 1.42-.27 1.95-.8.53-.54.8-1.19.8-1.95 0-.77-.27-1.42-.8-1.95a2.66 2.66 0 0 0-1.95-.8c-.77 0-1.42.27-1.95.8-.53.54-.8 1.19-.8 1.95 0 .77.27 1.42.8 1.95.54.53 1.19.8 1.95.8ZM11 9.8h26c-.8-.87-2.33-1.55-4.6-2.05-2.27-.5-5.05-.75-8.35-.75-3.93 0-6.95.22-9.05.67-2.1.46-3.43 1.16-4 2.13Zm4.1 25.1h17.8c1.17 0 2.14-.45 2.93-1.35a4.59 4.59 0 0 0 1.17-3.1v-6H11v6c0 1.17.4 2.2 1.18 3.1.78.9 1.75 1.35 2.92 1.35Z'/%3E%3C/svg%3E");
36 | --pka-icon-train: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M8 35V15.3c0-3 1.18-4.98 3.53-5.95 2.35-.97 5.99-1.48 10.92-1.55l1.6-3.3H14V2h20v2.5h-6.95l-1.5 3.3c4.2.07 7.66.58 10.37 1.52 2.72.96 4.08 2.95 4.08 5.98V35c0 1.97-.67 3.63-2.02 4.98A6.76 6.76 0 0 1 33 42l3 3v1h-3.5l-4-4h-9l-4 4H12v-1l3-3a6.76 6.76 0 0 1-4.97-2.02A6.76 6.76 0 0 1 8 35Zm28.95-7.7H11h25.95Zm-12.8 8.5c.77 0 1.42-.27 1.95-.8s.8-1.18.8-1.95-.27-1.42-.8-1.95a2.65 2.65 0 0 0-1.95-.8c-.77 0-1.42.27-1.95.8s-.8 1.18-.8 1.95.27 1.42.8 1.95 1.18.8 1.95.8Zm-13-22.25H36.6 11.15ZM11 24.3h25.95v-7.75H11v7.75Zm3.7 14.5h18.55a3.5 3.5 0 0 0 2.65-1.1 3.76 3.76 0 0 0 1.05-2.7v-7.7H11V35a3.71 3.71 0 0 0 3.7 3.8Zm9.35-28c-4.1 0-7.2.24-9.28.72-2.08.49-3.29 1.16-3.62 2.03H36.6c-.33-.67-1.5-1.3-3.52-1.88-2.02-.58-5.03-.87-9.03-.87Z'/%3E%3C/svg%3E");
37 | --pka-icon-townhall: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M10.6 35.95V19h3v16.95h-3Zm12.1 0V19h3v16.95h-3Zm-18.7 6v-3h40v3H4Zm30.4-6V19h3v16.95h-3ZM4 16v-2.65l20-11.4 20 11.4V16H4Zm6.7-3h26.6-26.6Zm0 0h26.6L24 5.4 10.7 13Z'/%3E%3C/svg%3E");
38 | --pka-icon-tourism: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='m13.15 34.85 14.5-7.15 7.15-14.5-14.5 7.15-7.15 14.5ZM24 26c-.57 0-1.04-.2-1.43-.57A1.94 1.94 0 0 1 22 24c0-.57.2-1.04.57-1.43.39-.38.86-.57 1.43-.57s1.04.2 1.43.57c.38.39.57.86.57 1.43s-.2 1.04-.57 1.43c-.39.38-.86.57-1.43.57Zm0 18c-2.73 0-5.32-.52-7.75-1.58A20.29 20.29 0 0 1 5.57 31.75 19.35 19.35 0 0 1 4 24 19.99 19.99 0 0 1 16.25 5.57 19.35 19.35 0 0 1 24 4c2.77 0 5.37.53 7.8 1.58a20.2 20.2 0 0 1 6.35 4.27 20.2 20.2 0 0 1 4.27 6.35A19.47 19.47 0 0 1 44 24c0 2.73-.52 5.32-1.58 7.75A19.99 19.99 0 0 1 24 44Zm0-3c4.73 0 8.75-1.66 12.05-4.98A16.42 16.42 0 0 0 41 24a16.4 16.4 0 0 0-4.95-12.05A16.4 16.4 0 0 0 24 7c-4.7 0-8.7 1.65-12.03 4.95A16.36 16.36 0 0 0 7 24c0 4.7 1.66 8.7 4.97 12.02A16.38 16.38 0 0 0 24 41Z'/%3E%3C/svg%3E");
39 | --pka-icon-noresults: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M27.5 20c0-.96-.34-1.79-1.03-2.47A3.37 3.37 0 0 0 24 16.5c-.47 0-.9.08-1.27.25-.39.17-.73.38-1.03.65l4.9 4.9c.27-.3.48-.64.65-1.03.17-.38.25-.8.25-1.27Zm8.35 11.55-2.2-2.2c1.13-1.7 1.98-3.3 2.52-4.8.55-1.5.83-2.88.83-4.15 0-3.93-1.26-7.14-3.77-9.64A12.57 12.57 0 0 0 24 7c-1.8 0-3.48.3-5.02.92-1.55.62-2.91 1.51-4.08 2.68l-2.1-2.1a15.41 15.41 0 0 1 5.16-3.35A16.29 16.29 0 0 1 24 4c4.23 0 7.96 1.48 11.17 4.45C38.4 11.42 40 15.4 40 20.4c0 1.7-.34 3.48-1.02 5.33a26.73 26.73 0 0 1-3.13 5.82Zm-6 2.6-18.2-18.2c-.23.67-.4 1.38-.5 2.13-.1.75-.15 1.52-.15 2.32 0 2.5 1.08 5.4 3.25 8.68 2.17 3.28 5.42 6.94 9.75 10.97a80.52 80.52 0 0 0 5.85-5.9Zm11.8 11.8L32 36.3a74.88 74.88 0 0 1-8 7.7c-5.37-4.57-9.38-8.8-12.03-12.73C9.32 27.36 8 23.73 8 20.4c0-1.26.1-2.45.32-3.6.22-1.13.55-2.2.98-3.2l-8-8 2.15-2.15L43.8 43.8l-2.15 2.15Z'/%3E%3C/svg%3E");
40 | --pka-icon-clear: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='m12.45 37.65-2.1-2.1L21.9 24 10.35 12.45l2.1-2.1L24 21.9l11.55-11.55 2.1 2.1L26.1 24l11.55 11.55-2.1 2.1L24 26.1 12.45 37.65Z'/%3E%3C/svg%3E");
41 | --pka-icon-cancel: var(--pka-icon-clear);
42 | --pka-icon-insert: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M35.9 35.7 15.3 15.1V34h-3V10h24v3H17.4L38 33.6l-2.1 2.1Z'/%3E%3C/svg%3E");
43 | --pka-icon-check: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M18.9 35.7 7.7 24.5l2.15-2.15 9.05 9.05 19.2-19.2 2.15 2.15L18.9 35.7Z'/%3E%3C/svg%3E");
44 | --pka-icon-switch: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M9.8 31.45c-.67-1.2-1.13-2.4-1.4-3.63-.27-1.21-.4-2.45-.4-3.72a15.4 15.4 0 0 1 4.72-11.28A15.4 15.4 0 0 1 24 8.1h2.15l-4-4 1.95-1.95 7.45 7.45-7.45 7.45-2-2 3.95-3.95H24c-3.57 0-6.63 1.28-9.18 3.83A12.51 12.51 0 0 0 11 24.1c0 .97.1 1.88.28 2.75.18.87.4 1.68.67 2.45L9.8 31.45ZM23.8 46l-7.45-7.45 7.45-7.45 1.95 1.95-4 4H24c3.57 0 6.63-1.27 9.17-3.82A12.51 12.51 0 0 0 37 24.05c0-.97-.08-1.88-.25-2.75-.17-.87-.42-1.68-.75-2.45l2.15-2.15c.67 1.2 1.14 2.4 1.43 3.63.28 1.21.42 2.45.42 3.72a15.4 15.4 0 0 1-4.73 11.28A15.4 15.4 0 0 1 24 40.05h-2.25l4 4L23.8 46Z'/%3E%3C/svg%3E");
45 | --pka-icon-geo-off: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M22.5 46v-3.75A18.22 18.22 0 0 1 11.1 37a18.22 18.22 0 0 1-5.25-11.4H2.1v-3h3.75a18.22 18.22 0 0 1 5.25-11.4 18.22 18.22 0 0 1 11.4-5.25V2.2h3v3.75c4.57.47 8.37 2.22 11.4 5.25a18.22 18.22 0 0 1 5.25 11.4h3.75v3h-3.75A18.22 18.22 0 0 1 36.9 37a18.22 18.22 0 0 1-11.4 5.25V46h-3Zm1.5-6.7c4.17 0 7.74-1.5 10.73-4.47A14.63 14.63 0 0 0 39.2 24.1c0-4.17-1.5-7.74-4.48-10.73A14.63 14.63 0 0 0 24 8.9c-4.17 0-7.74 1.5-10.72 4.47A14.63 14.63 0 0 0 8.8 24.1c0 4.17 1.5 7.74 4.47 10.73A14.63 14.63 0 0 0 24 39.3Z'/%3E%3C/svg%3E");
46 | --pka-icon-geo-on: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M22.5 45.9v-3.75a18.22 18.22 0 0 1-11.4-5.25 18.22 18.22 0 0 1-5.25-11.4H2.1v-3h3.75a18.22 18.22 0 0 1 5.25-11.4 18.22 18.22 0 0 1 11.4-5.25V2.1h3v3.75c4.57.47 8.37 2.22 11.4 5.25a18.22 18.22 0 0 1 5.25 11.4h3.75v3h-3.75a18.22 18.22 0 0 1-5.25 11.4 18.22 18.22 0 0 1-11.4 5.25v3.75h-3Zm1.5-6.7c4.17 0 7.74-1.5 10.73-4.48A14.63 14.63 0 0 0 39.2 24c0-4.17-1.5-7.74-4.48-10.72A14.63 14.63 0 0 0 24 8.8c-4.17 0-7.74 1.5-10.72 4.47A14.63 14.63 0 0 0 8.8 24c0 4.17 1.5 7.74 4.47 10.73A14.63 14.63 0 0 0 24 39.2Zm0-7.7c-2.1 0-3.88-.73-5.32-2.18A7.24 7.24 0 0 1 16.5 24c0-2.1.73-3.88 2.18-5.32A7.24 7.24 0 0 1 24 16.5c2.1 0 3.88.73 5.32 2.18A7.24 7.24 0 0 1 31.5 24c0 2.1-.73 3.88-2.18 5.32A7.24 7.24 0 0 1 24 31.5Zm0-3c1.27 0 2.33-.43 3.2-1.3a4.35 4.35 0 0 0 1.3-3.2c0-1.27-.43-2.33-1.3-3.2a4.35 4.35 0 0 0-3.2-1.3c-1.27 0-2.33.43-3.2 1.3a4.35 4.35 0 0 0-1.3 3.2c0 1.27.43 2.33 1.3 3.2.87.87 1.93 1.3 3.2 1.3Z'/%3E%3C/svg%3E");
47 | --pka-icon-loading: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M24 44A19.88 19.88 0 0 1 5.55 31.85 19.88 19.88 0 0 1 4 24 19.9 19.9 0 0 1 16.15 5.55 19.88 19.88 0 0 1 24 4v3a16.4 16.4 0 0 0-12.03 4.96A16.37 16.37 0 0 0 7 24c0 4.7 1.66 8.72 4.96 12.04A16.36 16.36 0 0 0 24 41c4.7 0 8.72-1.66 12.04-4.97A16.38 16.38 0 0 0 41 24h3a19.88 19.88 0 0 1-12.16 18.45A19.9 19.9 0 0 1 24 44Z'/%3E%3C/svg%3E");
48 | }
49 |
50 | /* dark mode overrides */
51 | .dark,
52 | [data-theme='dark'] {
53 | --pka-color-accent: 55, 131, 249;
54 | }
55 |
56 | /*
57 | * ----------------------------------------
58 | * 2. Globals
59 | * ----------------------------------------
60 | */
61 | /* local reset */
62 | .pka-input,
63 | .pka-input *,
64 | .pka-panel,
65 | .pka-panel * {
66 | box-sizing: border-box;
67 | margin: 0;
68 | padding: 0;
69 | }
70 |
71 | .pka-input button,
72 | .pka-panel button {
73 | appearance: none;
74 | border: none;
75 | background-color: transparent;
76 | }
77 |
78 | /* content only for screen-readers */
79 | .pka-sr-only {
80 | position: absolute;
81 | width: 1px;
82 | height: 1px;
83 | padding: 0;
84 | margin: -1px;
85 | overflow: hidden;
86 | clip: rect(0, 0, 0, 0);
87 | white-space: nowrap;
88 | border-width: 0;
89 | }
90 |
91 | /* flags and icons */
92 | .pka-flag {
93 | pointer-events: none;
94 | user-select: none;
95 | object-fit: contain;
96 | flex-shrink: 0;
97 | flex-grow: 0;
98 | width: 1em;
99 | height: auto;
100 | }
101 |
102 | .pka-icon {
103 | pointer-events: none;
104 | user-select: none;
105 | flex-shrink: 0;
106 | flex-grow: 0;
107 | width: 1em;
108 | height: 1em;
109 | background-color: currentcolor;
110 | mask-size: 1em;
111 | mask-repeat: no-repeat;
112 | mask-position: center;
113 | }
114 |
115 | .pka-icon-pin {
116 | mask-image: var(--pka-icon-pin);
117 | }
118 | .pka-icon-street {
119 | mask-image: var(--pka-icon-street);
120 | }
121 | .pka-icon-administrative {
122 | mask-image: var(--pka-icon-administrative);
123 | }
124 | .pka-icon-county {
125 | mask-image: var(--pka-icon-county);
126 | }
127 | .pka-icon-city {
128 | mask-image: var(--pka-icon-city);
129 | }
130 | .pka-icon-airport {
131 | mask-image: var(--pka-icon-airport);
132 | }
133 | .pka-icon-bus {
134 | mask-image: var(--pka-icon-bus);
135 | }
136 | .pka-icon-train {
137 | mask-image: var(--pka-icon-train);
138 | }
139 | .pka-icon-townhall {
140 | mask-image: var(--pka-icon-townhall);
141 | }
142 | .pka-icon-tourism {
143 | mask-image: var(--pka-icon-tourism);
144 | }
145 | .pka-icon-noresults {
146 | mask-image: var(--pka-icon-noresults);
147 | }
148 | .pka-icon-switch {
149 | mask-image: var(--pka-icon-switch);
150 | }
151 | .pka-icon-cancel {
152 | mask-image: var(--pka-icon-cancel);
153 | }
154 | .pka-icon-loading {
155 | mask-image: var(--pka-icon-loading);
156 | }
157 |
158 | /*
159 | * ----------------------------------------
160 | * 3. Input
161 | * ----------------------------------------
162 | */
163 | .pka-input {
164 | appearance: none;
165 | border: 1px solid rgb(var(--pka-color-light));
166 | border-radius: var(--pka-border-radius);
167 | background-color: rgb(var(--pka-color-white));
168 | box-shadow: 0 1px 2px 0 rgba(var(--pka-color-black), 0.1);
169 | height: calc(var(--pka-scale) * 2.375);
170 | line-height: calc(var(--pka-scale) * 1.25);
171 | font-size: calc(var(--pka-scale) * 0.875);
172 | font-family: var(--pka-font-family);
173 | color: rgb(var(--pka-color-black));
174 | }
175 |
176 | .dark .pka-input,
177 | [data-theme='dark'] .pka-input,
178 | .pka-input.dark {
179 | border-color: rgb(var(--pka-color-darker));
180 | background-color: rgb(var(--pka-color-black));
181 | color: rgb(var(--pka-color-lighter));
182 | }
183 |
184 | .pka-input:not(input) {
185 | overflow: hidden;
186 | position: relative;
187 | }
188 |
189 | .pka-input:not(input) input {
190 | appearance: none;
191 | width: 100%;
192 | border: none;
193 | background-color: transparent;
194 | line-height: inherit;
195 | font-size: 1em;
196 | font-family: inherit;
197 | color: inherit;
198 | }
199 |
200 | input.pka-input::placeholder,
201 | .pka-input:not(input) input::placeholder {
202 | color: rgb(var(--pka-color-dark));
203 | }
204 |
205 | input.pka-input:focus,
206 | .pka-input:not(input):focus-within {
207 | outline: none;
208 | box-shadow: 0 0 0 1px rgb(var(--pka-color-accent));
209 | border-color: rgb(var(--pka-color-accent));
210 | }
211 |
212 | .pka-input:not(input) input:focus {
213 | outline: none;
214 | box-shadow: none;
215 | border: none;
216 | }
217 |
218 | input.pka-input,
219 | .pka-input:not(input) input {
220 | padding: calc(var(--pka-scale) * 0.5) calc(var(--pka-scale) * 0.75);
221 | }
222 |
223 | .pka-input:not(input) input:disabled,
224 | input.pka-input:disabled {
225 | cursor: not-allowed;
226 | }
227 |
228 | /* input geoloc/clear buttons
229 | ---------------------------------------- */
230 | button.pka-input-geolocation,
231 | button.pka-input-clear {
232 | position: absolute;
233 | top: 0;
234 | z-index: 1;
235 | width: calc(var(--pka-scale) * 2.25);
236 | height: 100%;
237 | background-color: rgb(var(--pka-color-dark));
238 | mask-size: var(--pka-scale);
239 | mask-repeat: no-repeat;
240 | mask-position: center;
241 | }
242 |
243 | button.pka-input-geolocation {
244 | left: 0;
245 | mask-image: var(--pka-icon-geo-off);
246 | }
247 |
248 | button.pka-input-clear {
249 | right: 0;
250 | mask-image: var(--pka-icon-clear);
251 | }
252 |
253 | @media (hover: hover) and (pointer: fine) {
254 | button.pka-input-geolocation:hover,
255 | button.pka-input-clear:hover {
256 | background-color: rgb(var(--pka-color-black));
257 | }
258 |
259 | .dark button.pka-input-geolocation:hover,
260 | .dark button.pka-input-clear:hover,
261 | [data-theme='dark'] button.pka-input-geolocation:hover,
262 | [data-theme='dark'] button.pka-input-clear:hover {
263 | background-color: rgb(var(--pka-color-white));
264 | }
265 |
266 | button.pka-input-geolocation:hover {
267 | mask-image: var(--pka-icon-geo-on);
268 | }
269 | }
270 |
271 | button.pka-input-geolocation.pka-enabled {
272 | mask-image: var(--pka-icon-geo-on);
273 | }
274 |
275 | button.pka-input-geolocation.pka-enabled {
276 | background-color: rgb(var(--pka-color-accent));
277 | }
278 |
279 | .pka-input:not(input) button.pka-input-geolocation ~ input {
280 | padding-left: calc(var(--pka-scale) * 2.25);
281 | }
282 |
283 | button.pka-input-clear[aria-hidden='true'] {
284 | display: none;
285 | }
286 |
287 | .pka-input:not(input) button.pka-input-clear:not([aria-hidden='true']) ~ input {
288 | padding-right: calc(var(--pka-scale) * 2.25);
289 | }
290 |
291 | .pka-input:not(input) input[type='search']::-webkit-search-decoration,
292 | .pka-input:not(input) input[type='search']::-webkit-search-cancel-button,
293 | .pka-input:not(input) input[type='search']::-webkit-search-results-button,
294 | .pka-input:not(input) input[type='search']::-webkit-search-results-decoration {
295 | appearance: none;
296 | }
297 |
298 | /*
299 | * ----------------------------------------
300 | * 4. Panel
301 | * ----------------------------------------
302 | */
303 | .pka-panel {
304 | container-name: pka-panel;
305 | container-type: inline-size;
306 | z-index: -1;
307 | opacity: 0;
308 | pointer-events: none;
309 | overflow: hidden;
310 | min-width: 240px;
311 | border: 1px solid rgb(var(--pka-color-lighter));
312 | border-radius: var(--pka-border-radius);
313 | box-shadow:
314 | 0 10px 15px -3px rgba(var(--pka-color-black), 0.1),
315 | 0 4px 6px -4px rgba(var(--pka-color-black), 0.1);
316 | background-color: rgb(var(--pka-color-white));
317 | list-style: none;
318 | line-height: 1.125em;
319 | font-family: var(--pka-font-family);
320 | font-size: var(--pka-scale);
321 | }
322 |
323 | .pka-panel.pka-open {
324 | z-index: var(--pka-z-index);
325 | opacity: 1;
326 | pointer-events: auto;
327 | }
328 |
329 | .dark .pka-panel,
330 | [data-theme='dark'] .pka-panel,
331 | .pka-panel.dark {
332 | border-color: rgb(var(--pka-color-black));
333 | background-color: rgb(var(--pka-color-darker));
334 | }
335 |
336 | /* spinner */
337 | .pka-panel-loading {
338 | display: flex;
339 | position: absolute;
340 | top: 0;
341 | right: 0;
342 | height: 1.5em;
343 | width: 1.5em;
344 | padding: 0.25em;
345 | z-index: 10;
346 | color: rgb(var(--pka-color-accent));
347 | }
348 |
349 | .pka-panel-loading[aria-hidden='true'] {
350 | display: none;
351 | }
352 |
353 | .pka-panel-loading .pka-icon {
354 | animation: spin 3s linear infinite;
355 | }
356 |
357 | @keyframes spin {
358 | from {
359 | transform: rotate(0deg);
360 | }
361 | to {
362 | transform: rotate(360deg);
363 | }
364 | }
365 |
366 | /*
367 | * ----------------------------------------
368 | * 5. Suggestions
369 | * ----------------------------------------
370 | */
371 | .pka-panel-suggestions {
372 | overflow-y: auto;
373 | -webkit-overflow-scrolling: touch;
374 | min-height: 1.5em;
375 | max-height: 14em;
376 | color: rgb(var(--pka-color-dark));
377 | }
378 |
379 | .pka-panel-suggestion {
380 | position: relative;
381 | display: flex;
382 | align-items: center;
383 | gap: 0.625em;
384 | padding: 0.375em 0.625em;
385 | min-height: 2.5rem;
386 | }
387 |
388 | @container pka-panel (min-width: 420px) {
389 | .pka-panel-suggestion {
390 | padding: 0.625em;
391 | }
392 | }
393 |
394 | /* suggestion label
395 | ---------------------------------------- */
396 | .pka-panel-suggestion-label {
397 | flex-grow: 1;
398 | overflow: hidden;
399 | }
400 |
401 | .pka-panel-suggestion-label-name,
402 | .pka-panel-suggestion-label-sub {
403 | display: block;
404 | overflow: hidden;
405 | text-overflow: ellipsis;
406 | white-space: nowrap;
407 | }
408 |
409 | .pka-panel-suggestion-label-name {
410 | font-size: 0.875em;
411 | color: rgb(var(--pka-color-black));
412 | }
413 |
414 | .pka-panel-suggestion[aria-disabled='true'] .pka-panel-suggestion-label-name {
415 | color: rgb(var(--pka-color-darker));
416 | }
417 |
418 | .dark .pka-panel-suggestion-label-name,
419 | [data-theme='dark'] .pka-panel-suggestion-label-name {
420 | color: rgb(var(--pka-color-white));
421 | }
422 | .dark .pka-panel-suggestion[aria-disabled='true'] .pka-panel-suggestion-label-name,
423 | [data-theme='dark'] .pka-panel-suggestion[aria-disabled='true'] .pka-panel-suggestion-label-name {
424 | color: rgb(var(--pka-color-light));
425 | }
426 |
427 | .pka-panel-suggestion-label-name mark {
428 | background: transparent;
429 | color: inherit;
430 | text-decoration-line: underline;
431 | text-decoration-style: solid;
432 | text-decoration-color: rgb(var(--pka-color-dark));
433 | text-underline-offset: 2px;
434 | }
435 |
436 | .pka-panel-suggestion-label-sub {
437 | font-size: 0.75em;
438 | }
439 |
440 | @container pka-panel (min-width: 420px) {
441 | .pka-panel-suggestion-label {
442 | text-overflow: ellipsis;
443 | white-space: nowrap;
444 | }
445 |
446 | .pka-panel-suggestion-label-name,
447 | .pka-panel-suggestion-label-sub {
448 | display: inline;
449 | overflow: auto;
450 | }
451 | }
452 |
453 | /* suggestion action button
454 | ---------------------------------------- */
455 | button.pka-panel-suggestion-action {
456 | opacity: 0;
457 | flex: 0 0 1em;
458 | height: 1em;
459 | background-color: currentcolor;
460 | mask-size: var(--pka-scale);
461 | mask-repeat: no-repeat;
462 | mask-position: center;
463 | cursor: pointer;
464 | user-select: none;
465 | }
466 |
467 | /* suggestion active/selected overrides
468 | ---------------------------------------- */
469 | /* NOTE: .pka-active replaces :hover selector as both keyboard nav and mouseover are handled JS-side */
470 | .pka-panel-suggestion.pka-active {
471 | -webkit-font-smoothing: antialiased;
472 | background-color: rgb(var(--pka-color-accent));
473 | font-weight: 500;
474 | color: rgba(var(--pka-color-white), 0.8);
475 | cursor: pointer;
476 | }
477 |
478 | .pka-panel-suggestion.pka-active .pka-panel-suggestion-label-name {
479 | color: rgb(var(--pka-color-white));
480 | }
481 |
482 | .pka-panel-suggestion.pka-active .pka-panel-suggestion-label-name mark {
483 | text-decoration-color: rgba(var(--pka-color-white), 0.5);
484 | }
485 |
486 | .pka-panel-suggestion.pka-active button.pka-panel-suggestion-action {
487 | mask-image: var(--pka-icon-insert);
488 | }
489 |
490 | .pka-panel-suggestion.pka-selected button.pka-panel-suggestion-action,
491 | .pka-panel-suggestion.pka-active.pka-selected button.pka-panel-suggestion-action {
492 | mask-image: var(--pka-icon-check);
493 | }
494 |
495 | .pka-panel-suggestion.pka-active button.pka-panel-suggestion-action,
496 | .pka-panel-suggestion.pka-selected button.pka-panel-suggestion-action {
497 | opacity: 1;
498 | }
499 |
500 | @media (hover: hover) and (pointer: fine) {
501 | .pka-panel-suggestion.pka-active button.pka-panel-suggestion-action:hover {
502 | background-color: rgb(var(--pka-color-white));
503 | }
504 | }
505 |
506 | .pka-panel-suggestion.pka-active.pka-selected button.pka-panel-suggestion-action {
507 | background-color: rgb(var(--pka-color-white));
508 | }
509 |
510 | /*
511 | * ----------------------------------------
512 | * 6. Footer
513 | * ----------------------------------------
514 | */
515 | .pka-panel-footer {
516 | width: 100%;
517 | display: flex;
518 | align-items: center;
519 | justify-content: space-between;
520 | gap: 0.625em;
521 | padding: 0.375em 0.625em;
522 | border-top: 1px solid rgb(var(--pka-color-lighter));
523 | line-height: 1em;
524 | }
525 |
526 | .dark .pka-panel-footer,
527 | [data-theme='dark'] .pka-panel-footer {
528 | border-top-color: rgb(var(--pka-color-black));
529 | }
530 |
531 | /* country button
532 | ---------------------------------------- */
533 | .pka-panel-country {
534 | flex: 1;
535 | color: rgb(var(--pka-color-dark));
536 | max-width: calc(100% - var(--pka-scale) * 7);
537 | }
538 |
539 | @media (hover: hover) and (pointer: fine) {
540 | .pka-panel-country:hover {
541 | color: rgb(var(--pka-color-accent));
542 | }
543 | }
544 |
545 | .pka-panel-country-open,
546 | #pka-panel-country-mode:checked ~ .pka-panel-country-close {
547 | display: flex;
548 | align-items: center;
549 | gap: 0.625em;
550 | cursor: pointer;
551 | }
552 |
553 | .pka-panel-country-close,
554 | #pka-panel-country-mode:checked ~ .pka-panel-country-open,
555 | #pka-panel-country-mode:disabled ~ .pka-panel-country-open,
556 | #pka-panel-country-mode:disabled ~ .pka-panel-country-close {
557 | display: none;
558 | }
559 |
560 | .pka-panel-country-label {
561 | overflow: hidden;
562 | white-space: nowrap;
563 | text-overflow: ellipsis;
564 | font-size: 0.75em;
565 | }
566 |
567 | .pka-panel-country-label ~ .pka-icon {
568 | margin-left: -0.5em;
569 | font-size: 0.75em;
570 | }
571 |
572 | /* credits
573 | ---------------------------------------- */
574 | .pka-panel-credits {
575 | flex-shrink: 0;
576 | display: flex;
577 | align-items: center;
578 | gap: 0.125em;
579 | }
580 |
581 | .pka-panel-credits-text {
582 | font-size: 0.5em;
583 | text-transform: lowercase;
584 | color: rgb(var(--pka-color-dark));
585 | }
586 |
587 | .pka-panel-credits-link {
588 | position: relative;
589 | top: 0.05em;
590 | color: rgb(var(--pka-color-accent));
591 | }
592 |
593 | .dark .pka-panel-credits-link,
594 | [data-theme='dark'] .pka-panel-credits-link {
595 | color: rgb(var(--pka-color-white));
596 | }
597 |
--------------------------------------------------------------------------------
/src/placekit-autocomplete.d.ts:
--------------------------------------------------------------------------------
1 | import type { PKOptions, PKResult } from '@placekit/client-js';
2 |
3 | export default PlaceKitAutocomplete;
4 | export as namespace PlaceKitAutocomplete;
5 |
6 | declare function PlaceKitAutocomplete(apiKey?: string, opts?: PKAOptions): PKAClient;
7 |
8 | export type PKAState = {
9 | dirty: boolean;
10 | empty: boolean;
11 | freeForm: boolean;
12 | geolocation: boolean;
13 | countryMode: boolean;
14 | };
15 |
16 | export interface PKAClient {
17 | readonly input: HTMLInputElement;
18 | readonly options: PKAOptions;
19 | configure(opts?: PKOptions): PKAClient;
20 | on(event: 'open', handler?: PKAHandlers['open']): PKAClient;
21 | on(event: 'close', handler?: PKAHandlers['close']): PKAClient;
22 | on(event: 'results', handler?: PKAHandlers['results']): PKAClient;
23 | on(event: 'pick', handler?: PKAHandlers['pick']): PKAClient;
24 | on(event: 'error', handler?: PKAHandlers['error']): PKAClient;
25 | on(event: 'dirty', handler?: PKAHandlers['dirty']): PKAClient;
26 | on(event: 'empty', handler?: PKAHandlers['empty']): PKAClient;
27 | on(event: 'freeForm', handler?: PKAHandlers['freeForm']): PKAClient;
28 | on(event: 'geolocation', handler?: PKAHandlers['geolocation']): PKAClient;
29 | on(event: 'countryMode', handler?: PKAHandlers['countryMode']): PKAClient;
30 | on(event: 'state', handler?: PKAHandlers['state']): PKAClient;
31 | on(event: 'countryChange', handler?: PKAHandlers['countryChange']): PKAClient;
32 | readonly handlers: Partial;
33 | readonly state: PKAState;
34 | requestGeolocation(opts?: Object, cancelUpdate?: boolean): Promise;
35 | clearGeolocation(): PKAClient;
36 | open(): PKAClient;
37 | close(): PKAClient;
38 | clear(): PKAClient;
39 | setValue(value?: string | null, notify?: boolean): PKAClient;
40 | destroy(): void;
41 | }
42 |
43 | export type PKAHandlers = {
44 | open: () => void;
45 | close: () => void;
46 | results: (query: string, results: PKResult[]) => void;
47 | pick: (value: string, item: PKResult, index: number) => void;
48 | error: (error: Object) => void;
49 | dirty: (bool: boolean) => void;
50 | empty: (bool: boolean) => void;
51 | freeForm: (bool: boolean) => void;
52 | geolocation: (bool: boolean, position?: GeolocationPosition) => void;
53 | countryMode: (bool: boolean) => void;
54 | state: (state: PKAState) => void;
55 | countryChange: (item: PKResult) => void;
56 | };
57 |
58 | export type PKAOptions = PKOptions & {
59 | target: string | HTMLInputElement;
60 | panel?: {
61 | className?: string;
62 | offset?: number;
63 | strategy?: 'absolute' | 'fixed';
64 | flip?: boolean;
65 | };
66 | format?: {
67 | flag?: (countrycode: string) => string;
68 | icon?: (name: string, label?: string) => string;
69 | sub?: (item: PKResult) => string;
70 | noResults?: (query: string) => string;
71 | value?: (item: PKResult) => string;
72 | applySuggestion?: string;
73 | cancel?: string;
74 | };
75 | countryAutoFill?: boolean;
76 | countrySelect?: boolean;
77 | };
78 |
--------------------------------------------------------------------------------
/src/placekit-autocomplete.js:
--------------------------------------------------------------------------------
1 | import placekit from '@placekit/client-js/lite';
2 | import { createPopper } from '@popperjs/core';
3 |
4 | import './placekit-autocomplete.css'; // removed at build time
5 |
6 | // generic helpers
7 | const isString = (v) => Object.prototype.toString.call(v) === '[object String]';
8 | const isObject = (v) => typeof v === 'object' && !Array.isArray(v) && v !== null;
9 | const merge = (a, b) => {
10 | if (!isObject(a)) return b;
11 | Object.keys(b).forEach((k) => (a[k] = isObject(b[k]) ? merge(a[k] || {}, b[k]) : b[k]));
12 | return a;
13 | };
14 |
15 | export default function placekitAutocomplete(
16 | apiKey,
17 | { target = '#placekit', ...initOptions } = {},
18 | ) {
19 | // Init client
20 | // ----------------------------------------
21 | // find input
22 | const input = isString(target) ? document.querySelector(target) : target;
23 | if (!input) {
24 | throw `Error: target not found.`;
25 | } else if (
26 | input.tagName !== 'INPUT' ||
27 | !['text', 'search'].includes(input.getAttribute('type'))
28 | ) {
29 | throw `Error: target must be an HTML input of type "text" or "search".`;
30 | }
31 |
32 | // internals
33 | const pk = placekit(apiKey);
34 | const handlers = new Map();
35 | let suggestions = [];
36 | let userValue = null; // preserve user value on keyboard nav
37 | let backupValue = null; // preserve user value on country mode
38 | let country = null;
39 | let globalSearchMode = false;
40 |
41 | // external
42 | const client = {};
43 | const state = {
44 | empty: !input.value,
45 | dirty: false,
46 | freeForm: true,
47 | geolocation: false,
48 | countryMode: false,
49 | };
50 |
51 | // default options
52 | const options = {
53 | panel: {
54 | className: '',
55 | offset: 4,
56 | strategy: 'absolute',
57 | flip: false,
58 | },
59 | format: {
60 | flag: (countrycode) =>
61 | ` `,
62 | icon: (name, label) =>
63 | ` `,
66 | sub: (item) => {
67 | switch (item.type) {
68 | case 'administrative':
69 | return [item.country].filter((s) => s).join(' ');
70 | case 'city':
71 | return [item.zipcode.sort()[0], item.country].filter((s) => s).join(' ');
72 | case 'country':
73 | return '';
74 | case 'county':
75 | return [item.administrative, item.country].filter((s) => s).join(' ');
76 | default:
77 | return [item.city, item.county].filter((s) => s).join(', ');
78 | }
79 | },
80 | noResults: (query) => `No results for ${query}`,
81 | value: (item) => item.name,
82 | applySuggestion: 'Apply suggestion',
83 | suggestions: 'Address suggestions',
84 | changeCountry: 'Change country',
85 | cancel: 'Cancel',
86 | },
87 | countryAutoFill: true,
88 | countrySelect: true,
89 | };
90 |
91 | // Init DOM
92 | // ----------------------------------------
93 | // add input accessibility attributes
94 | input.setAttribute('autocomplete', 'off');
95 | input.setAttribute('aria-autocomplete', 'list');
96 | input.setAttribute('aria-expanded', false);
97 | input.setAttribute('role', 'combobox');
98 |
99 | // build panel
100 | const panel = document.createElement('div');
101 | panel.classList.add('pka-panel');
102 | panel.innerHTML = `
103 | ${options.format.icon(
104 | 'loading',
105 | )}
106 |
109 |
133 | `;
134 | document.body.append(panel);
135 |
136 | const loading = panel.querySelector('.pka-panel-loading');
137 | const suggestionsList = panel.querySelector('.pka-panel-suggestions');
138 | const countryMode = panel.querySelector('#pka-panel-country-mode');
139 | const popper = createPopper(input, panel);
140 |
141 | // Utility functions
142 | // ----------------------------------------
143 | // fire registered event handler
144 | function fireEvent(event, ...args) {
145 | if (handlers.has(event)) {
146 | handlers.get(event).apply(client, args);
147 | }
148 | }
149 |
150 | // set state value and fire event unless silent
151 | function setState(partial, silent = false) {
152 | if (!isObject(partial)) {
153 | throw `TypeError: setState first argument must be a key/value object.`;
154 | }
155 | let update = false;
156 | for (const k in state) {
157 | if (k in partial && partial[k] !== state[k]) {
158 | state[k] = partial[k];
159 | update = true;
160 | if (!silent) {
161 | fireEvent(k, state[k]);
162 | }
163 | }
164 | }
165 | if (update) {
166 | fireEvent('state', state);
167 | }
168 | }
169 |
170 | // manually set input value
171 | function setValue(value, { notify = false, focus = true } = {}) {
172 | if (isString(value)) {
173 | Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, value);
174 | setState({ empty: !input.value });
175 | if (notify) {
176 | userValue = null;
177 | input.dispatchEvent(new Event('input', { bubbles: true }));
178 | input.dispatchEvent(new Event('change', { bubbles: true }));
179 | }
180 | if (focus) {
181 | input.focus();
182 | }
183 | }
184 | }
185 |
186 | // manually clear input value
187 | function clearInput() {
188 | setValue('', {
189 | notify: true,
190 | focus: true,
191 | });
192 | togglePanel(false);
193 | if (state.geolocation) {
194 | search();
195 | } else {
196 | suggestions = [];
197 | }
198 | }
199 |
200 | // restore backed-up user value
201 | function restoreValue(clear = false) {
202 | if (userValue !== null) {
203 | setValue(userValue, { focus: false });
204 | if (clear) {
205 | userValue = null;
206 | }
207 | }
208 | }
209 |
210 | // open/close the suggestions panel
211 | function togglePanel(bool) {
212 | const prev = panel.classList.contains('pka-open');
213 | const open = typeof bool === 'undefined' ? !prev : bool;
214 | panel.classList.toggle('pka-open', open);
215 | input.setAttribute('aria-expanded', open);
216 | if (prev !== open) {
217 | if (!open) {
218 | clearActive();
219 | }
220 | fireEvent(open ? 'open' : 'close');
221 | }
222 | }
223 |
224 | // clear active (hover/keyboard-selected) suggestions
225 | function clearActive() {
226 | panel
227 | .querySelectorAll('[role="option"]')
228 | .forEach((node) => node.classList.remove('pka-active'));
229 | }
230 |
231 | // move active suggestion cursor (keyboard nav) and preview value
232 | function moveActive(index) {
233 | if (userValue === null) {
234 | userValue = input.value;
235 | }
236 | const children = Array.from(suggestionsList.children);
237 | const prev = children.findIndex((node) => node.classList.contains('pka-active'));
238 | clearActive();
239 | const steps = children.length + 1; // cycle through user value + suggestions
240 | const pos = (prev + 1 + index + steps) % steps;
241 | if (pos === 0) {
242 | restoreValue();
243 | } else {
244 | const current = children[pos - 1];
245 | current.classList.add('pka-active');
246 | suggestionsList.scrollTo({ top: current.offsetTop });
247 | setValue(options.format.value(suggestions[pos - 1]));
248 | }
249 | }
250 |
251 | // inject active suggestion into input
252 | function applySuggestion(index) {
253 | const children = Array.from(suggestionsList.children);
254 | if (typeof index === 'undefined') {
255 | index = children.findIndex((node) => node.classList.contains('pka-active'));
256 | }
257 | const current = children[index];
258 | if (!current) return;
259 | const item = suggestions[index];
260 | if (state.countryMode) {
261 | setCountry(item);
262 | toggleCountryMode(false);
263 | } else {
264 | children.forEach((node, i) => {
265 | node.classList.toggle('pka-selected', i === index);
266 | node.setAttribute('aria-selected', i === index);
267 | });
268 | setValue(options.format.value(item), { notify: true });
269 | setState({
270 | dirty: true,
271 | freeForm: false,
272 | });
273 | togglePanel(false);
274 | fireEvent('pick', input.value, item, index);
275 | }
276 | }
277 |
278 | // debounce loading
279 | let loadingTimeout;
280 | function setLoading(bool) {
281 | clearTimeout(loadingTimeout);
282 | loading.setAttribute('aria-hidden', true);
283 | if (bool) {
284 | loadingTimeout = setTimeout(() => {
285 | loading.setAttribute('aria-hidden', false);
286 | }, 300);
287 | }
288 | }
289 |
290 | // search and update suggestions
291 | async function search() {
292 | userValue = null;
293 | const query = input.value;
294 | setState({
295 | empty: !query,
296 | dirty: true,
297 | freeForm: true,
298 | });
299 | setLoading(true);
300 | if (!countryMode.disabled) {
301 | await detectCountry();
302 | }
303 | // show flag for cities in global search
304 | const flagTypes = globalSearchMode ? ['city', 'country'] : ['country'];
305 | pk.search(query, {
306 | countries: !!country ? [country.countrycode] : options.countries,
307 | types: state.countryMode ? ['country'] : options.types,
308 | maxResults: state.countryMode ? 20 : options.maxResults,
309 | })
310 | .then(({ results }) => {
311 | setLoading(false);
312 | if (input.value !== query) return; // skip outdated
313 | suggestions = results;
314 | suggestionsList.innerHTML =
315 | results.length > 0
316 | ? results
317 | .map(
318 | (item) => `
319 |
320 | ${
321 | flagTypes.includes(item.type)
322 | ? options.format.flag(item.countrycode)
323 | : options.format.icon(item.type || 'pin', item.type)
324 | }
325 |
326 | ${item.highlight}
327 | ${options.format.sub(item)}
328 |
329 |
332 |
333 | `,
334 | )
335 | .join('')
336 | : `
337 |
338 | ${options.format.icon('noresults')}
339 |
340 |
341 | ${
342 | options.format.noResults?.call
343 | ? options.format.noResults(query)
344 | : options.format.noResults
345 | }
346 |
347 |
348 |
349 | `;
350 | popper.update();
351 | fireEvent('results', query, results);
352 | })
353 | .catch((err) => fireEvent('error', err));
354 | }
355 |
356 | // update current country
357 | function setCountry(item) {
358 | panel.querySelector('.pka-panel-country-open').innerHTML =
359 | item === null
360 | ? ''
361 | : `
362 | ${options.format.flag(item.countrycode)}
363 | ${item.name}
364 | ${options.format.icon('switch')}
365 | `;
366 | if (item?.countrycode !== country?.countrycode) {
367 | country = item;
368 | fireEvent('countryChange', country);
369 | }
370 | }
371 |
372 | // toggle country mode: search in countries
373 | function toggleCountryMode(bool) {
374 | countryMode.checked =
375 | !countryMode.disabled && (typeof bool === 'undefined' ? !countryMode.checked : bool);
376 | setState({ countryMode: countryMode.checked });
377 | if (state.countryMode) {
378 | backupValue = input.value;
379 | setValue(country.name);
380 | input.select();
381 | search();
382 | } else if (backupValue !== null) {
383 | setValue(backupValue);
384 | backupValue = null;
385 | search();
386 | }
387 | }
388 |
389 | // detect current country with IP
390 | function detectCountry() {
391 | return !!country
392 | ? Promise.resolve(country)
393 | : pk
394 | .reverse({
395 | maxResults: 1,
396 | types: ['country'],
397 | })
398 | .then(({ results }) => {
399 | if (results.length) {
400 | setCountry(results[0]);
401 | return results[0];
402 | }
403 | return null;
404 | })
405 | .catch((err) => fireEvent('error', err));
406 | }
407 |
408 | // Event handlers
409 | // ----------------------------------------
410 | // JS-handled hover state
411 | panel.addEventListener('mousemove', (e) => {
412 | if (!e.movementX && !e.movementY) return;
413 | clearActive();
414 | e.target.closest('[role="option"]')?.classList.add('pka-active');
415 | });
416 |
417 | // preview/apply suggestion
418 | panel.addEventListener('click', (e) => {
419 | const target = e.target.closest('[role="option"]');
420 | if (!target) return;
421 | e.stopPropagation();
422 | const index = Array.from(suggestionsList.children).indexOf(target);
423 | if (e.target.closest('.pka-panel-suggestion-action')) {
424 | // inject suggestion with trailing space and keep typing
425 | const current = suggestions[index];
426 | if (current) {
427 | setValue(`${options.format.value(current)} `, { notify: true });
428 | setState({
429 | dirty: true,
430 | freeForm: false,
431 | });
432 | }
433 | } else {
434 | // apply suggestion
435 | applySuggestion(index);
436 | }
437 | });
438 |
439 | // toggle country mode
440 | countryMode.addEventListener('change', (e) => {
441 | toggleCountryMode(e.target.checked);
442 | });
443 |
444 | // update suggestions as user types
445 | function handleInput(e) {
446 | if (e instanceof InputEvent) {
447 | togglePanel(!!input.value.trim() || state.countryMode);
448 | search();
449 | }
450 | }
451 | input.addEventListener('input', handleInput);
452 |
453 | // open panel on input focus
454 | function handleFocus() {
455 | // trigger search if there's a default value
456 | if (!state.dirty && !!input.value) {
457 | togglePanel(true);
458 | search();
459 | } else {
460 | togglePanel(!!input.value.trim() || state.geolocation || state.countryMode);
461 | }
462 | }
463 | input.addEventListener('click', handleFocus);
464 | input.addEventListener('focus', handleFocus);
465 |
466 | // close panel on click outside
467 | function handleClickOutside(e) {
468 | if (![input, panel].includes(e.target) && !panel.contains(e.target)) {
469 | togglePanel(false);
470 | restoreValue(true);
471 | }
472 | }
473 | window.addEventListener('click', handleClickOutside);
474 |
475 | // keyboard navigation
476 | function handleKeyNav(e) {
477 | if (input === document.activeElement) {
478 | const isPanelOpen = panel.classList.contains('pka-open');
479 | switch (e.key) {
480 | case 'Up':
481 | case 'ArrowUp':
482 | // move through suggestions or open panel with ALT
483 | if (isPanelOpen) {
484 | e.preventDefault();
485 | if (e.altKey) {
486 | togglePanel(false);
487 | } else {
488 | moveActive(-1);
489 | }
490 | }
491 | break;
492 | case 'Down':
493 | case 'ArrowDown':
494 | // move through suggestions or open panel with ALT
495 | if (suggestions.length > 0) {
496 | if (!isPanelOpen) {
497 | e.preventDefault();
498 | togglePanel(true);
499 | } else if (!e.altKey) {
500 | e.preventDefault();
501 | moveActive(1);
502 | }
503 | }
504 | break;
505 | case 'Enter':
506 | // apply selected suggestion
507 | if (isPanelOpen) {
508 | e.preventDefault();
509 | applySuggestion();
510 | }
511 | break;
512 | case 'Esc':
513 | case 'Escape':
514 | // close countryMode, then panel, then clear value (default behaviour)
515 | e.preventDefault();
516 | if (isPanelOpen) {
517 | if (state.countryMode) {
518 | toggleCountryMode(false);
519 | } else {
520 | togglePanel(false);
521 | restoreValue(true);
522 | }
523 | } else {
524 | clearInput();
525 | }
526 | break;
527 | case 'Tab':
528 | togglePanel(false);
529 | break;
530 | }
531 | }
532 | }
533 | window.addEventListener('keydown', handleKeyNav);
534 |
535 | // update suggestions panel size on window resize
536 | function handleResize() {
537 | window.requestAnimationFrame(() => {
538 | panel.style.width = `${input.offsetWidth}px`;
539 | popper.update();
540 | });
541 | }
542 |
543 | handleResize();
544 | const resizeObserver = new ResizeObserver(handleResize);
545 | resizeObserver.observe(input);
546 |
547 | // Configure
548 | // ----------------------------------------
549 | // Update parameters
550 | function configure(opts = {}) {
551 | // deep merge and destructure options
552 | delete opts.target;
553 | /* eslint-disable no-unused-vars */
554 | const {
555 | panel: panelOptions,
556 | format: formatOptions,
557 | countryAutoFill,
558 | countrySelect,
559 | ...pkOptions
560 | } = merge(options, opts);
561 | /* eslint-enable no-unused-vars */
562 |
563 | // update PlaceKit Client config
564 | pk.configure(pkOptions);
565 |
566 | // update panel class and placement
567 | panel.setAttribute('class', `pka-panel ${options.panel.className}`.trim());
568 | popper.setOptions({
569 | placement: 'bottom-start',
570 | strategy: options.panel.strategy,
571 | modifiers: [
572 | {
573 | name: 'flip',
574 | enabled: options.panel.flip,
575 | },
576 | {
577 | name: 'offset',
578 | options: {
579 | offset: [0, options.panel.offset],
580 | },
581 | },
582 | ],
583 | });
584 |
585 | // show country select
586 | const typesStr = options.types?.join(',').toLowerCase() ?? '';
587 | countryMode.disabled = options.countries || !options.countrySelect || typesStr === 'country';
588 | panel.querySelector('.pka-panel-country').setAttribute('aria-hidden', countryMode.disabled);
589 |
590 | // set inner global search mode state
591 | globalSearchMode =
592 | !options.countries &&
593 | !options.countrySelect &&
594 | ['city', 'city,country', 'country,city', 'country'].includes(typesStr);
595 |
596 | // detect current country and inject into input as default value if type is ['country']
597 | if (options.countryAutoFill && typesStr === 'country' && !state.dirty && !input.value.trim()) {
598 | detectCountry().then((item) => {
599 | setValue(item.name, {
600 | notify: true,
601 | focus: false,
602 | });
603 | setState({ freeForm: false });
604 | fireEvent('pick', item.name, item, 0);
605 | });
606 | }
607 | }
608 |
609 | // initialize with instance options
610 | configure(initOptions);
611 |
612 | // Return instance
613 | // ----------------------------------------
614 | // read-only values
615 | Object.defineProperty(client, 'input', {
616 | get: () => input,
617 | });
618 |
619 | Object.defineProperty(client, 'options', {
620 | get: () => ({
621 | target,
622 | ...options,
623 | ...pk.options,
624 | }),
625 | });
626 |
627 | Object.defineProperty(client, 'handlers', {
628 | get: () => Object.fromEntries(handlers),
629 | });
630 |
631 | Object.defineProperty(client, 'state', {
632 | get: () => state,
633 | });
634 |
635 | // manually set input value
636 | client.setValue = (value, notify = false) => {
637 | setValue(value, {
638 | notify,
639 | focus: false,
640 | });
641 | return client;
642 | };
643 |
644 | // clear input value
645 | client.clear = clearInput;
646 |
647 | // register event handler
648 | client.on = (event, handler) => {
649 | if (!isString(event)) {
650 | throw `Error: first argument 'event' must be a string.`;
651 | }
652 | if (typeof handler !== 'undefined' && !handler.call) {
653 | throw `Error: second argument 'handler' must be a function if defined.`;
654 | }
655 | if (handler) {
656 | handlers.set(event, handler);
657 | } else if (handlers.has(event)) {
658 | handlers.delete(event);
659 | }
660 | return client;
661 | };
662 |
663 | // open suggestions panel
664 | client.open = () => {
665 | togglePanel(true);
666 | return client;
667 | };
668 |
669 | // close suggestions panel
670 | client.close = () => {
671 | togglePanel(false);
672 | return client;
673 | };
674 |
675 | // update options
676 | client.configure = (opts = {}) => {
677 | configure(opts);
678 | return client;
679 | };
680 |
681 | // request the device's location
682 | client.requestGeolocation = (opts = {}) => {
683 | return pk
684 | .requestGeolocation(opts)
685 | .then((pos) => {
686 | setState({ geolocation: true }, true);
687 | fireEvent('geolocation', true, pos);
688 | setCountry(null); // force redetect country
689 | input.focus();
690 | search();
691 | return pos;
692 | })
693 | .catch((err) => {
694 | setState({ geolocation: false }, true);
695 | fireEvent('geolocation', false, undefined, err.message);
696 | });
697 | };
698 |
699 | // clear device's location
700 | client.clearGeolocation = () => {
701 | pk.clearGeolocation();
702 | setState({ geolocation: false });
703 | return client;
704 | };
705 |
706 | // unbind events and remove panel
707 | client.destroy = () => {
708 | input.removeEventListener('input', handleInput);
709 | input.removeEventListener('click', handleFocus);
710 | input.removeEventListener('focus', handleFocus);
711 | window.removeEventListener('keydown', handleKeyNav);
712 | window.removeEventListener('click', handleClickOutside);
713 | resizeObserver.unobserve(input);
714 | panel.remove();
715 | };
716 |
717 | return client;
718 | }
719 |
--------------------------------------------------------------------------------