@skyscanner/backpack-web 40.2.1 → 40.2.2

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.
@@ -79,8 +79,23 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
79
79
  const originalInputOnPreviewRef = useRef(null);
80
80
  const hasInteractedRef = useRef(false);
81
81
  const hasLoadedInitiallyRef = useRef(false);
82
+ const committedSelectionRef = useRef(false);
83
+ const savedHighlightedIndexRef = useRef(null);
82
84
  const suggestionsCount = suggestions.length;
83
85
  const hasSuggestions = suggestionsCount > 0;
86
+ const flattenedSuggestions = multiSection ? suggestions.flatMap(section => getSectionSuggestions?.(section) ?? []) : suggestions;
87
+ const getTargetHighlightedIndex = (currentHighlightedIndex, isMenuOpening, isArrowKeyNavigation = false) => {
88
+ if (isMenuOpening && !isArrowKeyNavigation && highlightFirstSuggestion && flattenedSuggestions.length > 0) {
89
+ return 0;
90
+ }
91
+ if (currentHighlightedIndex !== null && currentHighlightedIndex !== undefined && currentHighlightedIndex >= 0) {
92
+ return currentHighlightedIndex;
93
+ }
94
+ if (savedHighlightedIndexRef.current !== null && savedHighlightedIndexRef.current >= 0) {
95
+ return savedHighlightedIndexRef.current;
96
+ }
97
+ return currentHighlightedIndex;
98
+ };
84
99
  function stateReducer(state, actionAndChanges) {
85
100
  const {
86
101
  changes,
@@ -93,23 +108,30 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
93
108
  isOpen: true
94
109
  };
95
110
  }
111
+ const isMenuOpening = changes.isOpen === true && state.isOpen === false;
96
112
  switch (type) {
97
113
  case useCombobox.stateChangeTypes.InputClick:
98
- return {
99
- ...changes,
100
- isOpen: state.isOpen
101
- };
114
+ {
115
+ const targetHighlightedIndex = getTargetHighlightedIndex(state.highlightedIndex, isMenuOpening);
116
+ return {
117
+ ...changes,
118
+ isOpen: state.isOpen,
119
+ highlightedIndex: targetHighlightedIndex ?? -1
120
+ };
121
+ }
102
122
  default:
103
123
  {
104
124
  const forceOpen = !isDesktop && !!changes.inputValue;
125
+ const isArrowKeyNavigation = type === useCombobox.stateChangeTypes.InputKeyDownArrowDown || type === useCombobox.stateChangeTypes.InputKeyDownArrowUp;
126
+ const targetHighlightedIndex = getTargetHighlightedIndex(changes.highlightedIndex, isMenuOpening, isArrowKeyNavigation);
105
127
  return {
106
128
  ...changes,
107
- isOpen: forceOpen ? true : changes.isOpen
129
+ isOpen: forceOpen ? true : changes.isOpen,
130
+ highlightedIndex: targetHighlightedIndex ?? changes.highlightedIndex
108
131
  };
109
132
  }
110
133
  }
111
134
  }
112
- const flattenedSuggestions = multiSection ? suggestions.flatMap(section => getSectionSuggestions?.(section) ?? []) : suggestions;
113
135
  const {
114
136
  getInputProps,
115
137
  getItemProps,
@@ -119,6 +141,7 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
119
141
  inputValue,
120
142
  isOpen,
121
143
  openMenu,
144
+ selectItem,
122
145
  setInputValue
123
146
  } = useCombobox({
124
147
  stateReducer,
@@ -137,11 +160,13 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
137
160
  newValue: newInputValue ?? ''
138
161
  });
139
162
  if (type === useCombobox.stateChangeTypes.InputChange) {
140
- if (newInputValue?.length > 0) {
163
+ if (newInputValue && newInputValue.length > 0) {
141
164
  if (newIsOpen) {
142
165
  onSuggestionsFetchRequested(newInputValue);
143
166
  }
144
167
  } else {
168
+ // Clear old suggestions before requesting defaults
169
+ onSuggestionsClearRequested?.();
145
170
  onSuggestionsFetchRequested('');
146
171
  }
147
172
  }
@@ -151,11 +176,14 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
151
176
  selectedItem
152
177
  } = changes;
153
178
  if (selectedItem) {
154
- setInputValue(getSuggestionValue(selectedItem));
179
+ const newValue = getSuggestionValue(selectedItem);
180
+ setInputValue(newValue);
155
181
  originalInputOnPreviewRef.current = null;
182
+ committedSelectionRef.current = true;
183
+ hasInteractedRef.current = true;
156
184
  onSuggestionSelected?.({
157
185
  suggestion: selectedItem,
158
- inputValue
186
+ inputValue: newValue
159
187
  });
160
188
  if (alwaysRenderSuggestions) {
161
189
  // Manually clear suggestions or hide menu
@@ -222,11 +250,11 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
222
250
  whileElementsMounted: isDesktop ? autoUpdate : undefined
223
251
  });
224
252
  useEffect(() => {
225
- if (defaultValue) {
226
- setInputValue(defaultValue);
227
- } else {
228
- setInputValue('');
253
+ // Prevent defaultValue from overwriting input after interaction or selection
254
+ if (hasInteractedRef.current || committedSelectionRef.current) {
255
+ return;
229
256
  }
257
+ setInputValue(defaultValue ?? '');
230
258
  }, [defaultValue, setInputValue]);
231
259
  useEffect(() => {
232
260
  if (!isDesktop) {
@@ -244,6 +272,8 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
244
272
  useEffect(() => {
245
273
  if (highlightedIndex === previousHighlightedIndexRef.current) return;
246
274
  previousHighlightedIndexRef.current = highlightedIndex;
275
+ // Save highlighted index to allow auto-selection on blur
276
+ savedHighlightedIndexRef.current = highlightedIndex;
247
277
  const currentSuggestion = highlightedIndex != null && highlightedIndex >= 0 ? flattenedSuggestions?.[highlightedIndex] ?? null : null;
248
278
  if (!currentSuggestion && originalInputOnPreviewRef.current !== null) {
249
279
  if ((inputValue ?? '') !== originalInputOnPreviewRef.current) {
@@ -256,22 +286,40 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
256
286
  });
257
287
  }, [highlightedIndex, flattenedSuggestions, onSuggestionHighlighted, inputValue, setInputValue]);
258
288
  const handleInputInteraction = () => {
289
+ if (isOpen) {
290
+ if (originalInputOnPreviewRef.current !== null) {
291
+ originalInputOnPreviewRef.current = null;
292
+ }
293
+ return;
294
+ }
259
295
  hasInteractedRef.current = true;
296
+ // Reset committed selection to allow auto-select on blur
297
+ committedSelectionRef.current = false;
298
+
299
+ // Keep the preview value (from arrow keys) when clicking the input
300
+ if (originalInputOnPreviewRef.current !== null) {
301
+ originalInputOnPreviewRef.current = null;
302
+ }
303
+ onSuggestionsClearRequested?.();
260
304
  if (shouldRenderSuggestions) {
261
305
  shouldRenderSuggestions(inputValue);
262
- openMenu();
263
306
  }
264
307
  if (!isOpen && inputValue.length) {
265
308
  onSuggestionsFetchRequested(inputValue);
266
309
  openMenu();
267
310
  } else if (alwaysRenderSuggestions && !inputValue) {
268
311
  onSuggestionsFetchRequested('');
312
+ if (!isOpen) {
313
+ openMenu();
314
+ }
315
+ } else if (!isOpen) {
316
+ openMenu();
317
+ onClick?.();
269
318
  } else {
270
319
  onClick?.();
271
320
  }
272
321
 
273
- // Desktop destination autosuggest lives on the homepage and is "loaded/interacted with" via clicking on it
274
- // Every other use case is within a new screen or modal so is interacted with via the user navigating into the modal/new screen
322
+ // Track desktop homepage interaction (mobile interaction is tracked via modal entry)
275
323
  if (isDesktop) {
276
324
  onLoad?.(inputValue);
277
325
  }
@@ -284,9 +332,13 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
284
332
  const clearSuggestions = e => {
285
333
  e?.stopPropagation();
286
334
  setInputValue('');
335
+ onSuggestionsClearRequested?.();
336
+ onSuggestionsFetchRequested('');
337
+ if (!isOpen) {
338
+ openMenu();
339
+ }
287
340
  if (alwaysRenderSuggestions) {
288
341
  hasLoadedInitiallyRef.current = true;
289
- onSuggestionsFetchRequested('');
290
342
  }
291
343
  };
292
344
 
@@ -302,6 +354,11 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
302
354
  const isFirst = globalIndex === 0;
303
355
  const itemId = sectionId ? `item-${sectionIndex}-${localIndex}` : undefined;
304
356
  const isHighlighted = highlightedIndex === globalIndex || highlightFirstSuggestion && isFirst && highlightedIndex === -1;
357
+
358
+ // Build stable unique key (prefer entityId/id, fallback to index)
359
+ const suggestionItem = suggestion;
360
+ const suggestionId = suggestionItem.entityId ?? suggestionItem.id ?? globalIndex;
361
+ const suggestionKey = sectionId ? `${sectionId}-${suggestionId}` : `item-${suggestionId}`;
305
362
  return /*#__PURE__*/_jsx("li", {
306
363
  "aria-labelledby": sectionId && itemId ? `${sectionId} ${itemId}` : undefined,
307
364
  ...getItemProps({
@@ -315,7 +372,7 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
315
372
  id: itemId,
316
373
  children: renderSuggestion(suggestion)
317
374
  }) : renderSuggestion(suggestion)
318
- }, sectionTitle ? `${sectionTitle}-${getSuggestionValue(suggestion)}` : getSuggestionValue(suggestion));
375
+ }, suggestionKey);
319
376
  });
320
377
 
321
378
  // renderSections function to render multi-section suggestions
@@ -366,6 +423,7 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
366
423
  ...restInputProps
367
424
  } = inputProps;
368
425
  const {
426
+ onBlur: downshiftOnBlur,
369
427
  ref: downshiftInputRef,
370
428
  value,
371
429
  ...finalInputProps
@@ -377,7 +435,40 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
377
435
  'aria-label': inputAriaLabel,
378
436
  className: inputClassName || theme.input,
379
437
  ...restInputProps
438
+ }, {
439
+ // Suppress the warning because we manually handle the ref
440
+ suppressRefError: true
380
441
  });
442
+ const handleBlur = e => {
443
+ // Call Downshift's onBlur first
444
+ downshiftOnBlur?.(e);
445
+ // Call original onBlur if provided
446
+ restInputProps.onBlur?.(e);
447
+ if (!isDesktop) {
448
+ return;
449
+ }
450
+ if (committedSelectionRef.current) {
451
+ return;
452
+ }
453
+
454
+ // Auto-select the highlighted suggestion on blur using the saved index
455
+ const savedIndex = savedHighlightedIndexRef.current;
456
+ let highlightedSuggestion = null;
457
+ if (savedIndex != null && savedIndex >= 0 && flattenedSuggestions[savedIndex]) {
458
+ // User highlighted a specific suggestion with arrow keys
459
+ highlightedSuggestion = flattenedSuggestions[savedIndex];
460
+ } else if (highlightFirstSuggestion && flattenedSuggestions.length > 0 && (savedIndex === -1 || savedIndex === null)) {
461
+ // First suggestion is highlighted by default (highlightFirstSuggestion is true)
462
+ const [firstSuggestion] = flattenedSuggestions;
463
+ highlightedSuggestion = firstSuggestion;
464
+ }
465
+ if (highlightedSuggestion) {
466
+ // Use setTimeout to ensure selectItem runs after the blur event completes
467
+ setTimeout(() => {
468
+ selectItem(highlightedSuggestion);
469
+ }, 0);
470
+ }
471
+ };
381
472
  const setInputRef = node => {
382
473
  if (refs.reference?.current === node) return;
383
474
 
@@ -390,10 +481,12 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
390
481
  };
391
482
  if (renderInputComponent) {
392
483
  return renderInputComponent({
484
+ ...finalInputProps,
393
485
  ref: setInputRef,
394
486
  enterKeyHint,
395
- value,
396
- ...finalInputProps
487
+ onBlur: handleBlur,
488
+ onClear: clearSuggestions,
489
+ value: inputValue ?? ''
397
490
  });
398
491
  }
399
492
  return /*#__PURE__*/_jsx("div", {
@@ -418,6 +511,7 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
418
511
  name: inputName || id,
419
512
  id: id,
420
513
  ...finalInputProps,
514
+ onBlur: handleBlur,
421
515
  enterKeyHint: enterKeyHint,
422
516
  onClear: clearSuggestions
423
517
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyscanner/backpack-web",
3
- "version": "40.2.1",
3
+ "version": "40.2.2",
4
4
  "description": "Backpack Design System web library",
5
5
  "repository": {
6
6
  "type": "git",