├── .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 | [![NPM](https://img.shields.io/npm/v/@placekit/autocomplete-js?style=flat-square)](https://www.npmjs.com/package/@placekit/autocomplete-js?activeTab=readme) [![LICENSE](https://img.shields.io/github/license/placekit/autocomplete-js?style=flat-square)](./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 |

Format options

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 | `${countrycode}`, 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 | 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 |
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 | --------------------------------------------------------------------------------