convert SearchControl to typescript

This commit is contained in:
Stephan Meijer
2020-04-15 13:52:16 +02:00
parent 5223c434c7
commit 95b43ee377
12 changed files with 506 additions and 404 deletions

View File

@@ -197,6 +197,8 @@
top: 0;
cursor: pointer;
border: none;
text-decoration: none;
background-color: #fff;
}
.leaflet-control-geosearch a.reset:hover {

2
package-lock.json generated
View File

@@ -17990,7 +17990,7 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.6.0.tgz",
"integrity": "sha512-CPkhyqWUKZKFJ6K8umN5/D2wrJ2+/8UIpXppY7QDnUZW5bZL5+SEI2J7GBpwh4LIupOKqbNSQXgqmrEJopHVNQ==",
"dev": true
"optional": true
},
"leven": {
"version": "3.1.0",

View File

@@ -24,7 +24,7 @@
"scripts": {
"clean": "rimraf ./dist",
"build": "run-s clean \"run-p build:dist build:lib\"",
"build:dist": "microbundle build --external none --format es,cjs,umd",
"build:dist": "microbundle build --external leaflet --format es,cjs,umd",
"build:lib": "tsc",
"build:watch": "npm run build:dist -- --compress false --watch",
"test": "jest",
@@ -77,7 +77,6 @@
"gatsby-plugin-react-leaflet": "^2.0.12",
"jest": "^25.3.0",
"jest-fetch-mock": "^3.0.3",
"leaflet": "^1.6.0",
"microbundle": "^0.12.0-next.8",
"npm-run-all": "^4.1.5",
"prettier": "^2.0.4",
@@ -88,5 +87,8 @@
"ts-jest": "^25.3.1",
"typescript": "^3.8.3"
},
"dependencies": {}
"dependencies": {},
"optionalDependencies": {
"leaflet": "^1.6.0"
}
}

473
src/SearchControl.ts Normal file
View File

@@ -0,0 +1,473 @@
import L, { ControlPosition, FeatureGroup, MarkerOptions, Map } from 'leaflet';
import SearchElement from './SearchElement';
import ResultList from './resultList';
import debounce from './lib/debounce';
import { createElement, addClassName, removeClassName } from './domUtils';
import {
ENTER_KEY,
SPECIAL_KEYS,
ARROW_UP_KEY,
ARROW_DOWN_KEY,
ESCAPE_KEY,
} from './constants';
import AbstractProvider, { SearchResult } from './providers/provider';
const defaultOptions: Omit<SearchControlOptions, 'provider'> = {
position: 'topleft',
style: 'button',
showMarker: true,
showPopup: false,
popupFormat: ({ result }) => `${result.label}`,
marker: {
icon: L && L.Icon ? new L.Icon.Default() : undefined,
draggable: false,
},
maxMarkers: 1,
retainZoomLevel: false,
animateZoom: true,
searchLabel: 'Enter address',
notFoundMessage: 'Sorry, that address could not be found.',
messageHideDelay: 3000,
zoomLevel: 18,
classNames: {
container: 'leaflet-bar leaflet-control leaflet-control-geosearch',
button: 'leaflet-bar-part leaflet-bar-part-single',
resetButton: 'reset',
msgbox: 'leaflet-bar message',
form: '',
input: '',
},
autoComplete: true,
autoCompleteDelay: 250,
autoClose: false,
keepResult: false,
};
const wasHandlerEnabled: { [key in MapHandler]?: boolean } = {};
type MapHandler =
| 'dragging'
| 'touchZoom'
| 'doubleClickZoom'
| 'scrollWheelZoom'
| 'boxZoom'
| 'keyboard';
const mapHandlers: MapHandler[] = [
'dragging',
'touchZoom',
'doubleClickZoom',
'scrollWheelZoom',
'boxZoom',
'keyboard',
];
const UNINITIALIZED_ERR =
'Leaflet must be loaded before instantiating the GeoSearch control';
interface SearchControlOptions {
provider: AbstractProvider;
position: ControlPosition;
style: 'button' | 'bar';
marker: MarkerOptions;
maxMarkers: number;
showMarker: boolean;
showPopup: boolean;
popupFormat<T = any>(args: {
query: Selection;
result: SearchResult<T>;
}): string;
searchLabel: string;
notFoundMessage: string;
messageHideDelay: number;
animateZoom: boolean;
zoomLevel: number;
retainZoomLevel: boolean;
classNames: {
container: string;
button: string;
resetButton: string;
msgbox: string;
form: string;
input: string;
};
autoComplete: boolean;
autoCompleteDelay: number;
autoClose: boolean;
keepResult: boolean;
}
export type SearchControlInputOptions = Partial<SearchControlOptions> & {
provider: AbstractProvider;
};
interface Selection {
query: string;
data?: SearchResult;
}
interface SearchControl {
options: Omit<SearchControlOptions, 'provider'> & {
provider?: SearchControlOptions['provider'];
};
markers: FeatureGroup;
handlersDisabled: boolean;
searchElement: SearchElement;
resultList: ResultList;
classNames: SearchControlOptions['classNames'];
container: HTMLDivElement;
input: HTMLInputElement;
map: Map;
// [key: string]: any;
initialize(options: SearchControlOptions): void;
onSubmit(result: Selection): void;
onClick(event: Event): void;
clearResults(event?: KeyboardEvent | null, force?: boolean): void;
autoSearch(event: KeyboardEvent): void;
selectResult(event: KeyboardEvent): void;
disableHandlers(event: Event): void;
restoreHandlers(event?: Event): void;
showResult(result: SearchResult, query: Selection): void;
addMarker(result: SearchResult, selection: Selection): void;
centerMap(result: SearchResult): void;
closeResults(): void;
getZoom(): number;
onAdd(map: Map): HTMLDivElement;
onRemove(): SearchControl;
}
// @ts-ignore
const Control: SearchControl = {
options: defaultOptions,
handlersDisabled: false,
classNames: defaultOptions.classNames,
initialize(options: SearchControlInputOptions) {
if (!L) {
throw new Error(UNINITIALIZED_ERR);
}
if (!options.provider) {
throw new Error('Provider is missing from options');
}
// merge given options with control defaults
Object.assign(this.options, options);
Object.assign(this.classNames, options.classNames);
this.markers = new L.FeatureGroup();
this.classNames.container += ` geosearch-${this.options.style}`;
this.searchElement = new SearchElement({
handleSubmit: (result) => this.onSubmit(result),
});
const button = createElement<HTMLAnchorElement>(
'a',
this.classNames.button,
this.searchElement.container,
{
title: this.options.searchLabel,
href: '#',
onClick: (e) => this.onClick(e),
},
);
const resetButton = createElement<HTMLAnchorElement>(
'a',
this.classNames.resetButton,
this.searchElement.form,
{
text: 'X',
href: '#',
onClick: () => this.clearResults(null, true),
},
);
if (this.options.autoComplete) {
this.resultList = new ResultList({
handleClick: ({ result }) => {
this.searchElement.input.value = result.label;
this.onSubmit({ query: result.label, data: result });
},
});
this.searchElement.form.appendChild(this.resultList.container);
this.searchElement.input.addEventListener(
'keyup',
debounce(
(e: KeyboardEvent) => this.autoSearch(e),
this.options.autoCompleteDelay,
),
true,
);
this.searchElement.input.addEventListener(
'keydown',
(e: KeyboardEvent) => this.selectResult(e),
true,
);
this.searchElement.input.addEventListener(
'keydown',
(e: KeyboardEvent) => this.clearResults(e, true),
true,
);
}
this.searchElement.form.addEventListener(
'mouseenter',
(e) => this.disableHandlers(e),
true,
);
this.searchElement.form.addEventListener(
'mouseleave',
() => this.restoreHandlers(),
true,
);
this.searchElement.form.addEventListener(
'click',
(e) => e.preventDefault(),
false,
);
},
onAdd(map: Map) {
const { showMarker, style } = this.options;
this.map = map;
if (showMarker) {
this.markers.addTo(map);
}
if (style === 'bar') {
const root = map
.getContainer()
.querySelector('.leaflet-control-container');
this.container = createElement<HTMLDivElement>(
'div',
'leaflet-control-geosearch bar',
);
this.container.appendChild(this.searchElement.form);
root!.appendChild(this.container);
}
return this.searchElement.container;
},
onRemove() {
this.container?.remove();
return this;
},
onClick(event: Event) {
event.preventDefault();
if (this.container.classList.contains('active')) {
removeClassName(this.container, 'active');
this.clearResults();
} else {
addClassName(this.container, 'active');
this.input.focus();
}
},
disableHandlers(event) {
if (!this.searchElement.form.contains(event.target as Node)) {
return;
}
mapHandlers.forEach((handler) => {
wasHandlerEnabled[handler] = this.map[handler]?.enabled();
this.map[handler]?.disable();
});
},
restoreHandlers(event: Event) {
if (event && !this.searchElement.form.includes(event.target as Node)) {
return;
}
mapHandlers.forEach((handler) => {
if (wasHandlerEnabled[handler]) {
this.map[handler]?.enable();
}
});
},
selectResult(event) {
if (
[ENTER_KEY, ARROW_DOWN_KEY, ARROW_UP_KEY].indexOf(event.keyCode) === -1
) {
return;
}
event.preventDefault();
if (event.keyCode === ENTER_KEY) {
const item = this.resultList.select(this.resultList.selected);
this.onSubmit({ query: this.searchElement.input.value, data: item });
return;
}
const max = this.resultList.count() - 1;
if (max < 0) {
return;
}
const { selected } = this.resultList;
const next = event.keyCode === ARROW_DOWN_KEY ? selected + 1 : selected - 1;
const idx = next < 0 ? max : next > max ? 0 : next;
const item = this.resultList.select(idx);
this.searchElement.input.value = item.label;
},
clearResults(event: KeyboardEvent | null, force = false) {
if (event && event.keyCode !== ESCAPE_KEY) {
return;
}
const { keepResult, autoComplete } = this.options;
if (force || !keepResult) {
this.searchElement.input.value = '';
this.markers.clearLayers();
}
if (autoComplete) {
this.resultList.clear();
}
},
async autoSearch(event) {
if (SPECIAL_KEYS.indexOf(event.keyCode) > -1) {
return;
}
const query = (event.target as HTMLInputElement).value;
const { provider } = this.options;
if (query.length) {
const results = await provider!.search({ query });
this.resultList.render(results);
} else {
this.resultList.clear();
}
},
async onSubmit(query) {
const { provider } = this.options;
const results = await provider!.search(query);
if (results && results.length > 0) {
this.showResult(results[0], query);
}
},
showResult(result, query) {
const { autoClose } = this.options;
// @ts-ignore
const markers = Object.keys(this.markers._layers);
if (markers.length >= this.options.maxMarkers) {
// @ts-ignore
this.markers.removeLayer(markers[0]);
}
const marker = this.addMarker(result, query);
this.centerMap(result);
this.map.fireEvent('geosearch/showlocation', {
location: result,
marker,
});
if (autoClose) {
this.closeResults();
}
},
closeResults() {
const { container } = this.searchElement;
if (container.classList.contains('active')) {
removeClassName(container, 'active');
}
this.restoreHandlers();
this.clearResults();
},
addMarker(result, query) {
const { marker: options, showPopup, popupFormat } = this.options;
const marker = new L.Marker([result.y, result.x], options);
let popupLabel = result.label;
if (typeof popupFormat === 'function') {
popupLabel = popupFormat({ query, result });
}
marker.bindPopup(popupLabel);
this.markers.addLayer(marker);
if (showPopup) {
marker.openPopup();
}
if (options.draggable) {
marker.on('dragend', (args) => {
this.map.fireEvent('geosearch/marker/dragend', {
location: marker.getLatLng(),
event: args,
});
});
}
return marker;
},
centerMap(result) {
const { retainZoomLevel, animateZoom } = this.options;
const resultBounds = new L.LatLngBounds(result.bounds);
const bounds = resultBounds.isValid()
? resultBounds
: this.markers.getBounds();
if (!retainZoomLevel && resultBounds.isValid()) {
this.map.fitBounds(bounds, { animate: animateZoom });
} else {
this.map.setView(bounds.getCenter(), this.getZoom(), {
animate: animateZoom,
});
}
},
getZoom(): number {
const { retainZoomLevel, zoomLevel } = this.options;
return retainZoomLevel ? this.map.getZoom() : zoomLevel;
},
};
export default function SearchControl(...options: any[]) {
if (!L) {
throw new Error(UNINITIALIZED_ERR);
}
const LControl = L.Control.extend(Control);
return new LControl(...options);
}

View File

@@ -6,11 +6,12 @@ import {
stopPropagation,
replaceClassName,
} from './domUtils';
import { ESCAPE_KEY, ENTER_KEY } from './constants';
interface SearchElementProps {
searchLabel?: string;
handleSubmit: () => void;
handleSubmit: (args: { query: string }) => void;
classNames?: {
container?: string;
form?: string;
@@ -52,10 +53,10 @@ export default class SearchElement {
type: 'text',
placeholder: searchLabel || 'search',
onInput: this.onInput,
onKeyUp: this.onKeyUp,
onKeyPress: this.onKeyPress,
onFocus: this.onFocus,
onBlur: this.onBlur,
onKeyUp: (e) => this.onKeyUp(e),
onKeyPress: (e) => this.onKeyPress(e),
onFocus: () => this.onFocus(),
onBlur: () => this.onBlur(),
},
);

View File

@@ -1,4 +1,4 @@
import LeafletControl from '../leafletControl';
import SearchControl from '../SearchControl';
import randomId from '../../test/randomId';
function createMapInstance(options, id = randomId()) {
@@ -24,7 +24,7 @@ test('Can add geosearch control to leaflet', () => {
const { div, map } = createMapInstance();
const provider = { search: jest.fn() };
const control = new LeafletControl({
const control = new SearchControl({
provider,
}).addTo(map);
@@ -35,7 +35,7 @@ test('It toggles the active class when the search button is clicked', () => {
const { map } = createMapInstance();
const provider = { search: jest.fn() };
const control = new LeafletControl({
const control = new SearchControl({
provider,
}).addTo(map);
@@ -57,7 +57,7 @@ test('Shows result on submit', async () => {
const provider = { search: jest.fn(async () => result) };
const control = new LeafletControl({
const control = new SearchControl({
provider,
}).addTo(map);
@@ -76,7 +76,7 @@ test('Change view on result', () => {
map.setView = jest.fn();
const control = new LeafletControl({}).addTo(map);
const control = new SearchControl({}).addTo(map);
control.showResult({ x: 50, y: 0 }, { query: 'none' });

View File

@@ -1,4 +1,4 @@
import SearchElement from '../searchElement';
import SearchElement from '../SearchElement';
test('Can localize texts', () => {
const searchLabel = 'Lookup address';

View File

@@ -17,6 +17,10 @@ export function createElement<T extends HTMLElement = HTMLElement>(
? key.substr(2).toLowerCase()
: key) as keyof HTMLElementEventMap;
el.addEventListener(type, attributes[key] as () => void);
} else if (key === 'html') {
el.innerHTML = attributes[key] as string;
} else if (key === 'text') {
el.innerText = attributes[key] as string;
} else {
el.setAttribute(key, attributes[key] as string);
}

View File

@@ -1,5 +1,6 @@
export { default as GeoSearchControl } from './leafletControl';
export { default as SearchElement } from './searchElement';
export { default as GeoSearchControl } from './SearchControl';
export { default as SearchControl } from './SearchControl';
export { default as SearchElement } from './SearchElement';
export { default as BingProvider } from './providers/bingProvider';
export { default as EsriProvider } from './providers/esriProvider';

View File

@@ -1,384 +0,0 @@
import GeoSearchElement from './searchElement';
import ResultList from './resultList';
import debounce from './lib/debounce';
import { createElement, addClassName, removeClassName } from './domUtils';
import {
ENTER_KEY,
SPECIAL_KEYS,
ARROW_UP_KEY,
ARROW_DOWN_KEY,
ESCAPE_KEY,
} from './constants';
const defaultOptions = () => ({
position: 'topleft',
style: 'button',
showMarker: true,
showPopup: false,
popupFormat: ({ result }) => `${result.label}`,
marker: {
icon: new L.Icon.Default(),
draggable: false,
},
maxMarkers: 1,
retainZoomLevel: false,
animateZoom: true,
searchLabel: 'Enter address',
notFoundMessage: 'Sorry, that address could not be found.',
messageHideDelay: 3000,
zoomLevel: 18,
classNames: {
container: 'leaflet-bar leaflet-control leaflet-control-geosearch',
button: 'leaflet-bar-part leaflet-bar-part-single',
resetButton: 'reset',
msgbox: 'leaflet-bar message',
form: '',
input: '',
},
autoComplete: true,
autoCompleteDelay: 250,
autoClose: false,
keepResult: false,
});
const wasHandlerEnabled = {};
const mapHandlers = [
'dragging',
'touchZoom',
'doubleClickZoom',
'scrollWheelZoom',
'boxZoom',
'keyboard',
];
const Control = {
initialize(options) {
this.markers = new L.FeatureGroup();
this.handlersDisabled = false;
this.options = {
...defaultOptions(),
...options,
};
const {
style,
classNames,
searchLabel,
autoComplete,
autoCompleteDelay,
} = this.options;
if (style !== 'button') {
this.options.classNames.container += ` ${options.style}`;
}
this.searchElement = new GeoSearchElement({
...this.options,
handleSubmit: (query) => this.onSubmit(query),
});
const { container, form, input } = this.searchElement.elements;
const button = createElement('a', classNames.button, container);
button.title = searchLabel;
button.href = '#';
button.addEventListener(
'click',
(e) => {
this.onClick(e);
},
false,
);
const resetButton = createElement('a', classNames.resetButton, form);
resetButton.innerHTML = 'X';
button.href = '#';
resetButton.addEventListener(
'click',
() => {
this.clearResults(null, true);
},
false,
);
if (autoComplete) {
this.resultList = new ResultList({
handleClick: ({ result }) => {
input.value = result.label;
this.onSubmit({ query: result.label, data: result });
},
});
form.appendChild(this.resultList.elements.container);
input.addEventListener(
'keyup',
debounce((e) => this.autoSearch(e), autoCompleteDelay),
true,
);
input.addEventListener('keydown', (e) => this.selectResult(e), true);
input.addEventListener(
'keydown',
(e) => this.clearResults(e, true),
true,
);
}
form.addEventListener('mouseenter', (e) => this.disableHandlers(e), true);
form.addEventListener('mouseleave', (e) => this.restoreHandlers(e), true);
form.addEventListener('click', (e) => e.preventDefault(), false);
this.elements = { button, resetButton };
},
onAdd(map) {
const { showMarker, style } = this.options;
this.map = map;
if (showMarker) {
this.markers.addTo(map);
}
if (style === 'bar') {
const { form } = this.searchElement.elements;
const root = map
.getContainer()
.querySelector('.leaflet-control-container');
const container = createElement('div', 'leaflet-control-geosearch bar');
container.appendChild(form);
root.appendChild(container);
this.elements.container = container;
}
return this.searchElement.elements.container;
},
onRemove() {
const { container } = this.elements;
if (container) {
container.remove();
}
return this;
},
onClick(event) {
event.preventDefault();
const { container, input } = this.searchElement.elements;
if (container.classList.contains('active')) {
removeClassName(container, 'active');
this.clearResults();
} else {
addClassName(container, 'active');
input.focus();
}
},
disableHandlers(e) {
const { form } = this.searchElement.elements;
if (this.handlersDisabled || (e && e.target !== form)) {
return;
}
this.handlersDisabled = true;
mapHandlers.forEach((handler) => {
if (this.map[handler]) {
wasHandlerEnabled[handler] = this.map[handler].enabled();
this.map[handler].disable();
}
});
},
restoreHandlers(e) {
const { form } = this.searchElement.elements;
if (!this.handlersDisabled || (e && e.target !== form)) {
return;
}
this.handlersDisabled = false;
mapHandlers.forEach((handler) => {
if (wasHandlerEnabled[handler]) {
this.map[handler].enable();
}
});
},
selectResult(event) {
if (
[ENTER_KEY, ARROW_DOWN_KEY, ARROW_UP_KEY].indexOf(event.keyCode) === -1
) {
return;
}
event.preventDefault();
const { input } = this.searchElement.elements;
const list = this.resultList;
if (event.keyCode === ENTER_KEY) {
const item = list.select(list.selected);
this.onSubmit({ query: input.value, data: item });
return;
}
const max = list.count() - 1;
if (max < 0) {
return;
}
// eslint-disable-next-line no-bitwise
const next =
event.code === 'ArrowDown' ? ~~list.selected + 1 : ~~list.selected - 1;
// eslint-disable-next-line no-nested-ternary
const idx = next < 0 ? max : next > max ? 0 : next;
const item = list.select(idx);
input.value = item.label;
},
clearResults(event, force = false) {
if (event && event.keyCode !== ESCAPE_KEY) {
return;
}
const { input } = this.searchElement.elements;
const { keepResult, autoComplete } = this.options;
if (force || !keepResult) {
input.value = '';
this.markers.clearLayers();
}
if (autoComplete) {
this.resultList.clear();
}
},
async autoSearch(event) {
if (SPECIAL_KEYS.indexOf(event.keyCode) > -1) {
return;
}
const query = event.target.value;
const { provider } = this.options;
if (query.length) {
const results = await provider.search({ query });
this.resultList.render(results);
} else {
this.resultList.clear();
}
},
async onSubmit(query) {
const { provider } = this.options;
const results = await provider.search(query);
if (results && results.length > 0) {
this.showResult(results[0], query);
}
},
showResult(result, { query }) {
const { autoClose } = this.options;
const markers = Object.keys(this.markers._layers);
if (markers.length >= this.options.maxMarkers) {
this.markers.removeLayer(markers[0]);
}
const marker = this.addMarker(result, query);
this.centerMap(result);
this.map.fireEvent('geosearch/showlocation', {
location: result,
marker,
});
if (autoClose) {
this.closeResults();
}
},
closeResults() {
const { container } = this.searchElement.elements;
if (container.classList.contains('active')) {
removeClassName(container, 'active');
}
this.restoreHandlers();
this.clearResults();
},
addMarker(result, query) {
const { marker: options, showPopup, popupFormat } = this.options;
const marker = new L.Marker([result.y, result.x], options);
let popupLabel = result.label;
if (typeof popupFormat === 'function') {
popupLabel = popupFormat({ query, result });
}
marker.bindPopup(popupLabel);
this.markers.addLayer(marker);
if (showPopup) {
marker.openPopup();
}
if (options.draggable) {
marker.on('dragend', (args) => {
this.map.fireEvent('geosearch/marker/dragend', {
location: marker.getLatLng(),
event: args,
});
});
}
return marker;
},
centerMap(result) {
const { retainZoomLevel, animateZoom } = this.options;
const resultBounds = new L.LatLngBounds(result.bounds);
const bounds = resultBounds.isValid()
? resultBounds
: this.markers.getBounds();
if (!retainZoomLevel && resultBounds.isValid()) {
this.map.fitBounds(bounds, { animate: animateZoom });
} else {
this.map.setView(bounds.getCenter(), this.getZoom(), {
animate: animateZoom,
});
}
},
getZoom() {
const { retainZoomLevel, zoomLevel } = this.options;
return retainZoomLevel ? this.map.getZoom() : zoomLevel;
},
};
export default function LeafletControl(...options) {
if (!L || !L.Control || !L.Control.extend) {
throw new Error(
'Leaflet must be loaded before instantiating the GeoSearch control',
);
}
const LControl = L.Control.extend(Control);
return new LControl(...options);
}

View File

@@ -49,8 +49,10 @@ export interface Provider<TRequestResult, TRawResult> {
search(options: SearchArgument): Promise<SearchResult<TRawResult>[]>;
}
export default abstract class AbstractProvider<TRequestResult, TRawResult>
implements Provider<TRequestResult, TRawResult> {
export default abstract class AbstractProvider<
TRequestResult = any,
TRawResult = any
> implements Provider<TRequestResult, TRawResult> {
options: ProviderOptions;
constructor(options: ProviderOptions = {}) {

View File

@@ -2,7 +2,7 @@ import { createElement, addClassName, removeClassName, cx } from './domUtils';
import { SearchResult } from './providers/provider';
interface ResultListProps {
handleClick: () => void;
handleClick: (args: { result: SearchResult }) => void;
classNames?: {
container?: string;
item?: string;
@@ -76,6 +76,7 @@ export default class ResultList {
if (typeof this.handleClick !== 'function') {
return;
}
const target = event.target as HTMLDivElement;
if (
!target ||