@pro6pp/infer-react 0.0.2-beta.8 → 0.1.0-beta.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,17 +3,120 @@ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { en
3
3
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
4
 
5
5
  // src/index.tsx
6
- import React, { useState, useMemo, useEffect, useRef } from "react";
6
+ import React, {
7
+ useState,
8
+ useMemo,
9
+ useEffect,
10
+ useRef,
11
+ forwardRef,
12
+ useImperativeHandle
13
+ } from "react";
14
+
15
+ // ../core/src/label-formatter.ts
16
+ function normalize(str) {
17
+ return str.toLowerCase().trim();
18
+ }
19
+ function escapeRegex(str) {
20
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
+ }
22
+ function findWordPosition(query, value) {
23
+ const normalizedQuery = normalize(query);
24
+ const normalizedValue = normalize(value);
25
+ if (normalizedValue.includes(" ")) {
26
+ return normalizedQuery.indexOf(normalizedValue);
27
+ }
28
+ const pattern = new RegExp(`(?:^|[,\\s])${escapeRegex(normalizedValue)}(?:$|[,\\s])`, "g");
29
+ const match = pattern.exec(normalizedQuery);
30
+ if (match) {
31
+ const matchStart = match.index;
32
+ const firstChar = normalizedQuery[matchStart];
33
+ if (firstChar === "," || firstChar === " ") {
34
+ return matchStart + 1;
35
+ }
36
+ return matchStart;
37
+ }
38
+ return -1;
39
+ }
40
+ function detectComponentOrder(query, value) {
41
+ const detected = [];
42
+ const componentMap = [];
43
+ if (value.street) {
44
+ componentMap.push({ value: value.street, type: "street" });
45
+ }
46
+ if (value.city) {
47
+ componentMap.push({ value: value.city, type: "city" });
48
+ }
49
+ if (value.postcode) {
50
+ componentMap.push({ value: value.postcode, type: "postcode" });
51
+ }
52
+ if (value.street_number !== void 0 && value.street_number !== null) {
53
+ componentMap.push({ value: String(value.street_number), type: "street_number" });
54
+ }
55
+ if (value.addition) {
56
+ componentMap.push({ value: value.addition, type: "addition" });
57
+ }
58
+ for (const comp of componentMap) {
59
+ const position = findWordPosition(query, comp.value);
60
+ if (position !== -1) {
61
+ detected.push({
62
+ type: comp.type,
63
+ value: comp.value,
64
+ position
65
+ });
66
+ }
67
+ }
68
+ detected.sort((a, b) => a.position - b.position);
69
+ return detected;
70
+ }
71
+ function formatLabelByInputOrder(query, value) {
72
+ if (!value || !query) {
73
+ return "";
74
+ }
75
+ const detectedOrder = detectComponentOrder(query, value);
76
+ const detectedTypes = new Set(detectedOrder.map((d) => d.type));
77
+ const parts = [];
78
+ for (const detected of detectedOrder) {
79
+ parts.push(detected.value);
80
+ }
81
+ const defaultOrder = ["street", "street_number", "addition", "postcode", "city"];
82
+ for (const type of defaultOrder) {
83
+ if (detectedTypes.has(type)) continue;
84
+ let val;
85
+ switch (type) {
86
+ case "street":
87
+ val = value.street;
88
+ break;
89
+ case "city":
90
+ val = value.city;
91
+ break;
92
+ case "street_number":
93
+ val = value.street_number !== void 0 ? String(value.street_number) : void 0;
94
+ break;
95
+ case "postcode":
96
+ val = value.postcode;
97
+ break;
98
+ case "addition":
99
+ val = value.addition;
100
+ break;
101
+ }
102
+ if (val) {
103
+ parts.push(val);
104
+ }
105
+ }
106
+ return parts.join(", ");
107
+ }
7
108
 
8
109
  // ../core/src/core.ts
9
110
  var DEFAULTS = {
10
111
  API_URL: "https://api.pro6pp.nl/v2",
11
- LIMIT: 1e3,
112
+ LIMIT: 20,
12
113
  DEBOUNCE_MS: 150,
13
- MIN_DEBOUNCE_MS: 50
114
+ MIN_DEBOUNCE_MS: 50,
115
+ MAX_RETRIES: 0
14
116
  };
15
117
  var PATTERNS = {
16
- DIGITS_1_3: /^[0-9]{1,3}$/
118
+ DIGITS_1_3: /^[0-9]{1,3}$/,
119
+ STREET_NUMBER_PREFIX: /^(\d+)\s*,\s*$/
17
120
  };
18
121
  var INITIAL_STATE = {
19
122
  query: "",
@@ -22,8 +125,10 @@ var INITIAL_STATE = {
22
125
  streets: [],
23
126
  suggestions: [],
24
127
  isValid: false,
128
+ value: null,
25
129
  isError: false,
26
130
  isLoading: false,
131
+ hasMore: false,
27
132
  selectedSuggestionIndex: -1
28
133
  };
29
134
  var InferCore = class {
@@ -34,8 +139,11 @@ var InferCore = class {
34
139
  constructor(config) {
35
140
  __publicField(this, "country");
36
141
  __publicField(this, "authKey");
37
- __publicField(this, "apiUrl");
38
- __publicField(this, "limit");
142
+ __publicField(this, "explicitApiUrl");
143
+ __publicField(this, "baseLimit");
144
+ __publicField(this, "currentLimit");
145
+ __publicField(this, "maxRetries");
146
+ __publicField(this, "language");
39
147
  __publicField(this, "fetcher");
40
148
  __publicField(this, "onStateChange");
41
149
  __publicField(this, "onSelect");
@@ -46,11 +154,14 @@ var InferCore = class {
46
154
  __publicField(this, "state");
47
155
  __publicField(this, "abortController", null);
48
156
  __publicField(this, "debouncedFetch");
49
- __publicField(this, "isSelecting", false);
50
157
  this.country = config.country;
51
158
  this.authKey = config.authKey;
52
- this.apiUrl = config.apiUrl || DEFAULTS.API_URL;
53
- this.limit = config.limit || DEFAULTS.LIMIT;
159
+ this.explicitApiUrl = config.apiUrl;
160
+ this.baseLimit = config.limit || DEFAULTS.LIMIT;
161
+ this.currentLimit = this.baseLimit;
162
+ this.language = config.language;
163
+ const configRetries = config.maxRetries !== void 0 ? config.maxRetries : DEFAULTS.MAX_RETRIES;
164
+ this.maxRetries = Math.max(0, Math.min(configRetries, 10));
54
165
  this.fetcher = config.fetcher || ((url, init) => fetch(url, init));
55
166
  this.onStateChange = config.onStateChange || (() => {
56
167
  });
@@ -67,28 +178,37 @@ var InferCore = class {
67
178
  * @param value The raw string from the input field.
68
179
  */
69
180
  handleInput(value) {
70
- if (this.isSelecting) {
71
- this.isSelecting = false;
72
- return;
73
- }
181
+ this.currentLimit = this.baseLimit;
74
182
  const isEditingFinal = this.state.stage === "final" && value !== this.state.query;
75
183
  this.updateState({
76
184
  query: value,
77
185
  isValid: false,
186
+ value: null,
78
187
  isLoading: !!value.trim(),
79
- selectedSuggestionIndex: -1
188
+ selectedSuggestionIndex: -1,
189
+ hasMore: false,
190
+ stage: isEditingFinal ? null : this.state.stage
80
191
  });
81
192
  if (isEditingFinal) {
82
193
  this.onSelect(null);
83
194
  }
84
195
  this.debouncedFetch(value);
85
196
  }
197
+ /**
198
+ * Increases the current limit and re-fetches the query to show more results.
199
+ */
200
+ loadMore() {
201
+ if (this.state.isLoading) return;
202
+ this.currentLimit += this.baseLimit;
203
+ this.updateState({ isLoading: true });
204
+ this.executeFetch(this.state.query);
205
+ }
86
206
  /**
87
207
  * Handles keyboard events for the input field.
88
208
  * Supports:
89
209
  * - `ArrowUp`/`ArrowDown`: Navigate through the suggestion list.
90
210
  * - `Enter`: Select the currently highlighted suggestion.
91
- * - `Space`: Automatically inserts a comma if a numeric house number is detected.
211
+ * - `Space`: Automatically inserts a comma if a numeric street number is detected.
92
212
  * @param event The keyboard event from the input element.
93
213
  */
94
214
  handleKeyDown(event) {
@@ -136,6 +256,7 @@ var InferCore = class {
136
256
  * Manually selects a suggestion or a string value.
137
257
  * This is typically called when a user clicks a suggestion in the UI.
138
258
  * @param item The suggestion object or string to select.
259
+ * @returns boolean True if the selection is a final address.
139
260
  */
140
261
  selectItem(item) {
141
262
  this.debouncedFetch.cancel();
@@ -149,26 +270,27 @@ var InferCore = class {
149
270
  }
150
271
  const valueObj = typeof item !== "string" && typeof item.value === "object" ? item.value : void 0;
151
272
  const isFullResult = !!valueObj && Object.keys(valueObj).length > 0;
152
- this.isSelecting = true;
153
273
  if (this.state.stage === "final" || isFullResult) {
154
274
  let finalQuery = label;
155
275
  if (valueObj && Object.keys(valueObj).length > 0) {
156
- const { street, street_number, house_number, city } = valueObj;
157
- const number = street_number || house_number;
158
- if (street && number && city) {
159
- finalQuery = `${street} ${number}, ${city}`;
276
+ const { street, street_number, postcode, city, addition } = valueObj;
277
+ if (street && street_number && city) {
278
+ const suffix = addition ? ` ${addition}` : "";
279
+ const postcodeStr = postcode ? `${postcode}, ` : "";
280
+ finalQuery = `${street}, ${street_number}${suffix}, ${postcodeStr}${city}`;
160
281
  }
161
282
  }
162
283
  this.finishSelection(finalQuery, valueObj);
163
- return;
284
+ return true;
164
285
  }
165
286
  const subtitle = typeof item !== "string" ? item.subtitle : null;
166
287
  this.processSelection(logicValue, subtitle);
288
+ return false;
167
289
  }
168
290
  shouldAutoInsertComma(currentVal) {
169
291
  const isStartOfSegmentAndNumeric = !currentVal.includes(",") && PATTERNS.DIGITS_1_3.test(currentVal.trim());
170
292
  if (isStartOfSegmentAndNumeric) return true;
171
- if (this.state.stage === "house_number") {
293
+ if (this.state.stage === "street_number") {
172
294
  const currentFragment = this.getCurrentFragment(currentVal);
173
295
  return PATTERNS.DIGITS_1_3.test(currentFragment);
174
296
  }
@@ -181,12 +303,11 @@ var InferCore = class {
181
303
  cities: [],
182
304
  streets: [],
183
305
  isValid: true,
184
- stage: "final"
306
+ value: value || null,
307
+ stage: "final",
308
+ hasMore: false
185
309
  });
186
310
  this.onSelect(value || label);
187
- setTimeout(() => {
188
- this.isSelecting = false;
189
- }, 0);
190
311
  }
191
312
  processSelection(text, subtitle) {
192
313
  const { stage, query } = this.state;
@@ -197,7 +318,22 @@ var InferCore = class {
197
318
  nextQuery = `${subtitle}, ${text}, `;
198
319
  } else {
199
320
  const prefix = this.getQueryPrefix(query);
200
- nextQuery = prefix ? `${prefix} ${text}, ${subtitle}, ` : `${text}, ${subtitle}, `;
321
+ const shouldAddSubtitle = !prefix || !prefix.includes(subtitle);
322
+ let effectivePrefix = prefix;
323
+ if (prefix && subtitle) {
324
+ const prefixNumMatch = prefix.match(PATTERNS.STREET_NUMBER_PREFIX);
325
+ if (prefixNumMatch) {
326
+ const num = prefixNumMatch[1];
327
+ if (subtitle.startsWith(num)) {
328
+ effectivePrefix = "";
329
+ }
330
+ }
331
+ }
332
+ if (shouldAddSubtitle) {
333
+ nextQuery = effectivePrefix ? `${effectivePrefix} ${text}, ${subtitle}, ` : `${text}, ${subtitle}, `;
334
+ } else {
335
+ nextQuery = effectivePrefix ? `${effectivePrefix} ${text}, ` : `${text}, `;
336
+ }
201
337
  }
202
338
  this.updateQueryAndFetch(nextQuery);
203
339
  return;
@@ -207,50 +343,78 @@ var InferCore = class {
207
343
  return;
208
344
  }
209
345
  const hasComma = query.includes(",");
210
- const isFirstSegment = !hasComma && (stage === "city" || stage === "street" || stage === "house_number_first");
346
+ const isFirstSegment = !hasComma && (stage === "city" || stage === "street" || stage === "street_number_first");
211
347
  if (isFirstSegment) {
212
348
  nextQuery = `${text}, `;
213
349
  } else {
214
350
  nextQuery = this.replaceLastSegment(query, text);
215
- if (stage !== "house_number") {
351
+ if (stage !== "street_number") {
216
352
  nextQuery += ", ";
217
353
  }
218
354
  }
219
355
  this.updateQueryAndFetch(nextQuery);
220
356
  }
221
- executeFetch(val) {
357
+ executeFetch(val, attempt = 0) {
222
358
  const text = (val || "").toString();
223
359
  if (!text.trim()) {
224
360
  this.abortController?.abort();
225
361
  this.resetState();
226
362
  return;
227
363
  }
228
- this.updateState({ isError: false });
229
- if (this.abortController) this.abortController.abort();
230
- this.abortController = new AbortController();
231
- const url = new URL(`${this.apiUrl}/infer/${this.country.toLowerCase()}`);
232
- const params = {
233
- authKey: this.authKey,
364
+ if (attempt === 0) {
365
+ this.updateState({ isError: false });
366
+ if (this.abortController) this.abortController.abort();
367
+ this.abortController = new AbortController();
368
+ }
369
+ const currentSignal = this.abortController?.signal;
370
+ const baseUrl = this.explicitApiUrl ? this.explicitApiUrl : `${DEFAULTS.API_URL}/infer/${this.country.toLowerCase()}`;
371
+ const params = new URLSearchParams({
234
372
  query: text,
235
- limit: this.limit.toString()
236
- };
237
- url.search = new URLSearchParams(params).toString();
238
- this.fetcher(url.toString(), { signal: this.abortController.signal }).then((res) => {
239
- if (!res.ok) throw new Error("Network error");
373
+ limit: this.currentLimit.toString()
374
+ });
375
+ if (this.explicitApiUrl) {
376
+ params.append("country", this.country.toLowerCase());
377
+ }
378
+ if (this.authKey) {
379
+ params.set("authKey", this.authKey);
380
+ }
381
+ if (this.language) {
382
+ params.set("language", this.language);
383
+ }
384
+ const separator = baseUrl.includes("?") ? "&" : "?";
385
+ const finalUrl = `${baseUrl}${separator}${params.toString()}`;
386
+ this.fetcher(finalUrl, { signal: currentSignal }).then((res) => {
387
+ if (!res.ok) {
388
+ if (attempt < this.maxRetries && (res.status >= 500 || res.status === 429)) {
389
+ return this.retry(val, attempt, currentSignal);
390
+ }
391
+ throw new Error("Network error");
392
+ }
240
393
  return res.json();
241
- }).then((data) => this.mapResponseToState(data)).catch((e) => {
242
- if (e.name !== "AbortError") {
243
- this.updateState({ isError: true, isLoading: false });
394
+ }).then((data) => {
395
+ if (data) this.mapResponseToState(data);
396
+ }).catch((e) => {
397
+ if (e.name === "AbortError") return;
398
+ if (attempt < this.maxRetries) {
399
+ return this.retry(val, attempt, currentSignal);
244
400
  }
401
+ this.updateState({ isError: true, isLoading: false });
245
402
  });
246
403
  }
404
+ retry(val, attempt, signal) {
405
+ if (signal?.aborted) return;
406
+ const delay = Math.pow(2, attempt) * 200;
407
+ setTimeout(() => {
408
+ if (!signal?.aborted) {
409
+ this.executeFetch(val, attempt + 1);
410
+ }
411
+ }, delay);
412
+ }
247
413
  mapResponseToState(data) {
248
414
  const newState = {
249
415
  stage: data.stage,
250
416
  isLoading: false
251
417
  };
252
- let autoSelect = false;
253
- let autoSelectItem = null;
254
418
  const rawSuggestions = data.suggestions || [];
255
419
  const uniqueSuggestions = [];
256
420
  const seen = /* @__PURE__ */ new Set();
@@ -258,43 +422,62 @@ var InferCore = class {
258
422
  const key = `${item.label}|${item.subtitle || ""}|${JSON.stringify(item.value || {})}`;
259
423
  if (!seen.has(key)) {
260
424
  seen.add(key);
261
- uniqueSuggestions.push(item);
425
+ const reformattedItem = this.reformatSuggestionLabel(item);
426
+ uniqueSuggestions.push(reformattedItem);
262
427
  }
263
428
  }
429
+ const totalCount = uniqueSuggestions.length + (data.cities?.length || 0) + (data.streets?.length || 0);
430
+ newState.hasMore = totalCount >= this.currentLimit;
264
431
  if (data.stage === "mixed") {
265
432
  newState.cities = data.cities || [];
266
433
  newState.streets = data.streets || [];
267
- newState.suggestions = [];
434
+ if (newState.cities?.length === 0 && newState.streets?.length === 0) {
435
+ newState.suggestions = uniqueSuggestions;
436
+ } else {
437
+ newState.suggestions = [];
438
+ }
268
439
  } else {
269
440
  newState.suggestions = uniqueSuggestions;
270
441
  newState.cities = [];
271
442
  newState.streets = [];
272
- if (data.stage === "final" && uniqueSuggestions.length === 1) {
273
- autoSelect = true;
274
- autoSelectItem = uniqueSuggestions[0];
275
- }
276
443
  }
277
444
  newState.isValid = data.stage === "final";
278
- if (autoSelect && autoSelectItem) {
279
- newState.query = autoSelectItem.label;
280
- newState.suggestions = [];
281
- newState.cities = [];
282
- newState.streets = [];
283
- newState.isValid = true;
284
- this.updateState(newState);
285
- const val = typeof autoSelectItem.value === "object" ? autoSelectItem.value : autoSelectItem.label;
286
- this.onSelect(val);
287
- } else {
288
- this.updateState(newState);
445
+ this.updateState(newState);
446
+ if (newState.isValid && uniqueSuggestions.length === 1) {
447
+ this.selectItem(uniqueSuggestions[0]);
289
448
  }
290
449
  }
450
+ /**
451
+ * Reformats a suggestion's label based on the user's input order.
452
+ * If the suggestion has a structured value object, we reorder the label
453
+ * to match how the user typed the components.
454
+ */
455
+ reformatSuggestionLabel(item) {
456
+ if (!item.value || typeof item.value === "string") {
457
+ return item;
458
+ }
459
+ const addressValue = item.value;
460
+ if (!addressValue.street || !addressValue.city) {
461
+ return item;
462
+ }
463
+ const reformattedLabel = formatLabelByInputOrder(this.state.query, addressValue);
464
+ if (reformattedLabel) {
465
+ return { ...item, label: reformattedLabel };
466
+ }
467
+ return item;
468
+ }
291
469
  updateQueryAndFetch(nextQuery) {
292
- this.updateState({ query: nextQuery, suggestions: [], cities: [], streets: [] });
293
- this.updateState({ isLoading: true, isValid: false });
470
+ this.updateState({
471
+ query: nextQuery,
472
+ suggestions: [],
473
+ cities: [],
474
+ streets: [],
475
+ isValid: false,
476
+ value: null,
477
+ isLoading: true,
478
+ hasMore: false
479
+ });
294
480
  this.debouncedFetch(nextQuery);
295
- setTimeout(() => {
296
- this.isSelecting = false;
297
- }, 0);
298
481
  }
299
482
  replaceLastSegment(fullText, newSegment) {
300
483
  const lastCommaIndex = fullText.lastIndexOf(",");
@@ -331,6 +514,49 @@ var InferCore = class {
331
514
  }
332
515
  };
333
516
 
517
+ // ../core/src/highlight.ts
518
+ function mergeSegments(segments) {
519
+ if (segments.length === 0) return segments;
520
+ const merged = [];
521
+ for (const seg of segments) {
522
+ const last = merged[merged.length - 1];
523
+ if (last && last.match === seg.match) {
524
+ last.text += seg.text;
525
+ } else {
526
+ merged.push({ text: seg.text, match: seg.match });
527
+ }
528
+ }
529
+ return merged;
530
+ }
531
+ function getHighlightSegments(text, query) {
532
+ if (!query || !text) return [{ text, match: false }];
533
+ const segments = [];
534
+ const normalizedText = text.toLowerCase();
535
+ const normalizedQuery = query.toLowerCase();
536
+ let queryCursor = 0;
537
+ let unmatchedCursor = 0;
538
+ for (let textCursor = 0; textCursor < text.length; textCursor++) {
539
+ const isMatch = queryCursor < query.length && normalizedText[textCursor] === normalizedQuery[queryCursor];
540
+ if (!isMatch) continue;
541
+ const hasPrecedingUnmatched = textCursor > unmatchedCursor;
542
+ if (hasPrecedingUnmatched) {
543
+ segments.push({ text: text.slice(unmatchedCursor, textCursor), match: false });
544
+ }
545
+ segments.push({ text: text[textCursor], match: true });
546
+ queryCursor++;
547
+ unmatchedCursor = textCursor + 1;
548
+ }
549
+ const hasRemainingText = unmatchedCursor < text.length;
550
+ if (hasRemainingText) {
551
+ segments.push({ text: text.slice(unmatchedCursor), match: false });
552
+ }
553
+ const isFullMatch = queryCursor === query.length;
554
+ if (!isFullMatch) {
555
+ return [{ text, match: false }];
556
+ }
557
+ return mergeSegments(segments);
558
+ }
559
+
334
560
  // ../core/src/default-styles.ts
335
561
  var DEFAULT_STYLES = `
336
562
  .pro6pp-wrapper {
@@ -338,116 +564,276 @@ var DEFAULT_STYLES = `
338
564
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
339
565
  box-sizing: border-box;
340
566
  width: 100%;
567
+ -webkit-tap-highlight-color: transparent;
341
568
  }
342
569
  .pro6pp-wrapper * {
343
570
  box-sizing: border-box;
344
571
  }
345
572
  .pro6pp-input {
346
573
  width: 100%;
347
- padding: 10px 12px;
574
+ padding: 12px 14px;
575
+ padding-right: 48px;
348
576
  border: 1px solid #e0e0e0;
349
- border-radius: 4px;
577
+ border-radius: 8px;
350
578
  font-size: 16px;
351
579
  line-height: 1.5;
580
+ appearance: none;
352
581
  transition: border-color 0.2s, box-shadow 0.2s;
353
582
  }
583
+
584
+ .pro6pp-input::placeholder {
585
+ font-size: 16px;
586
+ color: #a3a3a3;
587
+ }
588
+
354
589
  .pro6pp-input:focus {
355
590
  outline: none;
356
591
  border-color: #3b82f6;
357
592
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
358
593
  }
594
+
595
+ .pro6pp-input-addons {
596
+ position: absolute;
597
+ right: 4px;
598
+ top: 0;
599
+ bottom: 0;
600
+ display: flex;
601
+ align-items: center;
602
+ pointer-events: none;
603
+ }
604
+ .pro6pp-input-addons > * {
605
+ pointer-events: auto;
606
+ }
607
+
608
+ .pro6pp-clear-button {
609
+ background: none;
610
+ border: none;
611
+ width: 32px;
612
+ height: 32px;
613
+ cursor: pointer;
614
+ color: #a3a3a3;
615
+ display: flex;
616
+ align-items: center;
617
+ justify-content: center;
618
+ border-radius: 50%;
619
+ transition: color 0.2s, background-color 0.2s;
620
+ touch-action: manipulation;
621
+ }
622
+
623
+ @media (hover: hover) {
624
+ .pro6pp-clear-button:hover {
625
+ color: #1f2937;
626
+ background-color: #f3f4f6;
627
+ }
628
+ }
629
+
630
+ .pro6pp-clear-button:active {
631
+ background-color: #f3f4f6;
632
+ }
633
+
359
634
  .pro6pp-dropdown {
360
635
  position: absolute;
361
636
  top: 100%;
362
637
  left: 0;
363
638
  right: 0;
364
- z-index: 9999;
365
639
  margin-top: 4px;
366
- background: white;
367
- border: 1px solid #e0e0e0;
368
- border-radius: 4px;
369
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
370
- max-height: 300px;
640
+ background: #ffffff;
641
+ border: 1px solid #e5e7eb;
642
+ border-radius: 6px;
643
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
644
+ z-index: 9999;
645
+ padding: 0;
646
+ max-height: 280px;
371
647
  overflow-y: auto;
372
- list-style: none !important;
373
- padding: 0 !important;
374
- margin: 0 !important;
648
+ display: flex;
649
+ flex-direction: column;
650
+ }
651
+
652
+ @media (max-height: 500px) {
653
+ .pro6pp-dropdown {
654
+ max-height: 180px;
655
+ }
375
656
  }
657
+
658
+ .pro6pp-list {
659
+ list-style: none;
660
+ margin: 0;
661
+ padding: 0;
662
+ width: 100%;
663
+ }
664
+
376
665
  .pro6pp-item {
377
- padding: 10px 16px;
666
+ padding: 12px 14px;
378
667
  cursor: pointer;
379
668
  display: flex;
380
- flex-direction: row;
381
669
  align-items: center;
382
- color: #000000;
383
- font-size: 14px;
384
- line-height: 1.2;
385
- white-space: nowrap;
386
- overflow: hidden;
670
+ font-size: 15px;
671
+ line-height: 1.4;
672
+ color: #374151;
673
+ border-bottom: 1px solid #f3f4f6;
674
+ transition: background-color 0.1s;
675
+ flex-shrink: 0;
387
676
  }
388
- .pro6pp-item:hover, .pro6pp-item--active {
389
- background-color: #f5f5f5;
677
+
678
+ .pro6pp-item:last-child {
679
+ border-bottom: none;
390
680
  }
391
- .pro6pp-item__label {
392
- font-weight: 500;
393
- flex-shrink: 0;
681
+
682
+ @media (hover: hover) {
683
+ .pro6pp-item:hover, .pro6pp-item--active {
684
+ background-color: #f9fafb;
685
+ }
394
686
  }
395
- .pro6pp-item__subtitle {
396
- font-size: 14px;
397
- color: #404040;
687
+
688
+ .pro6pp-item:active {
689
+ background-color: #f3f4f6;
690
+ }
691
+
692
+ .pro6pp-item__label {
693
+ font-weight: 400;
694
+ flex-shrink: 1;
398
695
  overflow: hidden;
399
696
  text-overflow: ellipsis;
400
- flex-shrink: 1;
697
+ white-space: nowrap;
698
+ }
699
+
700
+ .pro6pp-item__label--match {
701
+ font-weight: 520;
702
+ }
703
+
704
+ .pro6pp-item__label--unmatched {
705
+ font-weight: 400;
706
+ color: #4b5563;
707
+ }
708
+
709
+ .pro6pp-item__subtitle {
710
+ color: #6b7280;
711
+ flex-shrink: 0;
401
712
  }
713
+
402
714
  .pro6pp-item__chevron {
403
- margin-left: auto;
715
+ color: #d1d5db;
404
716
  display: flex;
405
717
  align-items: center;
406
- color: #a3a3a3;
718
+ margin-left: auto;
407
719
  padding-left: 8px;
408
720
  }
721
+
409
722
  .pro6pp-no-results {
410
- padding: 12px;
411
- color: #555555;
412
- font-size: 14px;
723
+ padding: 24px 16px;
724
+ color: #6b7280;
725
+ font-size: 15px;
413
726
  text-align: center;
414
- user-select: none;
415
- pointer-events: none;
416
727
  }
417
- .pro6pp-loader {
418
- position: absolute;
419
- right: 12px;
420
- top: 50%;
421
- transform: translateY(-50%);
422
- width: 16px;
423
- height: 16px;
424
- border: 2px solid #e0e0e0;
425
- border-top-color: #404040;
728
+
729
+ .pro6pp-loader-item {
730
+ padding: 10px 12px;
731
+ color: #6b7280;
732
+ font-size: 0.875rem;
733
+ display: flex;
734
+ align-items: center;
735
+ justify-content: center;
736
+ gap: 8px;
737
+ background-color: #f9fafb;
738
+ border-top: 1px solid #f3f4f6;
739
+ }
740
+
741
+ .pro6pp-mini-spinner {
742
+ width: 14px;
743
+ height: 14px;
744
+ border: 2px solid #e5e7eb;
745
+ border-top-color: #6b7280;
426
746
  border-radius: 50%;
427
747
  animation: pro6pp-spin 0.6s linear infinite;
428
- pointer-events: none;
429
748
  }
749
+
750
+ @media (max-width: 640px) {
751
+ .pro6pp-input {
752
+ font-size: 16px;
753
+ padding: 10px 12px;
754
+ }
755
+ .pro6pp-item {
756
+ padding: 10px 12px;
757
+ font-size: 14px;
758
+ }
759
+ }
760
+
430
761
  @keyframes pro6pp-spin {
431
- to { transform: translateY(-50%) rotate(360deg); }
762
+ to { transform: rotate(360deg); }
432
763
  }
433
764
  `;
434
765
 
435
766
  // src/index.tsx
767
+ var HighlightedText = ({ text, query }) => {
768
+ const segments = useMemo(() => getHighlightSegments(text, query), [text, query]);
769
+ return /* @__PURE__ */ React.createElement("span", { className: "pro6pp-item__label" }, segments.map(
770
+ (seg, i) => seg.match ? /* @__PURE__ */ React.createElement("span", { key: i, className: "pro6pp-item__label--match" }, seg.text) : /* @__PURE__ */ React.createElement("span", { key: i, className: "pro6pp-item__label--unmatched" }, seg.text)
771
+ ));
772
+ };
436
773
  function useInfer(config) {
437
- const [state, setState] = useState(INITIAL_STATE);
774
+ const [state, setState] = useState(() => {
775
+ if (config.initialValue) {
776
+ const suffix = config.initialValue.addition ? ` ${config.initialValue.addition}` : "";
777
+ const postcodeStr = config.initialValue.postcode ? `${config.initialValue.postcode}, ` : "";
778
+ return {
779
+ ...INITIAL_STATE,
780
+ value: config.initialValue,
781
+ query: `${config.initialValue.street}, ${config.initialValue.street_number}${suffix}, ${postcodeStr}${config.initialValue.city}`,
782
+ isValid: true,
783
+ stage: "final"
784
+ };
785
+ }
786
+ return INITIAL_STATE;
787
+ });
788
+ const callbacksRef = useRef({
789
+ onStateChange: config.onStateChange,
790
+ onSelect: config.onSelect
791
+ });
792
+ useEffect(() => {
793
+ callbacksRef.current = {
794
+ onStateChange: config.onStateChange,
795
+ onSelect: config.onSelect
796
+ };
797
+ }, [config.onStateChange, config.onSelect]);
438
798
  const core = useMemo(() => {
439
- return new InferCore({
799
+ const instance = new InferCore({
440
800
  ...config,
441
801
  onStateChange: (newState) => {
442
802
  setState({ ...newState });
443
- if (config.onStateChange) {
444
- config.onStateChange(newState);
445
- }
803
+ callbacksRef.current.onStateChange?.(newState);
804
+ },
805
+ onSelect: (selection) => {
806
+ callbacksRef.current.onSelect?.(selection);
446
807
  }
447
808
  });
448
- }, [config.country, config.authKey, config.limit, config.debounceMs]);
809
+ if (config.initialValue) {
810
+ const address = config.initialValue;
811
+ const suffix = address.addition ? ` ${address.addition}` : "";
812
+ const postcodeStr = address.postcode ? `${address.postcode}, ` : "";
813
+ const label = `${address.street}, ${address.street_number}${suffix}, ${postcodeStr}${address.city}`;
814
+ instance.selectItem({ label, value: address });
815
+ }
816
+ return instance;
817
+ }, [
818
+ config.country,
819
+ config.authKey,
820
+ config.apiUrl,
821
+ config.fetcher,
822
+ config.limit,
823
+ config.debounceMs,
824
+ config.maxRetries,
825
+ config.initialValue,
826
+ config.language
827
+ ]);
828
+ const setValue = (address) => {
829
+ if (!address) return;
830
+ const suffix = address.addition ? ` ${address.addition}` : "";
831
+ const postcodeStr = address.postcode ? `${address.postcode}, ` : "";
832
+ const label = `${address.street}, ${address.street_number}${suffix}, ${postcodeStr}${address.city}`;
833
+ core.selectItem({ label, value: address });
834
+ };
449
835
  return {
450
- /** The current UI state (suggestions, loading status, query, etc.). */
836
+ /** The current UI state (suggestions, loading status, query, value, etc.). */
451
837
  state,
452
838
  /** The raw InferCore instance for manual control. */
453
839
  core,
@@ -457,95 +843,125 @@ function useInfer(config) {
457
843
  onChange: (e) => core.handleInput(e.target.value),
458
844
  onKeyDown: (e) => core.handleKeyDown(e)
459
845
  },
460
- /** Function to manually select a specific suggestion. */
461
- selectItem: (item) => core.selectItem(item)
846
+ /** Manually select a specific suggestion. */
847
+ selectItem: (item) => core.selectItem(item),
848
+ /** Programmatically set the address value. */
849
+ setValue,
850
+ /** Load more results. */
851
+ loadMore: () => core.loadMore()
462
852
  };
463
853
  }
464
- var Pro6PPInfer = ({
465
- className,
466
- style,
467
- inputProps,
468
- placeholder = "Start typing an address...",
469
- renderItem,
470
- disableDefaultStyles = false,
471
- noResultsText = "No results found",
472
- renderNoResults,
473
- ...config
474
- }) => {
475
- const { state, selectItem, inputProps: coreInputProps } = useInfer(config);
476
- const [isOpen, setIsOpen] = useState(false);
477
- const inputRef = useRef(null);
478
- const wrapperRef = useRef(null);
479
- useEffect(() => {
480
- if (disableDefaultStyles) return;
481
- const styleId = "pro6pp-styles";
482
- if (!document.getElementById(styleId)) {
483
- const styleEl = document.createElement("style");
484
- styleEl.id = styleId;
485
- styleEl.textContent = DEFAULT_STYLES;
486
- document.head.appendChild(styleEl);
487
- }
488
- }, [disableDefaultStyles]);
489
- useEffect(() => {
490
- const handleClickOutside = (event) => {
491
- if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
854
+ var Pro6PPInfer = forwardRef(
855
+ ({
856
+ className,
857
+ style,
858
+ inputProps,
859
+ placeholder = "Start typing an address...",
860
+ renderItem,
861
+ disableDefaultStyles = false,
862
+ noResultsText = "No results found",
863
+ loadingText = "Loading more...",
864
+ renderNoResults,
865
+ showClearButton = true,
866
+ ...config
867
+ }, ref) => {
868
+ const { state, selectItem, loadMore, inputProps: coreInputProps, core } = useInfer(config);
869
+ const [isOpen, setIsOpen] = useState(false);
870
+ const internalInputRef = useRef(null);
871
+ const wrapperRef = useRef(null);
872
+ const observerTarget = useRef(null);
873
+ useImperativeHandle(ref, () => internalInputRef.current);
874
+ useEffect(() => {
875
+ if (disableDefaultStyles) return;
876
+ const styleId = "pro6pp-styles";
877
+ if (!document.getElementById(styleId)) {
878
+ const styleEl = document.createElement("style");
879
+ styleEl.id = styleId;
880
+ styleEl.textContent = DEFAULT_STYLES;
881
+ document.head.appendChild(styleEl);
882
+ }
883
+ }, [disableDefaultStyles]);
884
+ useEffect(() => {
885
+ const handleClickOutside = (event) => {
886
+ if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
887
+ setIsOpen(false);
888
+ }
889
+ };
890
+ document.addEventListener("mousedown", handleClickOutside);
891
+ return () => document.removeEventListener("mousedown", handleClickOutside);
892
+ }, []);
893
+ useEffect(() => {
894
+ const currentTarget = observerTarget.current;
895
+ if (!currentTarget) return;
896
+ const observer = new IntersectionObserver(
897
+ (entries) => {
898
+ if (entries[0].isIntersecting && state.hasMore && !state.isLoading) {
899
+ loadMore();
900
+ }
901
+ },
902
+ { threshold: 0.1 }
903
+ );
904
+ observer.observe(currentTarget);
905
+ return () => {
906
+ if (currentTarget) observer.unobserve(currentTarget);
907
+ };
908
+ }, [state.hasMore, state.isLoading, loadMore, isOpen]);
909
+ const items = useMemo(() => {
910
+ return [
911
+ ...state.cities.map((c) => ({ ...c, type: "city" })),
912
+ ...state.streets.map((s) => ({ ...s, type: "street" })),
913
+ ...state.suggestions.map((s) => ({ ...s, type: "suggestion" }))
914
+ ];
915
+ }, [state.cities, state.streets, state.suggestions]);
916
+ const handleSelect = (item) => {
917
+ const isFinal = selectItem(item);
918
+ if (!isFinal) {
919
+ setTimeout(() => internalInputRef.current?.focus(), 0);
920
+ } else {
492
921
  setIsOpen(false);
493
922
  }
494
923
  };
495
- document.addEventListener("mousedown", handleClickOutside);
496
- return () => document.removeEventListener("mousedown", handleClickOutside);
497
- }, []);
498
- const items = useMemo(() => {
499
- return [
500
- ...state.cities.map((c) => ({ ...c, type: "city" })),
501
- ...state.streets.map((s) => ({ ...s, type: "street" })),
502
- ...state.suggestions.map((s) => ({ ...s, type: "suggestion" }))
503
- ];
504
- }, [state.cities, state.streets, state.suggestions]);
505
- const handleSelect = (item) => {
506
- selectItem(item);
507
- setIsOpen(false);
508
- if (!state.isValid && inputRef.current) {
509
- inputRef.current.focus();
510
- }
511
- };
512
- const hasResults = items.length > 0;
513
- const showNoResults = !state.isLoading && !state.isError && state.query.length > 0 && !hasResults && !state.isValid;
514
- const showDropdown = isOpen && (hasResults || showNoResults);
515
- return /* @__PURE__ */ React.createElement("div", { ref: wrapperRef, className: `pro6pp-wrapper ${className || ""}`, style }, /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(
516
- "input",
517
- {
518
- ref: inputRef,
519
- type: "text",
520
- className: "pro6pp-input",
521
- placeholder,
522
- autoComplete: "off",
523
- ...inputProps,
524
- ...coreInputProps,
525
- onFocus: (e) => {
526
- setIsOpen(true);
527
- inputProps?.onFocus?.(e);
924
+ const handleClear = () => {
925
+ core.handleInput("");
926
+ internalInputRef.current?.focus();
927
+ };
928
+ const hasResults = items.length > 0;
929
+ const showNoResults = !state.isLoading && !state.isError && state.query.length > 0 && !hasResults && !state.isValid;
930
+ const showDropdown = isOpen && (hasResults || state.isLoading || showNoResults);
931
+ const isInfiniteLoading = state.isLoading && items.length > 0;
932
+ return /* @__PURE__ */ React.createElement("div", { ref: wrapperRef, className: `pro6pp-wrapper ${className || ""}`, style }, /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(
933
+ "input",
934
+ {
935
+ ref: internalInputRef,
936
+ type: "text",
937
+ className: "pro6pp-input",
938
+ placeholder,
939
+ autoComplete: "off",
940
+ autoCorrect: "off",
941
+ autoCapitalize: "none",
942
+ spellCheck: "false",
943
+ inputMode: "search",
944
+ enterKeyHint: "search",
945
+ ...inputProps,
946
+ ...coreInputProps,
947
+ onFocus: (e) => {
948
+ setIsOpen(true);
949
+ inputProps?.onFocus?.(e);
950
+ }
528
951
  }
529
- }
530
- ), state.isLoading && /* @__PURE__ */ React.createElement("div", { className: "pro6pp-loader" })), showDropdown && /* @__PURE__ */ React.createElement("ul", { className: "pro6pp-dropdown", role: "listbox" }, hasResults ? items.map((item, index) => {
531
- const isActive = index === state.selectedSuggestionIndex;
532
- const secondaryText = item.subtitle || (item.count !== void 0 ? item.count : "");
533
- const showChevron = item.value === void 0 || item.value === null;
534
- return /* @__PURE__ */ React.createElement(
535
- "li",
952
+ ), /* @__PURE__ */ React.createElement("div", { className: "pro6pp-input-addons" }, showClearButton && state.query.length > 0 && /* @__PURE__ */ React.createElement(
953
+ "button",
536
954
  {
537
- key: `${item.label}-${index}`,
538
- role: "option",
539
- "aria-selected": isActive,
540
- className: `pro6pp-item ${isActive ? "pro6pp-item--active" : ""}`,
541
- onMouseDown: (e) => e.preventDefault(),
542
- onClick: () => handleSelect(item)
955
+ type: "button",
956
+ className: "pro6pp-clear-button",
957
+ onClick: handleClear,
958
+ "aria-label": "Clear input"
543
959
  },
544
- renderItem ? renderItem(item, isActive) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", { className: "pro6pp-item__label" }, item.label), secondaryText && /* @__PURE__ */ React.createElement("span", { className: "pro6pp-item__subtitle" }, ", ", secondaryText), showChevron && /* @__PURE__ */ React.createElement("div", { className: "pro6pp-item__chevron" }, /* @__PURE__ */ React.createElement(
960
+ /* @__PURE__ */ React.createElement(
545
961
  "svg",
546
962
  {
547
- width: "16",
548
- height: "16",
963
+ width: "14",
964
+ height: "14",
549
965
  viewBox: "0 0 24 24",
550
966
  fill: "none",
551
967
  stroke: "currentColor",
@@ -553,11 +969,49 @@ var Pro6PPInfer = ({
553
969
  strokeLinecap: "round",
554
970
  strokeLinejoin: "round"
555
971
  },
556
- /* @__PURE__ */ React.createElement("polyline", { points: "9 18 15 12 9 6" })
557
- )))
558
- );
559
- }) : /* @__PURE__ */ React.createElement("li", { className: "pro6pp-no-results" }, renderNoResults ? renderNoResults(state) : noResultsText)));
560
- };
972
+ /* @__PURE__ */ React.createElement("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
973
+ /* @__PURE__ */ React.createElement("line", { x1: "6", y1: "6", x2: "18", y2: "18" })
974
+ )
975
+ ))), showDropdown && /* @__PURE__ */ React.createElement(
976
+ "div",
977
+ {
978
+ className: "pro6pp-dropdown",
979
+ onWheel: (e) => e.stopPropagation(),
980
+ onMouseDown: (e) => e.stopPropagation()
981
+ },
982
+ /* @__PURE__ */ React.createElement("ul", { className: "pro6pp-list", role: "listbox" }, hasResults ? /* @__PURE__ */ React.createElement(React.Fragment, null, items.map((item, index) => {
983
+ const isActive = index === state.selectedSuggestionIndex;
984
+ const secondaryText = item.subtitle || (item.count !== void 0 ? item.count : "");
985
+ const showChevron = item.value === void 0 || item.value === null;
986
+ return /* @__PURE__ */ React.createElement(
987
+ "li",
988
+ {
989
+ key: `${item.label}-${index}`,
990
+ role: "option",
991
+ "aria-selected": isActive,
992
+ className: `pro6pp-item ${isActive ? "pro6pp-item--active" : ""}`,
993
+ onMouseDown: (e) => e.preventDefault(),
994
+ onClick: () => handleSelect(item)
995
+ },
996
+ renderItem ? renderItem(item, isActive) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(HighlightedText, { text: item.label, query: state.query }), secondaryText && /* @__PURE__ */ React.createElement("span", { className: "pro6pp-item__subtitle" }, ", ", secondaryText), showChevron && /* @__PURE__ */ React.createElement("div", { className: "pro6pp-item__chevron" }, /* @__PURE__ */ React.createElement(
997
+ "svg",
998
+ {
999
+ width: "16",
1000
+ height: "16",
1001
+ viewBox: "0 0 24 24",
1002
+ fill: "none",
1003
+ stroke: "currentColor",
1004
+ strokeWidth: "2",
1005
+ strokeLinecap: "round",
1006
+ strokeLinejoin: "round"
1007
+ },
1008
+ /* @__PURE__ */ React.createElement("polyline", { points: "9 18 15 12 9 6" })
1009
+ )))
1010
+ );
1011
+ }), state.hasMore && !state.isLoading && /* @__PURE__ */ React.createElement("li", { key: "sentinel", ref: observerTarget, style: { height: "1px", opacity: 0 } }), isInfiniteLoading && /* @__PURE__ */ React.createElement("li", { key: "loader", className: "pro6pp-loader-item" }, /* @__PURE__ */ React.createElement("div", { className: "pro6pp-mini-spinner" }), /* @__PURE__ */ React.createElement("span", null, loadingText))) : state.isLoading ? /* @__PURE__ */ React.createElement("li", { className: "pro6pp-no-results" }, "Searching...") : /* @__PURE__ */ React.createElement("li", { className: "pro6pp-no-results" }, renderNoResults ? renderNoResults(state) : noResultsText))
1012
+ ));
1013
+ }
1014
+ );
561
1015
  export {
562
1016
  Pro6PPInfer,
563
1017
  useInfer