@k3-tech/react-kit 0.0.56 → 0.0.58

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.
@@ -249,65 +249,91 @@ export function Autocomplete<T = unknown>({
249
249
  });
250
250
  }, [currentValue, options, storeOption]);
251
251
 
252
- // Load selected labels on mount for initial values
253
- const hasLoadedInitial = useRef(false);
254
- const [, setLabelsLoadedCounter] = useState(0);
255
-
252
+ // Tracks selected values currently being resolved via loadSelected. Doubles
253
+ // as the loading indicator and the recompute trigger: removing a value on
254
+ // settle re-renders, and by then its label is already stored in labelMapRef.
255
+ const [loadingSelectedValues, setLoadingSelectedValues] = useState<
256
+ Set<string | number>
257
+ >(() => new Set());
258
+
259
+ // Values with a loadSelected fetch currently in flight. Prevents duplicate
260
+ // concurrent requests when the parent re-renders with a fresh currentValue
261
+ // reference (common in multiple mode) before the first fetch resolves.
262
+ const inFlightSelectedRef = useRef<Set<string | number>>(new Set());
263
+
264
+ // Load selected labels via loadSelected whenever currentValue changes.
265
+ // Covers mount, programmatic value changes (even while the dropdown is
266
+ // closed), and dropdown opens — anything not already cached or resolvable
267
+ // from the local options array.
256
268
  useEffect(() => {
257
- // Only run once on mount
258
- if (!loadSelected || hasLoadedInitial.current) return;
259
- if (!currentValue) return;
260
-
261
- const values = Array.isArray(currentValue) ? currentValue : [currentValue];
262
- if (values.length === 0) return;
263
-
264
- // Check if any values are missing labels
265
- const missing = values.filter((v) => !labelMapRef.current.has(v));
266
- if (missing.length === 0) {
267
- hasLoadedInitial.current = true;
268
- return;
269
- }
269
+ if (!loadSelected) return;
270
270
 
271
- hasLoadedInitial.current = true;
272
- let cancelled = false;
273
- loadSelected(missing)
274
- .then((opts) => {
275
- if (!cancelled && opts.length > 0) {
276
- opts.forEach(storeOption);
277
- // Only trigger re-render if we actually stored something
278
- setLabelsLoadedCounter((c) => c + 1);
279
- }
280
- // eslint-disable-next-line @typescript-eslint/no-empty-function
281
- })
282
- .catch(() => { });
283
- return () => {
284
- cancelled = true;
285
- };
286
- // eslint-disable-next-line react-hooks/exhaustive-deps
287
- }, [loadSelected, storeOption, currentValue]); // Only run once on mount - uses closure values
288
-
289
- // Load selected labels if missing when dropdown opens
290
- useEffect(() => {
291
- if (!loadSelected || !isOpen) return;
292
271
  const values = Array.isArray(currentValue)
293
272
  ? currentValue
294
273
  : currentValue
295
274
  ? [currentValue]
296
275
  : [];
297
- const missing = values.filter((v) => !labelMapRef.current.has(v));
276
+ if (values.length === 0) return;
277
+
278
+ // Only fetch values whose labels we don't already have and aren't already
279
+ // being fetched.
280
+ const missing = values.filter(
281
+ (v) => !labelMapRef.current.has(v) && !inFlightSelectedRef.current.has(v),
282
+ );
298
283
  if (missing.length === 0) return;
299
284
 
300
- let cancelled = false;
285
+ missing.forEach((v) => {
286
+ inFlightSelectedRef.current.add(v);
287
+ });
288
+
289
+ // Mark missing values as loading so the UI shows a spinner instead of
290
+ // flashing the raw value while the fetch is in flight.
291
+ setLoadingSelectedValues((prev) => {
292
+ const next = new Set(prev);
293
+ missing.forEach((v) => {
294
+ next.add(v);
295
+ });
296
+ return next;
297
+ });
298
+
301
299
  loadSelected(missing)
302
300
  .then((opts) => {
303
- if (!cancelled) opts.forEach(storeOption);
301
+ // Store unconditionally — these are ref writes, safe even if this
302
+ // effect run was superseded. (Guarding on a "cancelled" flag here
303
+ // dropped labels in multiple mode, where currentValue is a fresh
304
+ // array reference each render and re-runs/cancels the effect.)
305
+ opts.forEach((opt) => {
306
+ storeOption(opt);
307
+ // Align the returned option back to the originally-requested value
308
+ // so getLabel finds it even when loadSelected returns a different
309
+ // value type (e.g. a GraphQL ID serialized as a string while
310
+ // currentValue holds a number). Match loosely by string, then store
311
+ // under the exact requested value.
312
+ const requested = missing.find(
313
+ (m) => String(m) === String(opt.value),
314
+ );
315
+ if (requested !== undefined && requested !== opt.value) {
316
+ storeOption({ ...opt, value: requested });
317
+ }
318
+ });
304
319
  // eslint-disable-next-line @typescript-eslint/no-empty-function
305
320
  })
306
- .catch(() => { });
307
- return () => {
308
- cancelled = true;
309
- };
310
- }, [currentValue, loadSelected, isOpen, storeOption]);
321
+ .catch(() => { })
322
+ .finally(() => {
323
+ missing.forEach((v) => {
324
+ inFlightSelectedRef.current.delete(v);
325
+ });
326
+ // Clear the loading flags regardless of outcome; the resulting state
327
+ // change re-renders and recomputes selectedItems from labelMapRef.
328
+ setLoadingSelectedValues((prev) => {
329
+ const next = new Set(prev);
330
+ missing.forEach((v) => {
331
+ next.delete(v);
332
+ });
333
+ return next;
334
+ });
335
+ });
336
+ }, [currentValue, loadSelected, storeOption]);
311
337
 
312
338
  // Get label helper
313
339
  const getLabel = useCallback((v: string | number) => {
@@ -316,6 +342,12 @@ export function Autocomplete<T = unknown>({
316
342
 
317
343
  // Selected items for display
318
344
  const selectedItems = useMemo(() => {
345
+ const toItem = (v: string | number) => ({
346
+ value: v,
347
+ label: getLabel(v),
348
+ raw: rawMapRef.current.get(v),
349
+ loading: loadingSelectedValues.has(v),
350
+ });
319
351
  if (!isMultiple) {
320
352
  if (
321
353
  currentValue === null ||
@@ -323,22 +355,14 @@ export function Autocomplete<T = unknown>({
323
355
  Array.isArray(currentValue)
324
356
  )
325
357
  return [];
326
- return [
327
- {
328
- value: currentValue,
329
- label: getLabel(currentValue),
330
- raw: rawMapRef.current.get(currentValue),
331
- },
332
- ];
358
+ return [toItem(currentValue)];
333
359
  }
334
360
  const values = Array.isArray(currentValue) ? currentValue : [];
335
- return values.map((v) => ({
336
- value: v,
337
- label: getLabel(v),
338
- raw: rawMapRef.current.get(v),
339
- }));
340
- // eslint-disable-next-line react-hooks/exhaustive-deps
341
- }, [currentValue, isMultiple, getLabel]); // labelsLoadedCounter triggers re-compute when loadSelected completes
361
+ return values.map(toItem);
362
+ // loadingSelectedValues also drives the recompute when loadSelected
363
+ // settles: clearing a value's flag re-renders, and its label is in
364
+ // labelMapRef (a ref React can't track) by then.
365
+ }, [currentValue, isMultiple, getLabel, loadingSelectedValues]);
342
366
 
343
367
  // Handle selection
344
368
  const handleSelect = useCallback(
@@ -450,13 +474,18 @@ export function Autocomplete<T = unknown>({
450
474
  if (isMultiple || isOpen) {
451
475
  return searchInput;
452
476
  }
453
- // In single mode when closed, show selected item label
477
+ // In single mode when closed, show selected item label. While its label
478
+ // is still resolving, show nothing (a spinner renders in the trailing
479
+ // icons) so we don't flash the raw value.
454
480
  if (selectedItems.length > 0) {
455
- return selectedItems[0].label;
481
+ return selectedItems[0].loading ? '' : selectedItems[0].label;
456
482
  }
457
483
  return '';
458
484
  }, [isMultiple, isOpen, searchInput, selectedItems]);
459
485
 
486
+ // Whether the single-mode selection is still resolving its label
487
+ const isSingleSelectionLoading = !isMultiple && !!selectedItems[0]?.loading;
488
+
460
489
  // Downshift
461
490
  const { getInputProps, getItemProps, getMenuProps, highlightedIndex } =
462
491
  useCombobox({
@@ -542,7 +571,13 @@ export function Autocomplete<T = unknown>({
542
571
  variant={chipVariant}
543
572
  className={cn('gap-1', chipClassName)}
544
573
  >
545
- <span className="max-w-[100px] truncate">{item.label}</span>
574
+ <span className="max-w-[100px] truncate">
575
+ {item.loading ? (
576
+ <Loader2 className="h-3 w-3 animate-spin" />
577
+ ) : (
578
+ item.label
579
+ )}
580
+ </span>
546
581
  <button
547
582
  type="button"
548
583
  onClick={(e) => {
@@ -583,7 +618,10 @@ export function Autocomplete<T = unknown>({
583
618
  )}
584
619
  />
585
620
  <div className="flex items-center gap-2 shrink-0">
586
- {showClearButton && (
621
+ {isSingleSelectionLoading && (
622
+ <Loader2 className="h-4 w-4 animate-spin opacity-50" />
623
+ )}
624
+ {showClearButton && !isSingleSelectionLoading && (
587
625
  <button
588
626
  type="button"
589
627
  onClick={(e) => {