@pro6pp/infer-react 0.0.2-beta.7 → 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
@@ -42,6 +42,10 @@ You can customize the appearance of the component via the following props:
42
42
  | `noResultsText` | The text to display when no suggestions are found. |
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
+ | `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. |
45
49
 
46
50
  ---
47
51
 
package/dist/index.cjs CHANGED
@@ -41,8 +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,
45
- DEBOUNCE_MS: 300
44
+ LIMIT: 20,
45
+ DEBOUNCE_MS: 150,
46
+ MIN_DEBOUNCE_MS: 50,
47
+ MAX_RETRIES: 0
46
48
  };
47
49
  var PATTERNS = {
48
50
  DIGITS_1_3: /^[0-9]{1,3}$/
@@ -56,17 +58,28 @@ var INITIAL_STATE = {
56
58
  isValid: false,
57
59
  isError: false,
58
60
  isLoading: false,
61
+ hasMore: false,
59
62
  selectedSuggestionIndex: -1
60
63
  };
61
64
  var InferCore = class {
65
+ /**
66
+ * Initializes a new instance of the Infer engine.
67
+ * @param config The configuration object including API keys and callbacks.
68
+ */
62
69
  constructor(config) {
63
70
  __publicField(this, "country");
64
71
  __publicField(this, "authKey");
65
72
  __publicField(this, "apiUrl");
66
- __publicField(this, "limit");
73
+ __publicField(this, "baseLimit");
74
+ __publicField(this, "currentLimit");
75
+ __publicField(this, "maxRetries");
67
76
  __publicField(this, "fetcher");
68
77
  __publicField(this, "onStateChange");
69
78
  __publicField(this, "onSelect");
79
+ /**
80
+ * The current read-only state of the engine.
81
+ * Use `onStateChange` to react to updates.
82
+ */
70
83
  __publicField(this, "state");
71
84
  __publicField(this, "abortController", null);
72
85
  __publicField(this, "debouncedFetch");
@@ -74,35 +87,61 @@ var InferCore = class {
74
87
  this.country = config.country;
75
88
  this.authKey = config.authKey;
76
89
  this.apiUrl = config.apiUrl || DEFAULTS.API_URL;
77
- 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));
78
94
  this.fetcher = config.fetcher || ((url, init) => fetch(url, init));
79
95
  this.onStateChange = config.onStateChange || (() => {
80
96
  });
81
97
  this.onSelect = config.onSelect || (() => {
82
98
  });
83
99
  this.state = { ...INITIAL_STATE };
84
- this.debouncedFetch = this.debounce(
85
- (val) => this.executeFetch(val),
86
- DEFAULTS.DEBOUNCE_MS
87
- );
100
+ const configDebounce = config.debounceMs !== void 0 ? config.debounceMs : DEFAULTS.DEBOUNCE_MS;
101
+ const debounceTime = Math.max(configDebounce, DEFAULTS.MIN_DEBOUNCE_MS);
102
+ this.debouncedFetch = this.debounce((val) => this.executeFetch(val), debounceTime);
88
103
  }
104
+ /**
105
+ * Processes new text input from the user.
106
+ * Triggers a debounced API request and updates the internal state.
107
+ * @param value The raw string from the input field.
108
+ */
89
109
  handleInput(value) {
90
110
  if (this.isSelecting) {
91
111
  this.isSelecting = false;
92
112
  return;
93
113
  }
114
+ this.currentLimit = this.baseLimit;
94
115
  const isEditingFinal = this.state.stage === "final" && value !== this.state.query;
95
116
  this.updateState({
96
117
  query: value,
97
118
  isValid: false,
98
119
  isLoading: !!value.trim(),
99
- selectedSuggestionIndex: -1
120
+ selectedSuggestionIndex: -1,
121
+ hasMore: false
100
122
  });
101
123
  if (isEditingFinal) {
102
124
  this.onSelect(null);
103
125
  }
104
126
  this.debouncedFetch(value);
105
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
+ }
137
+ /**
138
+ * Handles keyboard events for the input field.
139
+ * Supports:
140
+ * - `ArrowUp`/`ArrowDown`: Navigate through the suggestion list.
141
+ * - `Enter`: Select the currently highlighted suggestion.
142
+ * - `Space`: Automatically inserts a comma if a numeric house number is detected.
143
+ * @param event The keyboard event from the input element.
144
+ */
106
145
  handleKeyDown(event) {
107
146
  const target = event.target;
108
147
  if (!target) return;
@@ -144,6 +183,11 @@ var InferCore = class {
144
183
  this.updateQueryAndFetch(next);
145
184
  }
146
185
  }
186
+ /**
187
+ * Manually selects a suggestion or a string value.
188
+ * This is typically called when a user clicks a suggestion in the UI.
189
+ * @param item The suggestion object or string to select.
190
+ */
147
191
  selectItem(item) {
148
192
  this.debouncedFetch.cancel();
149
193
  if (this.abortController) {
@@ -188,7 +232,8 @@ var InferCore = class {
188
232
  cities: [],
189
233
  streets: [],
190
234
  isValid: true,
191
- stage: "final"
235
+ stage: "final",
236
+ hasMore: false
192
237
  });
193
238
  this.onSelect(value || label);
194
239
  setTimeout(() => {
@@ -225,32 +270,53 @@ var InferCore = class {
225
270
  }
226
271
  this.updateQueryAndFetch(nextQuery);
227
272
  }
228
- executeFetch(val) {
273
+ executeFetch(val, attempt = 0) {
229
274
  const text = (val || "").toString();
230
275
  if (!text.trim()) {
231
276
  this.abortController?.abort();
232
277
  this.resetState();
233
278
  return;
234
279
  }
235
- this.updateState({ isError: false });
236
- if (this.abortController) this.abortController.abort();
237
- 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;
238
286
  const url = new URL(`${this.apiUrl}/infer/${this.country.toLowerCase()}`);
239
287
  const params = {
240
288
  authKey: this.authKey,
241
289
  query: text,
242
- limit: this.limit.toString()
290
+ limit: this.currentLimit.toString()
243
291
  };
244
292
  url.search = new URLSearchParams(params).toString();
245
- this.fetcher(url.toString(), { signal: this.abortController.signal }).then((res) => {
246
- 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
+ }
247
300
  return res.json();
248
- }).then((data) => this.mapResponseToState(data)).catch((e) => {
249
- if (e.name !== "AbortError") {
250
- 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);
251
307
  }
308
+ this.updateState({ isError: true, isLoading: false });
252
309
  });
253
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
+ }
254
320
  mapResponseToState(data) {
255
321
  const newState = {
256
322
  stage: data.stage,
@@ -268,6 +334,8 @@ var InferCore = class {
268
334
  uniqueSuggestions.push(item);
269
335
  }
270
336
  }
337
+ const totalCount = uniqueSuggestions.length + (data.cities?.length || 0) + (data.streets?.length || 0);
338
+ newState.hasMore = totalCount >= this.currentLimit;
271
339
  if (data.stage === "mixed") {
272
340
  newState.cities = data.cities || [];
273
341
  newState.streets = data.streets || [];
@@ -288,6 +356,7 @@ var InferCore = class {
288
356
  newState.cities = [];
289
357
  newState.streets = [];
290
358
  newState.isValid = true;
359
+ newState.hasMore = false;
291
360
  this.updateState(newState);
292
361
  const val = typeof autoSelectItem.value === "object" ? autoSelectItem.value : autoSelectItem.label;
293
362
  this.onSelect(val);
@@ -297,7 +366,7 @@ var InferCore = class {
297
366
  }
298
367
  updateQueryAndFetch(nextQuery) {
299
368
  this.updateState({ query: nextQuery, suggestions: [], cities: [], streets: [] });
300
- this.updateState({ isLoading: true, isValid: false });
369
+ this.updateState({ isLoading: true, isValid: false, hasMore: false });
301
370
  this.debouncedFetch(nextQuery);
302
371
  setTimeout(() => {
303
372
  this.isSelecting = false;
@@ -352,6 +421,7 @@ var DEFAULT_STYLES = `
352
421
  .pro6pp-input {
353
422
  width: 100%;
354
423
  padding: 10px 12px;
424
+ padding-right: 48px;
355
425
  border: 1px solid #e0e0e0;
356
426
  border-radius: 4px;
357
427
  font-size: 16px;
@@ -363,6 +433,57 @@ var DEFAULT_STYLES = `
363
433
  border-color: #3b82f6;
364
434
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
365
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
+
366
487
  .pro6pp-dropdown {
367
488
  position: absolute;
368
489
  top: 100%;
@@ -373,30 +494,32 @@ var DEFAULT_STYLES = `
373
494
  background: white;
374
495
  border: 1px solid #e0e0e0;
375
496
  border-radius: 4px;
376
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
497
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
377
498
  max-height: 300px;
378
499
  overflow-y: auto;
500
+ display: flex;
501
+ flex-direction: column;
502
+ }
503
+ .pro6pp-list {
379
504
  list-style: none !important;
380
505
  padding: 0 !important;
381
506
  margin: 0 !important;
382
- overflow: hidden;
507
+ flex-grow: 1;
383
508
  }
384
509
  .pro6pp-item {
385
- padding: 12px 12px 9px 12px;
510
+ padding: 12px 16px;
386
511
  cursor: pointer;
387
512
  display: flex;
388
513
  flex-direction: row;
389
514
  align-items: center;
390
- color: #000000;
515
+ color: #111827;
391
516
  font-size: 14px;
392
- line-height: 1;
517
+ line-height: 1.2;
393
518
  white-space: nowrap;
394
519
  overflow: hidden;
395
- border-radius: 0 !important;
396
- margin: 0 !important;
397
520
  }
398
521
  .pro6pp-item:hover, .pro6pp-item--active {
399
- background-color: #f5f5f5;
522
+ background-color: #f9fafb;
400
523
  }
401
524
  .pro6pp-item__label {
402
525
  font-weight: 500;
@@ -404,7 +527,7 @@ var DEFAULT_STYLES = `
404
527
  }
405
528
  .pro6pp-item__subtitle {
406
529
  font-size: 14px;
407
- color: #404040;
530
+ color: #6b7280;
408
531
  overflow: hidden;
409
532
  text-overflow: ellipsis;
410
533
  flex-shrink: 1;
@@ -413,32 +536,34 @@ var DEFAULT_STYLES = `
413
536
  margin-left: auto;
414
537
  display: flex;
415
538
  align-items: center;
416
- color: #a3a3a3;
539
+ color: #9ca3af;
417
540
  padding-left: 8px;
418
541
  }
419
542
  .pro6pp-no-results {
420
- padding: 12px;
421
- color: #555555;
543
+ padding: 16px;
544
+ color: #6b7280;
422
545
  font-size: 14px;
423
546
  text-align: center;
424
- user-select: none;
425
- pointer-events: none;
426
547
  }
427
- .pro6pp-loader {
428
- position: absolute;
429
- right: 12px;
430
- top: 50%;
431
- transform: translateY(-50%);
432
- width: 16px;
433
- height: 16px;
434
- border: 2px solid #e0e0e0;
435
- border-top-color: #404040;
436
- border-radius: 50%;
437
- animation: pro6pp-spin 0.6s linear infinite;
438
- 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;
560
+ }
561
+ .pro6pp-load-more:hover {
562
+ background-color: #f3f4f6;
439
563
  }
564
+
440
565
  @keyframes pro6pp-spin {
441
- to { transform: translateY(-50%) rotate(360deg); }
566
+ to { transform: rotate(360deg); }
442
567
  }
443
568
  `;
444
569
 
@@ -455,16 +580,22 @@ function useInfer(config) {
455
580
  }
456
581
  }
457
582
  });
458
- }, [config.country, config.authKey, config.limit]);
583
+ }, [config.country, config.authKey, config.limit, config.debounceMs, config.maxRetries]);
459
584
  return {
585
+ /** The current UI state (suggestions, loading status, query, etc.). */
460
586
  state,
587
+ /** The raw InferCore instance for manual control. */
461
588
  core,
589
+ /** Pre-configured event handlers to spread onto an <input /> element. */
462
590
  inputProps: {
463
591
  value: state.query,
464
592
  onChange: (e) => core.handleInput(e.target.value),
465
593
  onKeyDown: (e) => core.handleKeyDown(e)
466
594
  },
467
- selectItem: (item) => core.selectItem(item)
595
+ /** Function to manually select a specific suggestion. */
596
+ selectItem: (item) => core.selectItem(item),
597
+ /** Function to load more results. */
598
+ loadMore: () => core.loadMore()
468
599
  };
469
600
  }
470
601
  var Pro6PPInfer = ({
@@ -475,11 +606,15 @@ var Pro6PPInfer = ({
475
606
  renderItem,
476
607
  disableDefaultStyles = false,
477
608
  noResultsText = "No results found",
609
+ loadMoreText = "Show more results...",
478
610
  renderNoResults,
611
+ showClearButton = true,
479
612
  ...config
480
613
  }) => {
481
- const { state, selectItem, inputProps: coreInputProps } = useInfer(config);
614
+ const { state, selectItem, loadMore, inputProps: coreInputProps, core } = useInfer(config);
615
+ const [isOpen, setIsOpen] = (0, import_react.useState)(false);
482
616
  const inputRef = (0, import_react.useRef)(null);
617
+ const wrapperRef = (0, import_react.useRef)(null);
483
618
  (0, import_react.useEffect)(() => {
484
619
  if (disableDefaultStyles) return;
485
620
  const styleId = "pro6pp-styles";
@@ -490,6 +625,15 @@ var Pro6PPInfer = ({
490
625
  document.head.appendChild(styleEl);
491
626
  }
492
627
  }, [disableDefaultStyles]);
628
+ (0, import_react.useEffect)(() => {
629
+ const handleClickOutside = (event) => {
630
+ if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
631
+ setIsOpen(false);
632
+ }
633
+ };
634
+ document.addEventListener("mousedown", handleClickOutside);
635
+ return () => document.removeEventListener("mousedown", handleClickOutside);
636
+ }, []);
493
637
  const items = (0, import_react.useMemo)(() => {
494
638
  return [
495
639
  ...state.cities.map((c) => ({ ...c, type: "city" })),
@@ -499,14 +643,21 @@ var Pro6PPInfer = ({
499
643
  }, [state.cities, state.streets, state.suggestions]);
500
644
  const handleSelect = (item) => {
501
645
  selectItem(item);
646
+ setIsOpen(false);
502
647
  if (!state.isValid && inputRef.current) {
503
648
  inputRef.current.focus();
504
649
  }
505
650
  };
651
+ const handleClear = () => {
652
+ core.handleInput("");
653
+ if (inputRef.current) {
654
+ inputRef.current.focus();
655
+ }
656
+ };
506
657
  const hasResults = items.length > 0;
507
658
  const showNoResults = !state.isLoading && !state.isError && state.query.length > 0 && !hasResults && !state.isValid;
508
- const showDropdown = hasResults || showNoResults;
509
- return /* @__PURE__ */ import_react.default.createElement("div", { className: `pro6pp-wrapper ${className || ""}`, style }, /* @__PURE__ */ import_react.default.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ import_react.default.createElement(
659
+ const showDropdown = isOpen && (hasResults || showNoResults);
660
+ return /* @__PURE__ */ import_react.default.createElement("div", { ref: wrapperRef, className: `pro6pp-wrapper ${className || ""}`, style }, /* @__PURE__ */ import_react.default.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ import_react.default.createElement(
510
661
  "input",
511
662
  {
512
663
  ref: inputRef,
@@ -515,38 +666,85 @@ var Pro6PPInfer = ({
515
666
  placeholder,
516
667
  autoComplete: "off",
517
668
  ...inputProps,
518
- ...coreInputProps
669
+ ...coreInputProps,
670
+ onFocus: (e) => {
671
+ setIsOpen(true);
672
+ inputProps?.onFocus?.(e);
673
+ }
519
674
  }
520
- ), 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) => {
521
- const isActive = index === state.selectedSuggestionIndex;
522
- const secondaryText = item.subtitle || item.count;
523
- const showChevron = item.value === void 0 || item.value === null;
524
- return /* @__PURE__ */ import_react.default.createElement(
525
- "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",
526
685
  {
527
- key: `${item.label}-${index}`,
528
- role: "option",
529
- "aria-selected": isActive,
530
- className: `pro6pp-item ${isActive ? "pro6pp-item--active" : ""}`,
531
- onMouseDown: (e) => e.preventDefault(),
532
- 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"
533
694
  },
534
- 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(
535
- "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",
536
711
  {
537
- width: "16",
538
- height: "16",
539
- viewBox: "0 0 24 24",
540
- fill: "none",
541
- stroke: "currentColor",
542
- strokeWidth: "2",
543
- strokeLinecap: "round",
544
- 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)
545
718
  },
546
- /* @__PURE__ */ import_react.default.createElement("polyline", { points: "9 18 15 12 9 6" })
547
- )))
548
- );
549
- }) : /* @__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
+ ));
550
748
  };
551
749
  // Annotate the CommonJS export names for ESM import in node:
552
750
  0 && (module.exports = {
package/dist/index.d.cts CHANGED
@@ -2,26 +2,67 @@ import React from 'react';
2
2
  import { InferConfig, InferState, InferCore, InferResult } from '@pro6pp/infer-core';
3
3
  export { AddressValue, CountryCode, Fetcher, InferConfig, InferResult, InferState, Stage } from '@pro6pp/infer-core';
4
4
 
5
+ /**
6
+ * A headless React hook that provides the logic for address search using the Infer API.
7
+ * @param config The engine configuration (authKey, country, etc.).
8
+ * @returns An object containing the current state, the core instance, and pre-bound input props.
9
+ */
5
10
  declare function useInfer(config: InferConfig): {
11
+ /** The current UI state (suggestions, loading status, query, etc.). */
6
12
  state: InferState;
13
+ /** The raw InferCore instance for manual control. */
7
14
  core: InferCore;
15
+ /** Pre-configured event handlers to spread onto an <input /> element. */
8
16
  inputProps: {
9
17
  value: string;
10
18
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
11
19
  onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
12
20
  };
21
+ /** Function to manually select a specific suggestion. */
13
22
  selectItem: (item: InferResult | string) => void;
23
+ /** Function to load more results. */
24
+ loadMore: () => void;
14
25
  };
26
+ /**
27
+ * Props for the Pro6PPInfer component.
28
+ */
15
29
  interface Pro6PPInferProps extends InferConfig {
30
+ /** Optional CSS class for the wrapper div. */
16
31
  className?: string;
32
+ /** Optional inline styles for the wrapper div. */
17
33
  style?: React.CSSProperties;
34
+ /** Attributes to pass directly to the underlying input element. */
18
35
  inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
36
+ /** * Custom placeholder text.
37
+ * @default 'Start typing an address...'
38
+ */
19
39
  placeholder?: string;
40
+ /** A custom render function for individual suggestion items. */
20
41
  renderItem?: (item: InferResult, isActive: boolean) => React.ReactNode;
42
+ /** * If true, prevents the default CSS theme from being injected.
43
+ * @default false
44
+ */
21
45
  disableDefaultStyles?: boolean;
46
+ /** * The text to show when no results are found.
47
+ * @default 'No results found'
48
+ */
22
49
  noResultsText?: string;
50
+ /** * The text to show on the load more button.
51
+ * @default 'Show more results...'
52
+ */
53
+ loadMoreText?: string;
54
+ /** A custom render function for the "no results" state. */
23
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;
24
61
  }
62
+ /**
63
+ * A styled React component for Pro6PP Infer API.
64
+ * Includes styling, keyboard navigation, and loading states.
65
+ */
25
66
  declare const Pro6PPInfer: React.FC<Pro6PPInferProps>;
26
67
 
27
68
  export { Pro6PPInfer, type Pro6PPInferProps, useInfer };
package/dist/index.d.ts CHANGED
@@ -2,26 +2,67 @@ import React from 'react';
2
2
  import { InferConfig, InferState, InferCore, InferResult } from '@pro6pp/infer-core';
3
3
  export { AddressValue, CountryCode, Fetcher, InferConfig, InferResult, InferState, Stage } from '@pro6pp/infer-core';
4
4
 
5
+ /**
6
+ * A headless React hook that provides the logic for address search using the Infer API.
7
+ * @param config The engine configuration (authKey, country, etc.).
8
+ * @returns An object containing the current state, the core instance, and pre-bound input props.
9
+ */
5
10
  declare function useInfer(config: InferConfig): {
11
+ /** The current UI state (suggestions, loading status, query, etc.). */
6
12
  state: InferState;
13
+ /** The raw InferCore instance for manual control. */
7
14
  core: InferCore;
15
+ /** Pre-configured event handlers to spread onto an <input /> element. */
8
16
  inputProps: {
9
17
  value: string;
10
18
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
11
19
  onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
12
20
  };
21
+ /** Function to manually select a specific suggestion. */
13
22
  selectItem: (item: InferResult | string) => void;
23
+ /** Function to load more results. */
24
+ loadMore: () => void;
14
25
  };
26
+ /**
27
+ * Props for the Pro6PPInfer component.
28
+ */
15
29
  interface Pro6PPInferProps extends InferConfig {
30
+ /** Optional CSS class for the wrapper div. */
16
31
  className?: string;
32
+ /** Optional inline styles for the wrapper div. */
17
33
  style?: React.CSSProperties;
34
+ /** Attributes to pass directly to the underlying input element. */
18
35
  inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
36
+ /** * Custom placeholder text.
37
+ * @default 'Start typing an address...'
38
+ */
19
39
  placeholder?: string;
40
+ /** A custom render function for individual suggestion items. */
20
41
  renderItem?: (item: InferResult, isActive: boolean) => React.ReactNode;
42
+ /** * If true, prevents the default CSS theme from being injected.
43
+ * @default false
44
+ */
21
45
  disableDefaultStyles?: boolean;
46
+ /** * The text to show when no results are found.
47
+ * @default 'No results found'
48
+ */
22
49
  noResultsText?: string;
50
+ /** * The text to show on the load more button.
51
+ * @default 'Show more results...'
52
+ */
53
+ loadMoreText?: string;
54
+ /** A custom render function for the "no results" state. */
23
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;
24
61
  }
62
+ /**
63
+ * A styled React component for Pro6PP Infer API.
64
+ * Includes styling, keyboard navigation, and loading states.
65
+ */
25
66
  declare const Pro6PPInfer: React.FC<Pro6PPInferProps>;
26
67
 
27
68
  export { Pro6PPInfer, type Pro6PPInferProps, useInfer };
package/dist/index.js CHANGED
@@ -8,8 +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,
12
- DEBOUNCE_MS: 300
11
+ LIMIT: 20,
12
+ DEBOUNCE_MS: 150,
13
+ MIN_DEBOUNCE_MS: 50,
14
+ MAX_RETRIES: 0
13
15
  };
14
16
  var PATTERNS = {
15
17
  DIGITS_1_3: /^[0-9]{1,3}$/
@@ -23,17 +25,28 @@ var INITIAL_STATE = {
23
25
  isValid: false,
24
26
  isError: false,
25
27
  isLoading: false,
28
+ hasMore: false,
26
29
  selectedSuggestionIndex: -1
27
30
  };
28
31
  var InferCore = class {
32
+ /**
33
+ * Initializes a new instance of the Infer engine.
34
+ * @param config The configuration object including API keys and callbacks.
35
+ */
29
36
  constructor(config) {
30
37
  __publicField(this, "country");
31
38
  __publicField(this, "authKey");
32
39
  __publicField(this, "apiUrl");
33
- __publicField(this, "limit");
40
+ __publicField(this, "baseLimit");
41
+ __publicField(this, "currentLimit");
42
+ __publicField(this, "maxRetries");
34
43
  __publicField(this, "fetcher");
35
44
  __publicField(this, "onStateChange");
36
45
  __publicField(this, "onSelect");
46
+ /**
47
+ * The current read-only state of the engine.
48
+ * Use `onStateChange` to react to updates.
49
+ */
37
50
  __publicField(this, "state");
38
51
  __publicField(this, "abortController", null);
39
52
  __publicField(this, "debouncedFetch");
@@ -41,35 +54,61 @@ var InferCore = class {
41
54
  this.country = config.country;
42
55
  this.authKey = config.authKey;
43
56
  this.apiUrl = config.apiUrl || DEFAULTS.API_URL;
44
- 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));
45
61
  this.fetcher = config.fetcher || ((url, init) => fetch(url, init));
46
62
  this.onStateChange = config.onStateChange || (() => {
47
63
  });
48
64
  this.onSelect = config.onSelect || (() => {
49
65
  });
50
66
  this.state = { ...INITIAL_STATE };
51
- this.debouncedFetch = this.debounce(
52
- (val) => this.executeFetch(val),
53
- DEFAULTS.DEBOUNCE_MS
54
- );
67
+ const configDebounce = config.debounceMs !== void 0 ? config.debounceMs : DEFAULTS.DEBOUNCE_MS;
68
+ const debounceTime = Math.max(configDebounce, DEFAULTS.MIN_DEBOUNCE_MS);
69
+ this.debouncedFetch = this.debounce((val) => this.executeFetch(val), debounceTime);
55
70
  }
71
+ /**
72
+ * Processes new text input from the user.
73
+ * Triggers a debounced API request and updates the internal state.
74
+ * @param value The raw string from the input field.
75
+ */
56
76
  handleInput(value) {
57
77
  if (this.isSelecting) {
58
78
  this.isSelecting = false;
59
79
  return;
60
80
  }
81
+ this.currentLimit = this.baseLimit;
61
82
  const isEditingFinal = this.state.stage === "final" && value !== this.state.query;
62
83
  this.updateState({
63
84
  query: value,
64
85
  isValid: false,
65
86
  isLoading: !!value.trim(),
66
- selectedSuggestionIndex: -1
87
+ selectedSuggestionIndex: -1,
88
+ hasMore: false
67
89
  });
68
90
  if (isEditingFinal) {
69
91
  this.onSelect(null);
70
92
  }
71
93
  this.debouncedFetch(value);
72
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
+ }
104
+ /**
105
+ * Handles keyboard events for the input field.
106
+ * Supports:
107
+ * - `ArrowUp`/`ArrowDown`: Navigate through the suggestion list.
108
+ * - `Enter`: Select the currently highlighted suggestion.
109
+ * - `Space`: Automatically inserts a comma if a numeric house number is detected.
110
+ * @param event The keyboard event from the input element.
111
+ */
73
112
  handleKeyDown(event) {
74
113
  const target = event.target;
75
114
  if (!target) return;
@@ -111,6 +150,11 @@ var InferCore = class {
111
150
  this.updateQueryAndFetch(next);
112
151
  }
113
152
  }
153
+ /**
154
+ * Manually selects a suggestion or a string value.
155
+ * This is typically called when a user clicks a suggestion in the UI.
156
+ * @param item The suggestion object or string to select.
157
+ */
114
158
  selectItem(item) {
115
159
  this.debouncedFetch.cancel();
116
160
  if (this.abortController) {
@@ -155,7 +199,8 @@ var InferCore = class {
155
199
  cities: [],
156
200
  streets: [],
157
201
  isValid: true,
158
- stage: "final"
202
+ stage: "final",
203
+ hasMore: false
159
204
  });
160
205
  this.onSelect(value || label);
161
206
  setTimeout(() => {
@@ -192,32 +237,53 @@ var InferCore = class {
192
237
  }
193
238
  this.updateQueryAndFetch(nextQuery);
194
239
  }
195
- executeFetch(val) {
240
+ executeFetch(val, attempt = 0) {
196
241
  const text = (val || "").toString();
197
242
  if (!text.trim()) {
198
243
  this.abortController?.abort();
199
244
  this.resetState();
200
245
  return;
201
246
  }
202
- this.updateState({ isError: false });
203
- if (this.abortController) this.abortController.abort();
204
- 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;
205
253
  const url = new URL(`${this.apiUrl}/infer/${this.country.toLowerCase()}`);
206
254
  const params = {
207
255
  authKey: this.authKey,
208
256
  query: text,
209
- limit: this.limit.toString()
257
+ limit: this.currentLimit.toString()
210
258
  };
211
259
  url.search = new URLSearchParams(params).toString();
212
- this.fetcher(url.toString(), { signal: this.abortController.signal }).then((res) => {
213
- 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
+ }
214
267
  return res.json();
215
- }).then((data) => this.mapResponseToState(data)).catch((e) => {
216
- if (e.name !== "AbortError") {
217
- 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);
218
274
  }
275
+ this.updateState({ isError: true, isLoading: false });
219
276
  });
220
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
+ }
221
287
  mapResponseToState(data) {
222
288
  const newState = {
223
289
  stage: data.stage,
@@ -235,6 +301,8 @@ var InferCore = class {
235
301
  uniqueSuggestions.push(item);
236
302
  }
237
303
  }
304
+ const totalCount = uniqueSuggestions.length + (data.cities?.length || 0) + (data.streets?.length || 0);
305
+ newState.hasMore = totalCount >= this.currentLimit;
238
306
  if (data.stage === "mixed") {
239
307
  newState.cities = data.cities || [];
240
308
  newState.streets = data.streets || [];
@@ -255,6 +323,7 @@ var InferCore = class {
255
323
  newState.cities = [];
256
324
  newState.streets = [];
257
325
  newState.isValid = true;
326
+ newState.hasMore = false;
258
327
  this.updateState(newState);
259
328
  const val = typeof autoSelectItem.value === "object" ? autoSelectItem.value : autoSelectItem.label;
260
329
  this.onSelect(val);
@@ -264,7 +333,7 @@ var InferCore = class {
264
333
  }
265
334
  updateQueryAndFetch(nextQuery) {
266
335
  this.updateState({ query: nextQuery, suggestions: [], cities: [], streets: [] });
267
- this.updateState({ isLoading: true, isValid: false });
336
+ this.updateState({ isLoading: true, isValid: false, hasMore: false });
268
337
  this.debouncedFetch(nextQuery);
269
338
  setTimeout(() => {
270
339
  this.isSelecting = false;
@@ -319,6 +388,7 @@ var DEFAULT_STYLES = `
319
388
  .pro6pp-input {
320
389
  width: 100%;
321
390
  padding: 10px 12px;
391
+ padding-right: 48px;
322
392
  border: 1px solid #e0e0e0;
323
393
  border-radius: 4px;
324
394
  font-size: 16px;
@@ -330,6 +400,57 @@ var DEFAULT_STYLES = `
330
400
  border-color: #3b82f6;
331
401
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
332
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
+
333
454
  .pro6pp-dropdown {
334
455
  position: absolute;
335
456
  top: 100%;
@@ -340,30 +461,32 @@ var DEFAULT_STYLES = `
340
461
  background: white;
341
462
  border: 1px solid #e0e0e0;
342
463
  border-radius: 4px;
343
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
464
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
344
465
  max-height: 300px;
345
466
  overflow-y: auto;
467
+ display: flex;
468
+ flex-direction: column;
469
+ }
470
+ .pro6pp-list {
346
471
  list-style: none !important;
347
472
  padding: 0 !important;
348
473
  margin: 0 !important;
349
- overflow: hidden;
474
+ flex-grow: 1;
350
475
  }
351
476
  .pro6pp-item {
352
- padding: 12px 12px 9px 12px;
477
+ padding: 12px 16px;
353
478
  cursor: pointer;
354
479
  display: flex;
355
480
  flex-direction: row;
356
481
  align-items: center;
357
- color: #000000;
482
+ color: #111827;
358
483
  font-size: 14px;
359
- line-height: 1;
484
+ line-height: 1.2;
360
485
  white-space: nowrap;
361
486
  overflow: hidden;
362
- border-radius: 0 !important;
363
- margin: 0 !important;
364
487
  }
365
488
  .pro6pp-item:hover, .pro6pp-item--active {
366
- background-color: #f5f5f5;
489
+ background-color: #f9fafb;
367
490
  }
368
491
  .pro6pp-item__label {
369
492
  font-weight: 500;
@@ -371,7 +494,7 @@ var DEFAULT_STYLES = `
371
494
  }
372
495
  .pro6pp-item__subtitle {
373
496
  font-size: 14px;
374
- color: #404040;
497
+ color: #6b7280;
375
498
  overflow: hidden;
376
499
  text-overflow: ellipsis;
377
500
  flex-shrink: 1;
@@ -380,32 +503,34 @@ var DEFAULT_STYLES = `
380
503
  margin-left: auto;
381
504
  display: flex;
382
505
  align-items: center;
383
- color: #a3a3a3;
506
+ color: #9ca3af;
384
507
  padding-left: 8px;
385
508
  }
386
509
  .pro6pp-no-results {
387
- padding: 12px;
388
- color: #555555;
510
+ padding: 16px;
511
+ color: #6b7280;
389
512
  font-size: 14px;
390
513
  text-align: center;
391
- user-select: none;
392
- pointer-events: none;
393
514
  }
394
- .pro6pp-loader {
395
- position: absolute;
396
- right: 12px;
397
- top: 50%;
398
- transform: translateY(-50%);
399
- width: 16px;
400
- height: 16px;
401
- border: 2px solid #e0e0e0;
402
- border-top-color: #404040;
403
- border-radius: 50%;
404
- animation: pro6pp-spin 0.6s linear infinite;
405
- 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;
527
+ }
528
+ .pro6pp-load-more:hover {
529
+ background-color: #f3f4f6;
406
530
  }
531
+
407
532
  @keyframes pro6pp-spin {
408
- to { transform: translateY(-50%) rotate(360deg); }
533
+ to { transform: rotate(360deg); }
409
534
  }
410
535
  `;
411
536
 
@@ -422,16 +547,22 @@ function useInfer(config) {
422
547
  }
423
548
  }
424
549
  });
425
- }, [config.country, config.authKey, config.limit]);
550
+ }, [config.country, config.authKey, config.limit, config.debounceMs, config.maxRetries]);
426
551
  return {
552
+ /** The current UI state (suggestions, loading status, query, etc.). */
427
553
  state,
554
+ /** The raw InferCore instance for manual control. */
428
555
  core,
556
+ /** Pre-configured event handlers to spread onto an <input /> element. */
429
557
  inputProps: {
430
558
  value: state.query,
431
559
  onChange: (e) => core.handleInput(e.target.value),
432
560
  onKeyDown: (e) => core.handleKeyDown(e)
433
561
  },
434
- selectItem: (item) => core.selectItem(item)
562
+ /** Function to manually select a specific suggestion. */
563
+ selectItem: (item) => core.selectItem(item),
564
+ /** Function to load more results. */
565
+ loadMore: () => core.loadMore()
435
566
  };
436
567
  }
437
568
  var Pro6PPInfer = ({
@@ -442,11 +573,15 @@ var Pro6PPInfer = ({
442
573
  renderItem,
443
574
  disableDefaultStyles = false,
444
575
  noResultsText = "No results found",
576
+ loadMoreText = "Show more results...",
445
577
  renderNoResults,
578
+ showClearButton = true,
446
579
  ...config
447
580
  }) => {
448
- const { state, selectItem, inputProps: coreInputProps } = useInfer(config);
581
+ const { state, selectItem, loadMore, inputProps: coreInputProps, core } = useInfer(config);
582
+ const [isOpen, setIsOpen] = useState(false);
449
583
  const inputRef = useRef(null);
584
+ const wrapperRef = useRef(null);
450
585
  useEffect(() => {
451
586
  if (disableDefaultStyles) return;
452
587
  const styleId = "pro6pp-styles";
@@ -457,6 +592,15 @@ var Pro6PPInfer = ({
457
592
  document.head.appendChild(styleEl);
458
593
  }
459
594
  }, [disableDefaultStyles]);
595
+ useEffect(() => {
596
+ const handleClickOutside = (event) => {
597
+ if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
598
+ setIsOpen(false);
599
+ }
600
+ };
601
+ document.addEventListener("mousedown", handleClickOutside);
602
+ return () => document.removeEventListener("mousedown", handleClickOutside);
603
+ }, []);
460
604
  const items = useMemo(() => {
461
605
  return [
462
606
  ...state.cities.map((c) => ({ ...c, type: "city" })),
@@ -466,14 +610,21 @@ var Pro6PPInfer = ({
466
610
  }, [state.cities, state.streets, state.suggestions]);
467
611
  const handleSelect = (item) => {
468
612
  selectItem(item);
613
+ setIsOpen(false);
469
614
  if (!state.isValid && inputRef.current) {
470
615
  inputRef.current.focus();
471
616
  }
472
617
  };
618
+ const handleClear = () => {
619
+ core.handleInput("");
620
+ if (inputRef.current) {
621
+ inputRef.current.focus();
622
+ }
623
+ };
473
624
  const hasResults = items.length > 0;
474
625
  const showNoResults = !state.isLoading && !state.isError && state.query.length > 0 && !hasResults && !state.isValid;
475
- const showDropdown = hasResults || showNoResults;
476
- return /* @__PURE__ */ React.createElement("div", { className: `pro6pp-wrapper ${className || ""}`, style }, /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(
626
+ const showDropdown = isOpen && (hasResults || showNoResults);
627
+ return /* @__PURE__ */ React.createElement("div", { ref: wrapperRef, className: `pro6pp-wrapper ${className || ""}`, style }, /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(
477
628
  "input",
478
629
  {
479
630
  ref: inputRef,
@@ -482,38 +633,85 @@ var Pro6PPInfer = ({
482
633
  placeholder,
483
634
  autoComplete: "off",
484
635
  ...inputProps,
485
- ...coreInputProps
636
+ ...coreInputProps,
637
+ onFocus: (e) => {
638
+ setIsOpen(true);
639
+ inputProps?.onFocus?.(e);
640
+ }
486
641
  }
487
- ), state.isLoading && /* @__PURE__ */ React.createElement("div", { className: "pro6pp-loader" })), showDropdown && /* @__PURE__ */ React.createElement("ul", { className: "pro6pp-dropdown", role: "listbox" }, hasResults ? items.map((item, index) => {
488
- const isActive = index === state.selectedSuggestionIndex;
489
- const secondaryText = item.subtitle || item.count;
490
- const showChevron = item.value === void 0 || item.value === null;
491
- return /* @__PURE__ */ React.createElement(
492
- "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",
493
652
  {
494
- key: `${item.label}-${index}`,
495
- role: "option",
496
- "aria-selected": isActive,
497
- className: `pro6pp-item ${isActive ? "pro6pp-item--active" : ""}`,
498
- onMouseDown: (e) => e.preventDefault(),
499
- 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"
500
661
  },
501
- 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(
502
- "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",
503
678
  {
504
- width: "16",
505
- height: "16",
506
- viewBox: "0 0 24 24",
507
- fill: "none",
508
- stroke: "currentColor",
509
- strokeWidth: "2",
510
- strokeLinecap: "round",
511
- 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)
512
685
  },
513
- /* @__PURE__ */ React.createElement("polyline", { points: "9 18 15 12 9 6" })
514
- )))
515
- );
516
- }) : /* @__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
+ ));
517
715
  };
518
716
  export {
519
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.7",
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.5"
49
+ "@pro6pp/infer-core": "0.0.2-beta.7"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@testing-library/dom": "^10.4.1",