UNPKG

@ideal-postcodes/address-finder

Version:

Address Finder JS library backed by the Ideal Postcodes UK address search API

917 lines (916 loc) 31.5 kB
/** * @module Controller */ /* eslint-disable no-invalid-this */ import { announcer } from "./announcer"; import debounce from "lodash/debounce"; import { toContextMap, defaultContexts, toContextList, } from "./contexts"; import { ApiCache } from "./cache"; import { addStyle, computeOffset } from "./css"; import { Client, checkKeyUsability } from "@ideal-postcodes/core-axios"; import { InterpreterStatus } from "@xstate/fsm"; import { create } from "./state"; import { getScope, setStyle, show, toKey, update, toHtmlElem, getDocument, hide, remove, restoreStyle, isString, populateAddress, idGen, } from "@ideal-postcodes/jsutil"; /** * @hidden */ export const NOOP = () => { }; /** * Default options assigned to controller instances */ export const defaults = { // DOM outputScope: null, // Client apiKey: "", checkKey: true, // WAI-ARIA compliance settings aria: "1.0", // Behaviour titleizePostTown: true, format: "gbr", outputFields: {}, names: {}, labels: {}, removeOrganisation: false, injectStyle: true, inputField: "", autocomplete: "none", populateCounty: true, populateOrganisation: true, queryOptions: {}, resolveOptions: {}, alignToInput: true, offset: 2, hideToolbar: false, detectCountry: true, // Country defaultCountry: "GBR", restrictCountries: [], contexts: defaultContexts, // Messages msgFallback: "Please enter your address manually", msgInitial: "Start typing to find address", msgNoMatch: "No matches found", msgList: "Select your address", msgCountryToggle: "Change Country", // Placeholder Messages msgPlaceholder: "Type the first line or postal code of your address", msgPlaceholderCountry: "Select your country", // View classes messageClass: "idpc_error", containerClass: "idpc_autocomplete", mainClass: "idpc_af", listClass: "idpc_ul", toolbarClass: "idpc_toolbar", countryToggleClass: "idpc_country", // Syles mainStyle: {}, inputStyle: {}, listStyle: {}, liStyle: {}, containerStyle: {}, // Hide / unhide unhide: null, unhideClass: "idpc-unhide", msgUnhide: "Enter address manually", hide: [], //change list position fixed: false, // Callbacks onOpen: NOOP, onSelect: NOOP, onBlur: NOOP, onClose: NOOP, onFocus: NOOP, onInput: NOOP, onLoaded: NOOP, onSearchError: NOOP, onSuggestionError: NOOP, onMounted: NOOP, onRemove: NOOP, onSuggestionsRetrieved: NOOP, onAddressSelected: NOOP, onAddressRetrieved: NOOP, onAddressPopulated: NOOP, onFailedCheck: NOOP, onMouseDown: NOOP, onKeyDown: NOOP, onUnhide: NOOP, onCountrySelected: NOOP, onContextChange: NOOP, }; /** * # Controller * * The Autocomplete Controller class acts as the public class which you may * wield to enable address autocomplete on your HTML address forms * * When instantiated, the controller will serve as a bridge beteen the * address suggestion view presented on the DOM and the Ideal * Postcodes Address resolution HTTP APIs * * The role of the controller is to bind to events produced by the user * interface and take appropriate action including querying the API, * modifying other aspects of the DOM. */ export class Controller { constructor(options) { this.options = { ...{ scope: window.document, document: window.document }, ...defaults, ...options, }; // Default inputField to line_1 if `inputField` not specified if (!options.inputField) this.options.inputField = this.options.outputFields.line_1 || ""; // To overcome config overload - idpcConfig global config object already // defines autocomplete (boolean) //@ts-ignore if (this.options.autocomplete === true) this.options.autocomplete = defaults.autocomplete; // Scope the operations of this controller to a document or DOM subtree this.scope = getScope(this.options.scope); // Assign a parent Document for elem creation this.document = getDocument(this.scope); // Assign a document or DOM subtree to scope outputs. Defaults to controller scope this.outputScope = findOrCreate(this.scope, this.options.outputScope, () => this.scope); // Initialise state this.context = this.options.defaultCountry; this.notification = this.options.msgInitial; this.current = -1; this.suggestions = []; this.contextSuggestions = []; this.updateContexts(this.options.contexts); this.client = new Client({ ...this.options, api_key: this.options.apiKey }); this.cache = new ApiCache(this.client); this.retrieveSuggestions = debounce((event) => { this.options.onInput.call(this, event); const query = this.query(); if (query.trim().length === 0) { this.setMessage(this.options.msgInitial); return Promise.resolve(this); } return this.cache .query(query, { ...this.options.queryOptions, context: this.context, }) .then((suggestions) => { this.options.onSuggestionsRetrieved.call(this, suggestions); return this.setSuggestions(suggestions, query); }) .catch((error) => { if (this.query() === query) this.setMessage(this.options.msgFallback); this.options.onSuggestionError.call(this, error); return this; }); }, 100, { leading: true, trailing: true, maxWait: 100, }); this.ids = idGen("idpcaf"); // Configure container this.container = this.options.document.createElement("div"); this.container.className = this.options.containerClass; this.container.id = this.ids(); this.container.setAttribute("aria-haspopup", "listbox"); // Create message element this.message = this.options.document.createElement("li"); this.message.textContent = this.options.msgInitial; this.message.className = this.options.messageClass; // Create button to toggle country selection this.countryToggle = this.options.document.createElement("span"); this.countryToggle.className = this.options.countryToggleClass; this.countryToggle.addEventListener("mousedown", _onCountryToggle(this)); this.countryIcon = this.options.document.createElement("span"); this.countryIcon.className = "idpc_icon"; this.countryIcon.innerText = this.currentContext().emoji; this.countryMessage = this.options.document.createElement("span"); this.countryMessage.innerText = "Select Country"; this.countryMessage.className = "idpc_country"; this.countryToggle.appendChild(this.countryMessage); this.countryToggle.appendChild(this.countryIcon); // Create toolbar (for country selection) this.toolbar = this.options.document.createElement("div"); this.toolbar.className = this.options.toolbarClass; this.toolbar.appendChild(this.countryToggle); if (this.options.hideToolbar) hide(this.toolbar); // Configure UL this.list = this.options.document.createElement("ul"); this.list.className = this.options.listClass; this.list.id = this.ids(); this.list.setAttribute("aria-label", this.options.msgList); this.list.setAttribute("role", "listbox"); this.mainComponent = this.options.document.createElement("div"); this.mainComponent.appendChild(this.list); this.mainComponent.appendChild(this.toolbar); this.mainComponent.className = this.options.mainClass; hide(this.mainComponent); //configure unhide this.unhideEvent = this.unhideFields.bind(this); this.unhide = this.createUnhide(); // Configure input let input; if (isString(this.options.inputField)) { input = this.scope.querySelector(this.options.inputField); } else { input = this.options.inputField; } if (!input) throw new Error("Address Finder: Unable to find valid input field"); this.input = input; this.input.setAttribute("autocomplete", this.options.autocomplete); this.input.setAttribute("aria-autocomplete", "list"); this.input.setAttribute("aria-controls", this.list.id); this.input.setAttribute("aria-autocomplete", "list"); this.input.setAttribute("aria-activedescendant", ""); this.input.setAttribute("autocorrect", "off"); this.input.setAttribute("autocapitalize", "off"); this.input.setAttribute("spellcheck", "false"); if (!this.input.id) this.input.id = this.ids(); const countryInput = this.scope.querySelector(this.options.outputFields.country); this.countryInput = countryInput; // Apply additional accessibility improvments this.ariaAnchor().setAttribute("role", "combobox"); this.ariaAnchor().setAttribute("aria-expanded", "false"); this.ariaAnchor().setAttribute("aria-owns", this.list.id); this.placeholderCache = this.input.placeholder; // Create listeners this.inputListener = _onInput(this); this.blurListener = _onBlur(this); this.focusListener = _onFocus(this); this.keydownListener = _onKeyDown(this); this.countryListener = _onCountryChange(this); const { container, announce } = announcer({ idA: this.ids(), idB: this.ids(), document: this.options.document, }); this.announce = announce; this.alerts = container; this.inputStyle = setStyle(this.input, this.options.inputStyle); setStyle(this.container, this.options.containerStyle); setStyle(this.list, this.options.listStyle); // Apply an offset based off any margin const offset = computeOffset(this); setStyle(this.mainComponent, { ...offset, ...this.options.mainStyle, }); this.fsm = create({ c: this }); this.init(); } /** * Sets placeholder and caches previous result * @hidden */ setPlaceholder(msg) { this.input.placeholder = msg; } /** * Unsets any placeholder value to original * @hidden */ unsetPlaceholder() { if (this.placeholderCache === undefined) return this.input.removeAttribute("placeholder"); this.input.placeholder = this.placeholderCache; } /** * Returns current highlighted context * @hidden */ currentContext() { const c = this.options.contexts[this.context]; if (c) return c; const first = Object.keys(this.options.contexts)[0]; return this.options.contexts[first]; } /** * Binds to DOM and begin DOM mutations * @hidden */ load() { this.attach(); addStyle(this); if (this.options.fixed) { //set position fixed and width calculation setPositionFixed(this.mainComponent, this.container, this.document); } this.options.onLoaded.call(this); //fix for safari scrollbar that close the list this.list.parentNode?.addEventListener("mousedown", (e) => e.preventDefault()); } /** * Attaches Controller to the DOM. * * If `checkKey` is enabled, a key check will be performed prioer to binding. Use the `onLoaded` and `onFailedCheck` callbacks to define follow up behaviour if the key check succeeds or fails */ init() { return new Promise((resolve) => { if (!this.options.checkKey) { this.load(); resolve(); return; } checkKeyUsability({ client: this.client, api_key: this.options.apiKey }) .then((response) => { if (!response.available) throw new Error("Key currently not usable"); this.updateContexts( // TODO: Remove cast when openapi updated toContextMap(response.contexts)); // Methods to apply context // 1. If detect country enabled and match, if no match // 2. Apply default context, if no match // 3. Apply first item of context list // If detect country enabled, set country to default const details = this.options.contexts[response.context]; if (this.options.detectCountry && details) { this.applyContext(details, false); } else { this.applyContext(this.currentContext(), false); } this.load(); resolve(); }) .catch((error) => { this.options.onFailedCheck.call(this, error); resolve(); }); }); } // Updates lists of available contexts updateContexts(contexts) { this.contextSuggestions = toContextList(contexts, this.options.restrictCountries); this.options.contexts = contexts; } filteredContexts() { const q = this.query(); if (q.trim().length === 0) return this.contextSuggestions; const f = q.toLowerCase().trim().replace(/\s+/g, " "); const regexp = new RegExp("^" + f); return this.contextSuggestions.filter((e) => { if (regexp.test(e.description.toLowerCase())) return true; if (e.iso_2.toLowerCase() === f) return true; if (e.iso_3.toLowerCase() === f) return true; return false; }); } /** * Render available country options */ renderContexts() { this.list.innerHTML = ""; this.filteredContexts().forEach((contextDetails, i) => { const { description } = contextDetails; const li = this.options.document.createElement("li"); li.textContent = description; li.setAttribute("aria-selected", "false"); li.setAttribute("tabindex", "-1"); li.setAttribute("aria-posinset", `${i + 1}`); li.setAttribute("aria-setsize", this.contextSuggestions.length.toString()); li.setAttribute("role", "option"); setStyle(li, this.options.liStyle); li.addEventListener("mousedown", (e) => { e.preventDefault(); this.options.onMouseDown.call(this, e); this.fsm.send({ type: "SELECT_COUNTRY", contextDetails }); }); li.id = `${this.list.id}_${i}`; this.list.appendChild(li); }); this.announce(`${this.contextSuggestions.length} countries available`); } /** * Render current address suggestions */ renderSuggestions() { this.list.innerHTML = ""; const s = this.suggestions; s.forEach((suggestion, i) => { const li = this.options.document.createElement("li"); li.textContent = suggestion.suggestion; li.setAttribute("aria-selected", "false"); li.setAttribute("tabindex", "-1"); li.setAttribute("title", suggestion.suggestion); li.setAttribute("aria-posinset", `${i + 1}`); li.setAttribute("aria-setsize", s.length.toString()); li.setAttribute("role", "option"); setStyle(li, this.options.liStyle); li.addEventListener("mousedown", (e) => { e.preventDefault(); this.options.onMouseDown.call(this, e); this.fsm.send({ type: "SELECT_ADDRESS", suggestion }); }); li.id = `${this.list.id}_${i}`; this.list.appendChild(li); }); this.announce(`${s.length} addresses available`); } /** * Updates current li in list to active descendant */ goToCurrent() { const lis = this.list.children; this.input.setAttribute("aria-activedescendant", ""); for (let i = 0; i < lis.length; i += 1) { if (i === this.current) { this.input.setAttribute("aria-activedescendant", lis[i].id); lis[i].setAttribute("aria-selected", "true"); this.goto(i); } else { lis[i].setAttribute("aria-selected", "false"); } } } /** * Marks aria component as opened */ ariaExpand() { this.ariaAnchor().setAttribute("aria-expanded", "true"); } /** * Marks aria component as closed */ ariaContract() { this.ariaAnchor().setAttribute("aria-expanded", "false"); } /** * Resolves a suggestion to full address and apply results to form */ applySuggestion(suggestion) { this.options.onSelect.call(this, suggestion); this.options.onAddressSelected.call(this, suggestion); this.announce(`The address ${suggestion.suggestion} has been applied to this form`); return this.cache .resolve(suggestion, this.options.format, this.options.resolveOptions) .then((address) => { if (address === null) throw "Unable to retrieve address"; this.options.onAddressRetrieved.call(this, address); this.populateAddress(address); return this; }) .catch((error) => { this.open(); this.setMessage(this.options.msgFallback); this.options.onSearchError.call(this, error); return error; }); } /** * Writes a selected to the input fields specified in the controller config */ populateAddress(address) { this.unhideFields(); populateAddress({ address, config: { ...this.options, scope: this.outputScope }, outputFields: this.options.outputFields, names: this.options.names, labels: this.options.labels, }); this.options.onAddressPopulated.call(this, address); } /** * Applies new query options to search. This process clears the existing * cache to prevent stale searches */ setQueryOptions(options) { this.cache.clear(); this.options.queryOptions = options; } /** * Applies new query options to search. This process clears the existing * cache to prevent stale searches */ setResolveOptions(options) { this.cache.clear(); this.options.resolveOptions = options; } /** * Adds Address Finder to DOM * - Wraps input with container * - Appends suggestion list to container * - Enables listeners * - Starts FSM */ attach() { if (this.fsm.status === InterpreterStatus.Running) return this; this.input.addEventListener("input", this.inputListener); this.input.addEventListener("blur", this.blurListener); this.input.addEventListener("focus", this.focusListener); this.input.addEventListener("keydown", this.keydownListener); if (this.countryInput) this.countryInput.addEventListener("change", this.countryListener); const parent = this.input.parentNode; if (parent) { // Wrap input in a div and append suggestion list parent.insertBefore(this.container, this.input); this.container.appendChild(this.input); this.container.appendChild(this.mainComponent); this.container.appendChild(this.alerts); if (this.options.hide.length > 0 && this.options.unhide == null) this.container.appendChild(this.unhide); } this.fsm.start(); this.options.onMounted.call(this); this.hideFields(); return this; } /** * Removes Address Finder from DOM * - Disable listeners * - Removes sugestion list from container * - Appends suggestion list to container * - Enables listeners * - Stops FSM */ detach() { if (this.fsm.status !== InterpreterStatus.Running) return this; this.input.removeEventListener("input", this.inputListener); this.input.removeEventListener("blur", this.blurListener); this.input.removeEventListener("focus", this.focusListener); this.input.removeEventListener("keydown", this.keydownListener); if (this.countryInput) this.countryInput.removeEventListener("change", this.countryListener); this.container.removeChild(this.mainComponent); this.container.removeChild(this.alerts); const parent = this.container.parentNode; if (parent) { parent.insertBefore(this.input, this.container); parent.removeChild(this.container); } this.unmountUnhide(); this.unhideFields(); this.fsm.stop(); restoreStyle(this.input, this.inputStyle); this.options.onRemove.call(this); this.unsetPlaceholder(); return this; } /** * Sets message as a list item, no or empty string removes any message */ setMessage(notification) { this.fsm.send({ type: "NOTIFY", notification }); return this; } /** * Returns HTML Element which recevies key aria attributes * * @hidden */ ariaAnchor() { if (this.options.aria === "1.0") return this.input; return this.container; } /** * Returns current address query */ query() { return this.input.value; } clearInput() { update(this.input, ""); } /** * Set address finder suggestions */ setSuggestions(suggestions, query) { if (query !== this.query()) return this; if (suggestions.length === 0) return this.setMessage(this.options.msgNoMatch); this.fsm.send({ type: "SUGGEST", suggestions }); return this; } /** * Close address finder */ close(reason = "blur") { hide(this.mainComponent); if (reason === "esc") update(this.input, ""); this.options.onClose.call(this, reason); } /** * Updates suggestions and resets current selection * @hidden */ updateSuggestions(s) { this.suggestions = s; this.current = -1; } /** * Applies context to API cache * @hidden */ applyContext(details, announce = true) { const context = details.iso_3; this.context = context; this.cache.clear(); this.countryIcon.innerText = details.emoji; if (announce) this.announce(`Country switched to ${details.description}`); this.options.onContextChange.call(this, context); } /** * Renders notification box * @hidden */ renderNotice() { this.list.innerHTML = ""; this.input.setAttribute("aria-activedescendant", ""); this.message.textContent = this.notification; this.announce(this.notification); this.list.appendChild(this.message); } /** * Open address finder * @hidden */ open() { show(this.mainComponent); this.options.onOpen.call(this); } /** * Sets next suggestion as current * @hidden */ next() { if (this.current + 1 > this.list.children.length - 1) { // Goes over edge of list and back to start this.current = 0; } else { this.current += 1; } return this; } /** * Sets previous suggestion as current * @hidden */ previous() { if (this.current - 1 < 0) { this.current = this.list.children.length - 1; // Wrap to last elem } else { this.current += -1; } return this; } /** * Given a HTMLLiElement, scroll parent until it is in view * @hidden */ scrollToView(li) { const liOffset = li.offsetTop; const ulScrollTop = this.list.scrollTop; if (liOffset < ulScrollTop) { this.list.scrollTop = liOffset; } const ulHeight = this.list.clientHeight; const liHeight = li.clientHeight; if (liOffset + liHeight > ulScrollTop + ulHeight) { this.list.scrollTop = liOffset - ulHeight + liHeight; } return this; } /** * Moves currently selected li into view * @hidden */ goto(i) { const lis = this.list.children; const suggestion = lis[i]; if (i > -1 && lis.length > 0) { this.scrollToView(suggestion); } else { this.scrollToView(lis[0]); } return this; } /** * Returns true if address finder is open */ opened() { return !this.closed(); } /** * Returs false if address finder is closed */ closed() { return this.fsm.state.matches("closed"); } /** * Creates a clickable element that can trigger unhiding of fields */ createUnhide() { const e = findOrCreate(this.scope, this.options.unhide, () => { const e = this.options.document.createElement("p"); e.innerText = this.options.msgUnhide; e.setAttribute("role", "button"); e.setAttribute("tabindex", "0"); if (this.options.unhideClass) e.className = this.options.unhideClass; return e; }); e.addEventListener("click", this.unhideEvent); return e; } /** * Removes unhide elem from DOM */ unmountUnhide() { this.unhide.removeEventListener("click", this.unhideEvent); if (this.options.unhide == null && this.options.hide.length) remove(this.unhide); } hiddenFields() { return this.options.hide .map((e) => { if (isString(e)) return toHtmlElem(this.options.scope, e); return e; }) .filter((e) => e !== null); } /** * Hides fields marked for hiding */ hideFields() { this.hiddenFields().forEach(hide); } /** * Unhides fields marked for hiding */ unhideFields() { this.hiddenFields().forEach(show); this.options.onUnhide.call(this); } } /** * Event handler: Fires when focus moves away from input field * @hidden */ const _onBlur = (c) => function () { c.options.onBlur.call(c); c.fsm.send({ type: "CLOSE", reason: "blur" }); }; /** * Event handler: Fires when input field is focused * @hidden */ const _onFocus = (c) => function (_) { c.options.onFocus.call(c); c.fsm.send("AWAKE"); }; /** * Event handler: Fires when input is detected on input field * @hidden */ const _onInput = (c) => function (event) { if (c.query().toLowerCase() === ":c") { update(c.input, ""); return c.fsm.send({ type: "CHANGE_COUNTRY" }); } c.fsm.send({ type: "INPUT", event }); }; /** * Event handler: Fires when country selection is clicked * Triggers: * - Country selection menu * * @hidden */ const _onCountryToggle = (c) => function (e) { e.preventDefault(); c.fsm.send({ type: "CHANGE_COUNTRY" }); }; /** * Event handler: Fires on "keyDown" event of search field * @hidden */ export const _onKeyDown = (c) => function (event) { // Dispatch events based on keys const key = toKey(event); if (key === "Enter") event.preventDefault(); c.options.onKeyDown.call(c, event); if (c.closed()) return c.fsm.send("AWAKE"); // When suggesting country if (c.fsm.state.matches("suggesting_country")) { if (key === "Enter") { const contextDetails = c.filteredContexts()[c.current]; if (contextDetails) c.fsm.send({ type: "SELECT_COUNTRY", contextDetails }); } if (key === "Backspace") c.fsm.send({ type: "INPUT", event }); if (key === "ArrowUp") { event.preventDefault(); c.fsm.send("PREVIOUS"); } if (key === "ArrowDown") { event.preventDefault(); c.fsm.send("NEXT"); } } // When suggesting address if (c.fsm.state.matches("suggesting")) { if (key === "Enter") { const suggestion = c.suggestions[c.current]; if (suggestion) c.fsm.send({ type: "SELECT_ADDRESS", suggestion }); } if (key === "Backspace") c.fsm.send({ type: "INPUT", event }); if (key === "ArrowUp") { event.preventDefault(); c.fsm.send("PREVIOUS"); } if (key === "ArrowDown") { event.preventDefault(); c.fsm.send("NEXT"); } } if (key === "Escape") c.fsm.send({ type: "CLOSE", reason: "esc" }); if (key === "Home") c.fsm.send({ type: "RESET" }); if (key === "End") c.fsm.send({ type: "RESET" }); }; // Event handler: Fires when country selection is changed export const _onCountryChange = (c) => function (event) { if (event.target === null) return; const target = event.target; if (!target) return; let contextDetails = findMatchingContext(target.value, c.options.contexts); c.fsm.send({ type: "COUNTRY_CHANGE_EVENT", contextDetails }); }; /** * Retrieve Element * - If string, assumes is valid and returns first match within scope * - If null, invokes the create method to return a default * - If HTMLElement returns instance * @hidden */ export const findOrCreate = (scope, q, create) => { if (isString(q)) return scope.querySelector(q); if (create && q === null) return create(); return q; }; export const setPositionFixed = (mainComponent, container, document) => { const setMinWith = (scope, component) => { if (scope === null) return; const box = scope.getBoundingClientRect(); component.style.minWidth = `${Math.round(box.width)}px`; }; const parent = container.parentElement; mainComponent.style.position = "fixed"; mainComponent.style.left = "auto"; setMinWith(parent, mainComponent); document.defaultView !== null && document.defaultView.addEventListener("resize", () => { setMinWith(parent, mainComponent); }); }; const findMatchingContext = (name, contexts) => { const n = name.toUpperCase(); for (const context of Object.values(contexts)) { if (context.iso_3 === n) return context; if (context.iso_2 === n) return context; if (context.description.toUpperCase() === n) return context; } return undefined; };