@pro6pp/infer-react 0.0.2-beta.8 → 0.0.2-beta.9

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/README.md CHANGED
@@ -43,6 +43,9 @@ You can customize the appearance of the component via the following props:
43
43
  | `renderItem` | A custom render function for suggestion items, receiving the `item` and `isActive` state. |
44
44
  | `renderNoResults` | A custom render function for the empty state, receiving the current `state`. |
45
45
  | `debounceMs` | Delay in ms before API search. Defaults to `150` (min `50`). |
46
+ | `maxRetries` | Maximum retry attempts for transient network errors. Valid range: `0` to `10`. |
47
+ | `showClearButton` | If `true`, displays a button to empty the input field. Defaults to `true`. |
48
+ | `loadMoreText` | The text to display on the pagination button. |
46
49
 
47
50
  ---
48
51
 
package/dist/index.cjs CHANGED
@@ -41,9 +41,10 @@ var import_react = __toESM(require("react"), 1);
41
41
  // ../core/src/core.ts
42
42
  var DEFAULTS = {
43
43
  API_URL: "https://api.pro6pp.nl/v2",
44
- LIMIT: 1e3,
44
+ LIMIT: 20,
45
45
  DEBOUNCE_MS: 150,
46
- MIN_DEBOUNCE_MS: 50
46
+ MIN_DEBOUNCE_MS: 50,
47
+ MAX_RETRIES: 0
47
48
  };
48
49
  var PATTERNS = {
49
50
  DIGITS_1_3: /^[0-9]{1,3}$/
@@ -57,6 +58,7 @@ var INITIAL_STATE = {
57
58
  isValid: false,
58
59
  isError: false,
59
60
  isLoading: false,
61
+ hasMore: false,
60
62
  selectedSuggestionIndex: -1
61
63
  };
62
64
  var InferCore = class {
@@ -68,7 +70,9 @@ var InferCore = class {
68
70
  __publicField(this, "country");
69
71
  __publicField(this, "authKey");
70
72
  __publicField(this, "apiUrl");
71
- __publicField(this, "limit");
73
+ __publicField(this, "baseLimit");
74
+ __publicField(this, "currentLimit");
75
+ __publicField(this, "maxRetries");
72
76
  __publicField(this, "fetcher");
73
77
  __publicField(this, "onStateChange");
74
78
  __publicField(this, "onSelect");
@@ -83,7 +87,10 @@ var InferCore = class {
83
87
  this.country = config.country;
84
88
  this.authKey = config.authKey;
85
89
  this.apiUrl = config.apiUrl || DEFAULTS.API_URL;
86
- this.limit = config.limit || DEFAULTS.LIMIT;
90
+ this.baseLimit = config.limit || DEFAULTS.LIMIT;
91
+ this.currentLimit = this.baseLimit;
92
+ const configRetries = config.maxRetries !== void 0 ? config.maxRetries : DEFAULTS.MAX_RETRIES;
93
+ this.maxRetries = Math.max(0, Math.min(configRetries, 10));
87
94
  this.fetcher = config.fetcher || ((url, init) => fetch(url, init));
88
95
  this.onStateChange = config.onStateChange || (() => {
89
96
  });
@@ -104,18 +111,29 @@ var InferCore = class {
104
111
  this.isSelecting = false;
105
112
  return;
106
113
  }
114
+ this.currentLimit = this.baseLimit;
107
115
  const isEditingFinal = this.state.stage === "final" && value !== this.state.query;
108
116
  this.updateState({
109
117
  query: value,
110
118
  isValid: false,
111
119
  isLoading: !!value.trim(),
112
- selectedSuggestionIndex: -1
120
+ selectedSuggestionIndex: -1,
121
+ hasMore: false
113
122
  });
114
123
  if (isEditingFinal) {
115
124
  this.onSelect(null);
116
125
  }
117
126
  this.debouncedFetch(value);
118
127
  }
128
+ /**
129
+ * Increases the current limit and re-fetches the query to show more results.
130
+ */
131
+ loadMore() {
132
+ if (this.state.isLoading) return;
133
+ this.currentLimit += this.baseLimit;
134
+ this.updateState({ isLoading: true });
135
+ this.executeFetch(this.state.query);
136
+ }
119
137
  /**
120
138
  * Handles keyboard events for the input field.
121
139
  * Supports:
@@ -214,7 +232,8 @@ var InferCore = class {
214
232
  cities: [],
215
233
  streets: [],
216
234
  isValid: true,
217
- stage: "final"
235
+ stage: "final",
236
+ hasMore: false
218
237
  });
219
238
  this.onSelect(value || label);
220
239
  setTimeout(() => {
@@ -251,32 +270,53 @@ var InferCore = class {
251
270
  }
252
271
  this.updateQueryAndFetch(nextQuery);
253
272
  }
254
- executeFetch(val) {
273
+ executeFetch(val, attempt = 0) {
255
274
  const text = (val || "").toString();
256
275
  if (!text.trim()) {
257
276
  this.abortController?.abort();
258
277
  this.resetState();
259
278
  return;
260
279
  }
261
- this.updateState({ isError: false });
262
- if (this.abortController) this.abortController.abort();
263
- this.abortController = new AbortController();
280
+ if (attempt === 0) {
281
+ this.updateState({ isError: false });
282
+ if (this.abortController) this.abortController.abort();
283
+ this.abortController = new AbortController();
284
+ }
285
+ const currentSignal = this.abortController?.signal;
264
286
  const url = new URL(`${this.apiUrl}/infer/${this.country.toLowerCase()}`);
265
287
  const params = {
266
288
  authKey: this.authKey,
267
289
  query: text,
268
- limit: this.limit.toString()
290
+ limit: this.currentLimit.toString()
269
291
  };
270
292
  url.search = new URLSearchParams(params).toString();
271
- this.fetcher(url.toString(), { signal: this.abortController.signal }).then((res) => {
272
- if (!res.ok) throw new Error("Network error");
293
+ this.fetcher(url.toString(), { signal: currentSignal }).then((res) => {
294
+ if (!res.ok) {
295
+ if (attempt < this.maxRetries && (res.status >= 500 || res.status === 429)) {
296
+ return this.retry(val, attempt, currentSignal);
297
+ }
298
+ throw new Error("Network error");
299
+ }
273
300
  return res.json();
274
- }).then((data) => this.mapResponseToState(data)).catch((e) => {
275
- if (e.name !== "AbortError") {
276
- this.updateState({ isError: true, isLoading: false });
301
+ }).then((data) => {
302
+ if (data) this.mapResponseToState(data);
303
+ }).catch((e) => {
304
+ if (e.name === "AbortError") return;
305
+ if (attempt < this.maxRetries) {
306
+ return this.retry(val, attempt, currentSignal);
277
307
  }
308
+ this.updateState({ isError: true, isLoading: false });
278
309
  });
279
310
  }
311
+ retry(val, attempt, signal) {
312
+ if (signal?.aborted) return;
313
+ const delay = Math.pow(2, attempt) * 200;
314
+ setTimeout(() => {
315
+ if (!signal?.aborted) {
316
+ this.executeFetch(val, attempt + 1);
317
+ }
318
+ }, delay);
319
+ }
280
320
  mapResponseToState(data) {
281
321
  const newState = {
282
322
  stage: data.stage,
@@ -294,6 +334,8 @@ var InferCore = class {
294
334
  uniqueSuggestions.push(item);
295
335
  }
296
336
  }
337
+ const totalCount = uniqueSuggestions.length + (data.cities?.length || 0) + (data.streets?.length || 0);
338
+ newState.hasMore = totalCount >= this.currentLimit;
297
339
  if (data.stage === "mixed") {
298
340
  newState.cities = data.cities || [];
299
341
  newState.streets = data.streets || [];
@@ -314,6 +356,7 @@ var InferCore = class {
314
356
  newState.cities = [];
315
357
  newState.streets = [];
316
358
  newState.isValid = true;
359
+ newState.hasMore = false;
317
360
  this.updateState(newState);
318
361
  const val = typeof autoSelectItem.value === "object" ? autoSelectItem.value : autoSelectItem.label;
319
362
  this.onSelect(val);
@@ -323,7 +366,7 @@ var InferCore = class {
323
366
  }
324
367
  updateQueryAndFetch(nextQuery) {
325
368
  this.updateState({ query: nextQuery, suggestions: [], cities: [], streets: [] });
326
- this.updateState({ isLoading: true, isValid: false });
369
+ this.updateState({ isLoading: true, isValid: false, hasMore: false });
327
370
  this.debouncedFetch(nextQuery);
328
371
  setTimeout(() => {
329
372
  this.isSelecting = false;
@@ -378,6 +421,7 @@ var DEFAULT_STYLES = `
378
421
  .pro6pp-input {
379
422
  width: 100%;
380
423
  padding: 10px 12px;
424
+ padding-right: 48px;
381
425
  border: 1px solid #e0e0e0;
382
426
  border-radius: 4px;
383
427
  font-size: 16px;
@@ -389,6 +433,57 @@ var DEFAULT_STYLES = `
389
433
  border-color: #3b82f6;
390
434
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
391
435
  }
436
+
437
+ .pro6pp-input-addons {
438
+ position: absolute;
439
+ right: 6px;
440
+ top: 0;
441
+ bottom: 0;
442
+ display: flex;
443
+ align-items: center;
444
+ gap: 2px;
445
+ pointer-events: none;
446
+ }
447
+ .pro6pp-input-addons > * {
448
+ pointer-events: auto;
449
+ }
450
+
451
+ .pro6pp-clear-button {
452
+ background: none;
453
+ border: none;
454
+ width: 28px;
455
+ height: 28px;
456
+ cursor: pointer;
457
+ color: #a3a3a3;
458
+ display: flex;
459
+ align-items: center;
460
+ justify-content: center;
461
+ border-radius: 50%;
462
+ transition: color 0.2s, background-color 0.2s, transform 0.1s;
463
+ }
464
+ .pro6pp-clear-button:hover {
465
+ color: #1f2937;
466
+ background-color: #f3f4f6;
467
+ }
468
+ .pro6pp-clear-button:active {
469
+ transform: scale(0.92);
470
+ }
471
+ .pro6pp-clear-button svg {
472
+ width: 18px;
473
+ height: 18px;
474
+ }
475
+
476
+ .pro6pp-loader {
477
+ width: 18px;
478
+ height: 18px;
479
+ margin: 0 4px;
480
+ border: 2px solid #e0e0e0;
481
+ border-top-color: #6b7280;
482
+ border-radius: 50%;
483
+ animation: pro6pp-spin 0.6s linear infinite;
484
+ flex-shrink: 0;
485
+ }
486
+
392
487
  .pro6pp-dropdown {
393
488
  position: absolute;
394
489
  top: 100%;
@@ -399,27 +494,32 @@ var DEFAULT_STYLES = `
399
494
  background: white;
400
495
  border: 1px solid #e0e0e0;
401
496
  border-radius: 4px;
402
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
497
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
403
498
  max-height: 300px;
404
499
  overflow-y: auto;
500
+ display: flex;
501
+ flex-direction: column;
502
+ }
503
+ .pro6pp-list {
405
504
  list-style: none !important;
406
505
  padding: 0 !important;
407
506
  margin: 0 !important;
507
+ flex-grow: 1;
408
508
  }
409
509
  .pro6pp-item {
410
- padding: 10px 16px;
510
+ padding: 12px 16px;
411
511
  cursor: pointer;
412
512
  display: flex;
413
513
  flex-direction: row;
414
514
  align-items: center;
415
- color: #000000;
515
+ color: #111827;
416
516
  font-size: 14px;
417
517
  line-height: 1.2;
418
518
  white-space: nowrap;
419
519
  overflow: hidden;
420
520
  }
421
521
  .pro6pp-item:hover, .pro6pp-item--active {
422
- background-color: #f5f5f5;
522
+ background-color: #f9fafb;
423
523
  }
424
524
  .pro6pp-item__label {
425
525
  font-weight: 500;
@@ -427,7 +527,7 @@ var DEFAULT_STYLES = `
427
527
  }
428
528
  .pro6pp-item__subtitle {
429
529
  font-size: 14px;
430
- color: #404040;
530
+ color: #6b7280;
431
531
  overflow: hidden;
432
532
  text-overflow: ellipsis;
433
533
  flex-shrink: 1;
@@ -436,32 +536,34 @@ var DEFAULT_STYLES = `
436
536
  margin-left: auto;
437
537
  display: flex;
438
538
  align-items: center;
439
- color: #a3a3a3;
539
+ color: #9ca3af;
440
540
  padding-left: 8px;
441
541
  }
442
542
  .pro6pp-no-results {
443
- padding: 12px;
444
- color: #555555;
543
+ padding: 16px;
544
+ color: #6b7280;
445
545
  font-size: 14px;
446
546
  text-align: center;
447
- user-select: none;
448
- pointer-events: none;
449
547
  }
450
- .pro6pp-loader {
451
- position: absolute;
452
- right: 12px;
453
- top: 50%;
454
- transform: translateY(-50%);
455
- width: 16px;
456
- height: 16px;
457
- border: 2px solid #e0e0e0;
458
- border-top-color: #404040;
459
- border-radius: 50%;
460
- animation: pro6pp-spin 0.6s linear infinite;
461
- pointer-events: none;
548
+ .pro6pp-load-more {
549
+ width: 100%;
550
+ padding: 10px;
551
+ background: #f9fafb;
552
+ border: none;
553
+ border-top: 1px solid #e0e0e0;
554
+ color: #3b82f6;
555
+ font-size: 13px;
556
+ font-weight: 600;
557
+ cursor: pointer;
558
+ transition: background-color 0.2s;
559
+ flex-shrink: 0;
462
560
  }
561
+ .pro6pp-load-more:hover {
562
+ background-color: #f3f4f6;
563
+ }
564
+
463
565
  @keyframes pro6pp-spin {
464
- to { transform: translateY(-50%) rotate(360deg); }
566
+ to { transform: rotate(360deg); }
465
567
  }
466
568
  `;
467
569
 
@@ -478,7 +580,7 @@ function useInfer(config) {
478
580
  }
479
581
  }
480
582
  });
481
- }, [config.country, config.authKey, config.limit, config.debounceMs]);
583
+ }, [config.country, config.authKey, config.limit, config.debounceMs, config.maxRetries]);
482
584
  return {
483
585
  /** The current UI state (suggestions, loading status, query, etc.). */
484
586
  state,
@@ -491,7 +593,9 @@ function useInfer(config) {
491
593
  onKeyDown: (e) => core.handleKeyDown(e)
492
594
  },
493
595
  /** Function to manually select a specific suggestion. */
494
- selectItem: (item) => core.selectItem(item)
596
+ selectItem: (item) => core.selectItem(item),
597
+ /** Function to load more results. */
598
+ loadMore: () => core.loadMore()
495
599
  };
496
600
  }
497
601
  var Pro6PPInfer = ({
@@ -502,10 +606,12 @@ var Pro6PPInfer = ({
502
606
  renderItem,
503
607
  disableDefaultStyles = false,
504
608
  noResultsText = "No results found",
609
+ loadMoreText = "Show more results...",
505
610
  renderNoResults,
611
+ showClearButton = true,
506
612
  ...config
507
613
  }) => {
508
- const { state, selectItem, inputProps: coreInputProps } = useInfer(config);
614
+ const { state, selectItem, loadMore, inputProps: coreInputProps, core } = useInfer(config);
509
615
  const [isOpen, setIsOpen] = (0, import_react.useState)(false);
510
616
  const inputRef = (0, import_react.useRef)(null);
511
617
  const wrapperRef = (0, import_react.useRef)(null);
@@ -542,6 +648,12 @@ var Pro6PPInfer = ({
542
648
  inputRef.current.focus();
543
649
  }
544
650
  };
651
+ const handleClear = () => {
652
+ core.handleInput("");
653
+ if (inputRef.current) {
654
+ inputRef.current.focus();
655
+ }
656
+ };
545
657
  const hasResults = items.length > 0;
546
658
  const showNoResults = !state.isLoading && !state.isError && state.query.length > 0 && !hasResults && !state.isValid;
547
659
  const showDropdown = isOpen && (hasResults || showNoResults);
@@ -560,36 +672,79 @@ var Pro6PPInfer = ({
560
672
  inputProps?.onFocus?.(e);
561
673
  }
562
674
  }
563
- ), state.isLoading && /* @__PURE__ */ import_react.default.createElement("div", { className: "pro6pp-loader" })), showDropdown && /* @__PURE__ */ import_react.default.createElement("ul", { className: "pro6pp-dropdown", role: "listbox" }, hasResults ? items.map((item, index) => {
564
- const isActive = index === state.selectedSuggestionIndex;
565
- const secondaryText = item.subtitle || (item.count !== void 0 ? item.count : "");
566
- const showChevron = item.value === void 0 || item.value === null;
567
- return /* @__PURE__ */ import_react.default.createElement(
568
- "li",
675
+ ), /* @__PURE__ */ import_react.default.createElement("div", { className: "pro6pp-input-addons" }, state.isLoading && /* @__PURE__ */ import_react.default.createElement("div", { className: "pro6pp-loader" }), showClearButton && state.query.length > 0 && /* @__PURE__ */ import_react.default.createElement(
676
+ "button",
677
+ {
678
+ type: "button",
679
+ className: "pro6pp-clear-button",
680
+ onClick: handleClear,
681
+ "aria-label": "Clear input"
682
+ },
683
+ /* @__PURE__ */ import_react.default.createElement(
684
+ "svg",
569
685
  {
570
- key: `${item.label}-${index}`,
571
- role: "option",
572
- "aria-selected": isActive,
573
- className: `pro6pp-item ${isActive ? "pro6pp-item--active" : ""}`,
574
- onMouseDown: (e) => e.preventDefault(),
575
- onClick: () => handleSelect(item)
686
+ width: "14",
687
+ height: "14",
688
+ viewBox: "0 0 24 24",
689
+ fill: "none",
690
+ stroke: "currentColor",
691
+ strokeWidth: "2",
692
+ strokeLinecap: "round",
693
+ strokeLinejoin: "round"
576
694
  },
577
- renderItem ? renderItem(item, isActive) : /* @__PURE__ */ import_react.default.createElement(import_react.default.Fragment, null, /* @__PURE__ */ import_react.default.createElement("span", { className: "pro6pp-item__label" }, item.label), secondaryText && /* @__PURE__ */ import_react.default.createElement("span", { className: "pro6pp-item__subtitle" }, ", ", secondaryText), showChevron && /* @__PURE__ */ import_react.default.createElement("div", { className: "pro6pp-item__chevron" }, /* @__PURE__ */ import_react.default.createElement(
578
- "svg",
695
+ /* @__PURE__ */ import_react.default.createElement("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
696
+ /* @__PURE__ */ import_react.default.createElement("line", { x1: "6", y1: "6", x2: "18", y2: "18" })
697
+ )
698
+ ))), showDropdown && /* @__PURE__ */ import_react.default.createElement(
699
+ "div",
700
+ {
701
+ className: "pro6pp-dropdown",
702
+ onWheel: (e) => e.stopPropagation(),
703
+ onMouseDown: (e) => e.stopPropagation()
704
+ },
705
+ /* @__PURE__ */ import_react.default.createElement("ul", { className: "pro6pp-list", role: "listbox" }, hasResults ? items.map((item, index) => {
706
+ const isActive = index === state.selectedSuggestionIndex;
707
+ const secondaryText = item.subtitle || (item.count !== void 0 ? item.count : "");
708
+ const showChevron = item.value === void 0 || item.value === null;
709
+ return /* @__PURE__ */ import_react.default.createElement(
710
+ "li",
579
711
  {
580
- width: "16",
581
- height: "16",
582
- viewBox: "0 0 24 24",
583
- fill: "none",
584
- stroke: "currentColor",
585
- strokeWidth: "2",
586
- strokeLinecap: "round",
587
- strokeLinejoin: "round"
712
+ key: `${item.label}-${index}`,
713
+ role: "option",
714
+ "aria-selected": isActive,
715
+ className: `pro6pp-item ${isActive ? "pro6pp-item--active" : ""}`,
716
+ onMouseDown: (e) => e.preventDefault(),
717
+ onClick: () => handleSelect(item)
588
718
  },
589
- /* @__PURE__ */ import_react.default.createElement("polyline", { points: "9 18 15 12 9 6" })
590
- )))
591
- );
592
- }) : /* @__PURE__ */ import_react.default.createElement("li", { className: "pro6pp-no-results" }, renderNoResults ? renderNoResults(state) : noResultsText)));
719
+ renderItem ? renderItem(item, isActive) : /* @__PURE__ */ import_react.default.createElement(import_react.default.Fragment, null, /* @__PURE__ */ import_react.default.createElement("span", { className: "pro6pp-item__label" }, item.label), secondaryText && /* @__PURE__ */ import_react.default.createElement("span", { className: "pro6pp-item__subtitle" }, ", ", secondaryText), showChevron && /* @__PURE__ */ import_react.default.createElement("div", { className: "pro6pp-item__chevron" }, /* @__PURE__ */ import_react.default.createElement(
720
+ "svg",
721
+ {
722
+ width: "16",
723
+ height: "16",
724
+ viewBox: "0 0 24 24",
725
+ fill: "none",
726
+ stroke: "currentColor",
727
+ strokeWidth: "2",
728
+ strokeLinecap: "round",
729
+ strokeLinejoin: "round"
730
+ },
731
+ /* @__PURE__ */ import_react.default.createElement("polyline", { points: "9 18 15 12 9 6" })
732
+ )))
733
+ );
734
+ }) : /* @__PURE__ */ import_react.default.createElement("li", { className: "pro6pp-no-results" }, renderNoResults ? renderNoResults(state) : noResultsText)),
735
+ state.hasMore && /* @__PURE__ */ import_react.default.createElement(
736
+ "button",
737
+ {
738
+ type: "button",
739
+ className: "pro6pp-load-more",
740
+ onClick: (e) => {
741
+ e.preventDefault();
742
+ loadMore();
743
+ }
744
+ },
745
+ loadMoreText
746
+ )
747
+ ));
593
748
  };
594
749
  // Annotate the CommonJS export names for ESM import in node:
595
750
  0 && (module.exports = {
package/dist/index.d.cts CHANGED
@@ -20,6 +20,8 @@ declare function useInfer(config: InferConfig): {
20
20
  };
21
21
  /** Function to manually select a specific suggestion. */
22
22
  selectItem: (item: InferResult | string) => void;
23
+ /** Function to load more results. */
24
+ loadMore: () => void;
23
25
  };
24
26
  /**
25
27
  * Props for the Pro6PPInfer component.
@@ -45,8 +47,17 @@ interface Pro6PPInferProps extends InferConfig {
45
47
  * @default 'No results found'
46
48
  */
47
49
  noResultsText?: string;
50
+ /** * The text to show on the load more button.
51
+ * @default 'Show more results...'
52
+ */
53
+ loadMoreText?: string;
48
54
  /** A custom render function for the "no results" state. */
49
55
  renderNoResults?: (state: InferState) => React.ReactNode;
56
+ /**
57
+ * If true, shows a clear button when the input is not empty.
58
+ * @default true
59
+ */
60
+ showClearButton?: boolean;
50
61
  }
51
62
  /**
52
63
  * A styled React component for Pro6PP Infer API.
package/dist/index.d.ts CHANGED
@@ -20,6 +20,8 @@ declare function useInfer(config: InferConfig): {
20
20
  };
21
21
  /** Function to manually select a specific suggestion. */
22
22
  selectItem: (item: InferResult | string) => void;
23
+ /** Function to load more results. */
24
+ loadMore: () => void;
23
25
  };
24
26
  /**
25
27
  * Props for the Pro6PPInfer component.
@@ -45,8 +47,17 @@ interface Pro6PPInferProps extends InferConfig {
45
47
  * @default 'No results found'
46
48
  */
47
49
  noResultsText?: string;
50
+ /** * The text to show on the load more button.
51
+ * @default 'Show more results...'
52
+ */
53
+ loadMoreText?: string;
48
54
  /** A custom render function for the "no results" state. */
49
55
  renderNoResults?: (state: InferState) => React.ReactNode;
56
+ /**
57
+ * If true, shows a clear button when the input is not empty.
58
+ * @default true
59
+ */
60
+ showClearButton?: boolean;
50
61
  }
51
62
  /**
52
63
  * A styled React component for Pro6PP Infer API.
package/dist/index.js CHANGED
@@ -8,9 +8,10 @@ import React, { useState, useMemo, useEffect, useRef } from "react";
8
8
  // ../core/src/core.ts
9
9
  var DEFAULTS = {
10
10
  API_URL: "https://api.pro6pp.nl/v2",
11
- LIMIT: 1e3,
11
+ LIMIT: 20,
12
12
  DEBOUNCE_MS: 150,
13
- MIN_DEBOUNCE_MS: 50
13
+ MIN_DEBOUNCE_MS: 50,
14
+ MAX_RETRIES: 0
14
15
  };
15
16
  var PATTERNS = {
16
17
  DIGITS_1_3: /^[0-9]{1,3}$/
@@ -24,6 +25,7 @@ var INITIAL_STATE = {
24
25
  isValid: false,
25
26
  isError: false,
26
27
  isLoading: false,
28
+ hasMore: false,
27
29
  selectedSuggestionIndex: -1
28
30
  };
29
31
  var InferCore = class {
@@ -35,7 +37,9 @@ var InferCore = class {
35
37
  __publicField(this, "country");
36
38
  __publicField(this, "authKey");
37
39
  __publicField(this, "apiUrl");
38
- __publicField(this, "limit");
40
+ __publicField(this, "baseLimit");
41
+ __publicField(this, "currentLimit");
42
+ __publicField(this, "maxRetries");
39
43
  __publicField(this, "fetcher");
40
44
  __publicField(this, "onStateChange");
41
45
  __publicField(this, "onSelect");
@@ -50,7 +54,10 @@ var InferCore = class {
50
54
  this.country = config.country;
51
55
  this.authKey = config.authKey;
52
56
  this.apiUrl = config.apiUrl || DEFAULTS.API_URL;
53
- this.limit = config.limit || DEFAULTS.LIMIT;
57
+ this.baseLimit = config.limit || DEFAULTS.LIMIT;
58
+ this.currentLimit = this.baseLimit;
59
+ const configRetries = config.maxRetries !== void 0 ? config.maxRetries : DEFAULTS.MAX_RETRIES;
60
+ this.maxRetries = Math.max(0, Math.min(configRetries, 10));
54
61
  this.fetcher = config.fetcher || ((url, init) => fetch(url, init));
55
62
  this.onStateChange = config.onStateChange || (() => {
56
63
  });
@@ -71,18 +78,29 @@ var InferCore = class {
71
78
  this.isSelecting = false;
72
79
  return;
73
80
  }
81
+ this.currentLimit = this.baseLimit;
74
82
  const isEditingFinal = this.state.stage === "final" && value !== this.state.query;
75
83
  this.updateState({
76
84
  query: value,
77
85
  isValid: false,
78
86
  isLoading: !!value.trim(),
79
- selectedSuggestionIndex: -1
87
+ selectedSuggestionIndex: -1,
88
+ hasMore: false
80
89
  });
81
90
  if (isEditingFinal) {
82
91
  this.onSelect(null);
83
92
  }
84
93
  this.debouncedFetch(value);
85
94
  }
95
+ /**
96
+ * Increases the current limit and re-fetches the query to show more results.
97
+ */
98
+ loadMore() {
99
+ if (this.state.isLoading) return;
100
+ this.currentLimit += this.baseLimit;
101
+ this.updateState({ isLoading: true });
102
+ this.executeFetch(this.state.query);
103
+ }
86
104
  /**
87
105
  * Handles keyboard events for the input field.
88
106
  * Supports:
@@ -181,7 +199,8 @@ var InferCore = class {
181
199
  cities: [],
182
200
  streets: [],
183
201
  isValid: true,
184
- stage: "final"
202
+ stage: "final",
203
+ hasMore: false
185
204
  });
186
205
  this.onSelect(value || label);
187
206
  setTimeout(() => {
@@ -218,32 +237,53 @@ var InferCore = class {
218
237
  }
219
238
  this.updateQueryAndFetch(nextQuery);
220
239
  }
221
- executeFetch(val) {
240
+ executeFetch(val, attempt = 0) {
222
241
  const text = (val || "").toString();
223
242
  if (!text.trim()) {
224
243
  this.abortController?.abort();
225
244
  this.resetState();
226
245
  return;
227
246
  }
228
- this.updateState({ isError: false });
229
- if (this.abortController) this.abortController.abort();
230
- this.abortController = new AbortController();
247
+ if (attempt === 0) {
248
+ this.updateState({ isError: false });
249
+ if (this.abortController) this.abortController.abort();
250
+ this.abortController = new AbortController();
251
+ }
252
+ const currentSignal = this.abortController?.signal;
231
253
  const url = new URL(`${this.apiUrl}/infer/${this.country.toLowerCase()}`);
232
254
  const params = {
233
255
  authKey: this.authKey,
234
256
  query: text,
235
- limit: this.limit.toString()
257
+ limit: this.currentLimit.toString()
236
258
  };
237
259
  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");
260
+ this.fetcher(url.toString(), { signal: currentSignal }).then((res) => {
261
+ if (!res.ok) {
262
+ if (attempt < this.maxRetries && (res.status >= 500 || res.status === 429)) {
263
+ return this.retry(val, attempt, currentSignal);
264
+ }
265
+ throw new Error("Network error");
266
+ }
240
267
  return res.json();
241
- }).then((data) => this.mapResponseToState(data)).catch((e) => {
242
- if (e.name !== "AbortError") {
243
- this.updateState({ isError: true, isLoading: false });
268
+ }).then((data) => {
269
+ if (data) this.mapResponseToState(data);
270
+ }).catch((e) => {
271
+ if (e.name === "AbortError") return;
272
+ if (attempt < this.maxRetries) {
273
+ return this.retry(val, attempt, currentSignal);
244
274
  }
275
+ this.updateState({ isError: true, isLoading: false });
245
276
  });
246
277
  }
278
+ retry(val, attempt, signal) {
279
+ if (signal?.aborted) return;
280
+ const delay = Math.pow(2, attempt) * 200;
281
+ setTimeout(() => {
282
+ if (!signal?.aborted) {
283
+ this.executeFetch(val, attempt + 1);
284
+ }
285
+ }, delay);
286
+ }
247
287
  mapResponseToState(data) {
248
288
  const newState = {
249
289
  stage: data.stage,
@@ -261,6 +301,8 @@ var InferCore = class {
261
301
  uniqueSuggestions.push(item);
262
302
  }
263
303
  }
304
+ const totalCount = uniqueSuggestions.length + (data.cities?.length || 0) + (data.streets?.length || 0);
305
+ newState.hasMore = totalCount >= this.currentLimit;
264
306
  if (data.stage === "mixed") {
265
307
  newState.cities = data.cities || [];
266
308
  newState.streets = data.streets || [];
@@ -281,6 +323,7 @@ var InferCore = class {
281
323
  newState.cities = [];
282
324
  newState.streets = [];
283
325
  newState.isValid = true;
326
+ newState.hasMore = false;
284
327
  this.updateState(newState);
285
328
  const val = typeof autoSelectItem.value === "object" ? autoSelectItem.value : autoSelectItem.label;
286
329
  this.onSelect(val);
@@ -290,7 +333,7 @@ var InferCore = class {
290
333
  }
291
334
  updateQueryAndFetch(nextQuery) {
292
335
  this.updateState({ query: nextQuery, suggestions: [], cities: [], streets: [] });
293
- this.updateState({ isLoading: true, isValid: false });
336
+ this.updateState({ isLoading: true, isValid: false, hasMore: false });
294
337
  this.debouncedFetch(nextQuery);
295
338
  setTimeout(() => {
296
339
  this.isSelecting = false;
@@ -345,6 +388,7 @@ var DEFAULT_STYLES = `
345
388
  .pro6pp-input {
346
389
  width: 100%;
347
390
  padding: 10px 12px;
391
+ padding-right: 48px;
348
392
  border: 1px solid #e0e0e0;
349
393
  border-radius: 4px;
350
394
  font-size: 16px;
@@ -356,6 +400,57 @@ var DEFAULT_STYLES = `
356
400
  border-color: #3b82f6;
357
401
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
358
402
  }
403
+
404
+ .pro6pp-input-addons {
405
+ position: absolute;
406
+ right: 6px;
407
+ top: 0;
408
+ bottom: 0;
409
+ display: flex;
410
+ align-items: center;
411
+ gap: 2px;
412
+ pointer-events: none;
413
+ }
414
+ .pro6pp-input-addons > * {
415
+ pointer-events: auto;
416
+ }
417
+
418
+ .pro6pp-clear-button {
419
+ background: none;
420
+ border: none;
421
+ width: 28px;
422
+ height: 28px;
423
+ cursor: pointer;
424
+ color: #a3a3a3;
425
+ display: flex;
426
+ align-items: center;
427
+ justify-content: center;
428
+ border-radius: 50%;
429
+ transition: color 0.2s, background-color 0.2s, transform 0.1s;
430
+ }
431
+ .pro6pp-clear-button:hover {
432
+ color: #1f2937;
433
+ background-color: #f3f4f6;
434
+ }
435
+ .pro6pp-clear-button:active {
436
+ transform: scale(0.92);
437
+ }
438
+ .pro6pp-clear-button svg {
439
+ width: 18px;
440
+ height: 18px;
441
+ }
442
+
443
+ .pro6pp-loader {
444
+ width: 18px;
445
+ height: 18px;
446
+ margin: 0 4px;
447
+ border: 2px solid #e0e0e0;
448
+ border-top-color: #6b7280;
449
+ border-radius: 50%;
450
+ animation: pro6pp-spin 0.6s linear infinite;
451
+ flex-shrink: 0;
452
+ }
453
+
359
454
  .pro6pp-dropdown {
360
455
  position: absolute;
361
456
  top: 100%;
@@ -366,27 +461,32 @@ var DEFAULT_STYLES = `
366
461
  background: white;
367
462
  border: 1px solid #e0e0e0;
368
463
  border-radius: 4px;
369
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
464
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
370
465
  max-height: 300px;
371
466
  overflow-y: auto;
467
+ display: flex;
468
+ flex-direction: column;
469
+ }
470
+ .pro6pp-list {
372
471
  list-style: none !important;
373
472
  padding: 0 !important;
374
473
  margin: 0 !important;
474
+ flex-grow: 1;
375
475
  }
376
476
  .pro6pp-item {
377
- padding: 10px 16px;
477
+ padding: 12px 16px;
378
478
  cursor: pointer;
379
479
  display: flex;
380
480
  flex-direction: row;
381
481
  align-items: center;
382
- color: #000000;
482
+ color: #111827;
383
483
  font-size: 14px;
384
484
  line-height: 1.2;
385
485
  white-space: nowrap;
386
486
  overflow: hidden;
387
487
  }
388
488
  .pro6pp-item:hover, .pro6pp-item--active {
389
- background-color: #f5f5f5;
489
+ background-color: #f9fafb;
390
490
  }
391
491
  .pro6pp-item__label {
392
492
  font-weight: 500;
@@ -394,7 +494,7 @@ var DEFAULT_STYLES = `
394
494
  }
395
495
  .pro6pp-item__subtitle {
396
496
  font-size: 14px;
397
- color: #404040;
497
+ color: #6b7280;
398
498
  overflow: hidden;
399
499
  text-overflow: ellipsis;
400
500
  flex-shrink: 1;
@@ -403,32 +503,34 @@ var DEFAULT_STYLES = `
403
503
  margin-left: auto;
404
504
  display: flex;
405
505
  align-items: center;
406
- color: #a3a3a3;
506
+ color: #9ca3af;
407
507
  padding-left: 8px;
408
508
  }
409
509
  .pro6pp-no-results {
410
- padding: 12px;
411
- color: #555555;
510
+ padding: 16px;
511
+ color: #6b7280;
412
512
  font-size: 14px;
413
513
  text-align: center;
414
- user-select: none;
415
- pointer-events: none;
416
514
  }
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;
426
- border-radius: 50%;
427
- animation: pro6pp-spin 0.6s linear infinite;
428
- pointer-events: none;
515
+ .pro6pp-load-more {
516
+ width: 100%;
517
+ padding: 10px;
518
+ background: #f9fafb;
519
+ border: none;
520
+ border-top: 1px solid #e0e0e0;
521
+ color: #3b82f6;
522
+ font-size: 13px;
523
+ font-weight: 600;
524
+ cursor: pointer;
525
+ transition: background-color 0.2s;
526
+ flex-shrink: 0;
429
527
  }
528
+ .pro6pp-load-more:hover {
529
+ background-color: #f3f4f6;
530
+ }
531
+
430
532
  @keyframes pro6pp-spin {
431
- to { transform: translateY(-50%) rotate(360deg); }
533
+ to { transform: rotate(360deg); }
432
534
  }
433
535
  `;
434
536
 
@@ -445,7 +547,7 @@ function useInfer(config) {
445
547
  }
446
548
  }
447
549
  });
448
- }, [config.country, config.authKey, config.limit, config.debounceMs]);
550
+ }, [config.country, config.authKey, config.limit, config.debounceMs, config.maxRetries]);
449
551
  return {
450
552
  /** The current UI state (suggestions, loading status, query, etc.). */
451
553
  state,
@@ -458,7 +560,9 @@ function useInfer(config) {
458
560
  onKeyDown: (e) => core.handleKeyDown(e)
459
561
  },
460
562
  /** Function to manually select a specific suggestion. */
461
- selectItem: (item) => core.selectItem(item)
563
+ selectItem: (item) => core.selectItem(item),
564
+ /** Function to load more results. */
565
+ loadMore: () => core.loadMore()
462
566
  };
463
567
  }
464
568
  var Pro6PPInfer = ({
@@ -469,10 +573,12 @@ var Pro6PPInfer = ({
469
573
  renderItem,
470
574
  disableDefaultStyles = false,
471
575
  noResultsText = "No results found",
576
+ loadMoreText = "Show more results...",
472
577
  renderNoResults,
578
+ showClearButton = true,
473
579
  ...config
474
580
  }) => {
475
- const { state, selectItem, inputProps: coreInputProps } = useInfer(config);
581
+ const { state, selectItem, loadMore, inputProps: coreInputProps, core } = useInfer(config);
476
582
  const [isOpen, setIsOpen] = useState(false);
477
583
  const inputRef = useRef(null);
478
584
  const wrapperRef = useRef(null);
@@ -509,6 +615,12 @@ var Pro6PPInfer = ({
509
615
  inputRef.current.focus();
510
616
  }
511
617
  };
618
+ const handleClear = () => {
619
+ core.handleInput("");
620
+ if (inputRef.current) {
621
+ inputRef.current.focus();
622
+ }
623
+ };
512
624
  const hasResults = items.length > 0;
513
625
  const showNoResults = !state.isLoading && !state.isError && state.query.length > 0 && !hasResults && !state.isValid;
514
626
  const showDropdown = isOpen && (hasResults || showNoResults);
@@ -527,36 +639,79 @@ var Pro6PPInfer = ({
527
639
  inputProps?.onFocus?.(e);
528
640
  }
529
641
  }
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",
642
+ ), /* @__PURE__ */ React.createElement("div", { className: "pro6pp-input-addons" }, state.isLoading && /* @__PURE__ */ React.createElement("div", { className: "pro6pp-loader" }), showClearButton && state.query.length > 0 && /* @__PURE__ */ React.createElement(
643
+ "button",
644
+ {
645
+ type: "button",
646
+ className: "pro6pp-clear-button",
647
+ onClick: handleClear,
648
+ "aria-label": "Clear input"
649
+ },
650
+ /* @__PURE__ */ React.createElement(
651
+ "svg",
536
652
  {
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)
653
+ width: "14",
654
+ height: "14",
655
+ viewBox: "0 0 24 24",
656
+ fill: "none",
657
+ stroke: "currentColor",
658
+ strokeWidth: "2",
659
+ strokeLinecap: "round",
660
+ strokeLinejoin: "round"
543
661
  },
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(
545
- "svg",
662
+ /* @__PURE__ */ React.createElement("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
663
+ /* @__PURE__ */ React.createElement("line", { x1: "6", y1: "6", x2: "18", y2: "18" })
664
+ )
665
+ ))), showDropdown && /* @__PURE__ */ React.createElement(
666
+ "div",
667
+ {
668
+ className: "pro6pp-dropdown",
669
+ onWheel: (e) => e.stopPropagation(),
670
+ onMouseDown: (e) => e.stopPropagation()
671
+ },
672
+ /* @__PURE__ */ React.createElement("ul", { className: "pro6pp-list", role: "listbox" }, hasResults ? items.map((item, index) => {
673
+ const isActive = index === state.selectedSuggestionIndex;
674
+ const secondaryText = item.subtitle || (item.count !== void 0 ? item.count : "");
675
+ const showChevron = item.value === void 0 || item.value === null;
676
+ return /* @__PURE__ */ React.createElement(
677
+ "li",
546
678
  {
547
- width: "16",
548
- height: "16",
549
- viewBox: "0 0 24 24",
550
- fill: "none",
551
- stroke: "currentColor",
552
- strokeWidth: "2",
553
- strokeLinecap: "round",
554
- strokeLinejoin: "round"
679
+ key: `${item.label}-${index}`,
680
+ role: "option",
681
+ "aria-selected": isActive,
682
+ className: `pro6pp-item ${isActive ? "pro6pp-item--active" : ""}`,
683
+ onMouseDown: (e) => e.preventDefault(),
684
+ onClick: () => handleSelect(item)
555
685
  },
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)));
686
+ 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(
687
+ "svg",
688
+ {
689
+ width: "16",
690
+ height: "16",
691
+ viewBox: "0 0 24 24",
692
+ fill: "none",
693
+ stroke: "currentColor",
694
+ strokeWidth: "2",
695
+ strokeLinecap: "round",
696
+ strokeLinejoin: "round"
697
+ },
698
+ /* @__PURE__ */ React.createElement("polyline", { points: "9 18 15 12 9 6" })
699
+ )))
700
+ );
701
+ }) : /* @__PURE__ */ React.createElement("li", { className: "pro6pp-no-results" }, renderNoResults ? renderNoResults(state) : noResultsText)),
702
+ state.hasMore && /* @__PURE__ */ React.createElement(
703
+ "button",
704
+ {
705
+ type: "button",
706
+ className: "pro6pp-load-more",
707
+ onClick: (e) => {
708
+ e.preventDefault();
709
+ loadMore();
710
+ }
711
+ },
712
+ loadMoreText
713
+ )
714
+ ));
560
715
  };
561
716
  export {
562
717
  Pro6PPInfer,
package/package.json CHANGED
@@ -20,7 +20,7 @@
20
20
  "url": "https://github.com/pro6pp/infer-sdk/issues"
21
21
  },
22
22
  "sideEffects": false,
23
- "version": "0.0.2-beta.8",
23
+ "version": "0.0.2-beta.9",
24
24
  "main": "./dist/index.cjs",
25
25
  "module": "./dist/index.js",
26
26
  "types": "./dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "react": ">=16"
47
47
  },
48
48
  "dependencies": {
49
- "@pro6pp/infer-core": "0.0.2-beta.6"
49
+ "@pro6pp/infer-core": "0.0.2-beta.7"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@testing-library/dom": "^10.4.1",