@underverse-ui/underverse 1.0.106 → 1.0.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -7343,6 +7343,7 @@ var import_lucide_react14 = require("lucide-react");
7343
7343
  // src/components/Combobox.tsx
7344
7344
  var React24 = __toESM(require("react"), 1);
7345
7345
  var import_react15 = require("react");
7346
+ var import_react_virtual = require("@tanstack/react-virtual");
7346
7347
  var import_lucide_react13 = require("lucide-react");
7347
7348
  var import_jsx_runtime29 = require("react/jsx-runtime");
7348
7349
  var getOptionLabel = (option) => {
@@ -7388,9 +7389,19 @@ var Combobox = ({
7388
7389
  groupBy,
7389
7390
  renderOption,
7390
7391
  renderValue,
7392
+ selectedOption: selectedOptionProp,
7391
7393
  error,
7392
7394
  helperText,
7393
- useOverlayScrollbar = false
7395
+ useOverlayScrollbar = false,
7396
+ virtualized = false,
7397
+ estimatedItemHeight = 44,
7398
+ overscan = 8,
7399
+ searchMode = "auto",
7400
+ onSearchChange,
7401
+ searchDebounceMs = 0,
7402
+ minSearchLength = 0,
7403
+ maxInitialOptions,
7404
+ showSearchPromptWhenEmptyQuery = false
7394
7405
  }) => {
7395
7406
  const tv = useSmartTranslations("ValidationInput");
7396
7407
  const [open, setOpen] = React24.useState(false);
@@ -7404,12 +7415,53 @@ var Combobox = ({
7404
7415
  const autoId = (0, import_react15.useId)();
7405
7416
  const resolvedId = id ? String(id) : `combobox-${autoId}`;
7406
7417
  const labelId = label ? `${resolvedId}-label` : void 0;
7407
- const enableSearch = options.length > 10;
7418
+ const enableSearch = options.length > 10 || searchMode === "manual" || minSearchLength > 0 || !!onSearchChange;
7419
+ const trimmedQuery = query.trim();
7420
+ const queryMeetsMinimum = trimmedQuery.length >= minSearchLength;
7421
+ const shouldPromptForSearch = minSearchLength > 0 && !queryMeetsMinimum && (searchMode === "manual" || showSearchPromptWhenEmptyQuery);
7408
7422
  const filteredOptions = React24.useMemo(
7409
- () => enableSearch ? options.filter((o) => getOptionLabel(o).toLowerCase().includes(query.trim().toLowerCase())) : options,
7410
- [options, query, enableSearch]
7423
+ () => {
7424
+ if (shouldPromptForSearch) return [];
7425
+ if (!enableSearch || searchMode === "manual") return options;
7426
+ const normalizedQuery = trimmedQuery.toLowerCase();
7427
+ if (!normalizedQuery) return options;
7428
+ return options.filter((o) => getOptionLabel(o).toLowerCase().includes(normalizedQuery));
7429
+ },
7430
+ [enableSearch, options, searchMode, shouldPromptForSearch, trimmedQuery]
7431
+ );
7432
+ const renderLimitedOptions = React24.useMemo(
7433
+ () => {
7434
+ if (trimmedQuery || maxInitialOptions === void 0 || maxInitialOptions < 1) {
7435
+ return filteredOptions;
7436
+ }
7437
+ return filteredOptions.slice(0, maxInitialOptions);
7438
+ },
7439
+ [filteredOptions, maxInitialOptions, trimmedQuery]
7411
7440
  );
7441
+ const canVirtualize = virtualized && !groupBy;
7442
+ const optionVirtualizer = (0, import_react_virtual.useVirtualizer)({
7443
+ count: canVirtualize ? renderLimitedOptions.length : 0,
7444
+ getScrollElement: () => optionsViewportRef.current,
7445
+ estimateSize: () => estimatedItemHeight,
7446
+ initialRect: { width: 0, height: maxHeight },
7447
+ overscan,
7448
+ enabled: canVirtualize
7449
+ });
7450
+ const virtualItems = canVirtualize ? optionVirtualizer.getVirtualItems() : [];
7412
7451
  const triggerRef = React24.useRef(null);
7452
+ const scrollVirtualListToIndex = React24.useCallback((index) => {
7453
+ if (!canVirtualize || renderLimitedOptions.length === 0) return;
7454
+ optionVirtualizer.scrollToIndex(index, { align: "auto" });
7455
+ }, [canVirtualize, optionVirtualizer, renderLimitedOptions.length]);
7456
+ const scrollVirtualListToStart = React24.useCallback(() => {
7457
+ scrollVirtualListToIndex(0);
7458
+ }, [scrollVirtualListToIndex]);
7459
+ const moveActiveIndex = React24.useCallback((direction) => {
7460
+ if (renderLimitedOptions.length === 0) return;
7461
+ const next = activeIndex === null ? direction === 1 ? 0 : renderLimitedOptions.length - 1 : (activeIndex + direction + renderLimitedOptions.length) % renderLimitedOptions.length;
7462
+ setActiveIndex(next);
7463
+ scrollVirtualListToIndex(next);
7464
+ }, [activeIndex, renderLimitedOptions.length, scrollVirtualListToIndex]);
7413
7465
  const handleSelect = (option) => {
7414
7466
  if (getOptionDisabled(option)) return;
7415
7467
  const val = getOptionValue(option);
@@ -7422,6 +7474,9 @@ var Combobox = ({
7422
7474
  };
7423
7475
  const handleClear = (e) => {
7424
7476
  e.stopPropagation();
7477
+ clearValue();
7478
+ };
7479
+ const clearValue = () => {
7425
7480
  onChange(null);
7426
7481
  setOpen(false);
7427
7482
  };
@@ -7429,13 +7484,26 @@ var Combobox = ({
7429
7484
  if (!open) {
7430
7485
  setQuery("");
7431
7486
  setActiveIndex(null);
7487
+ scrollVirtualListToStart();
7432
7488
  } else if (enableSearch) {
7433
7489
  setTimeout(() => {
7434
7490
  inputRef.current?.focus();
7435
7491
  }, 100);
7436
7492
  }
7437
- }, [open, enableSearch]);
7438
- const selectedOption = findOptionByValue(options, value);
7493
+ }, [enableSearch, open, scrollVirtualListToStart]);
7494
+ React24.useEffect(() => {
7495
+ if (!onSearchChange) return void 0;
7496
+ const timeoutId = window.setTimeout(() => onSearchChange(query), searchDebounceMs);
7497
+ return () => window.clearTimeout(timeoutId);
7498
+ }, [onSearchChange, query, searchDebounceMs]);
7499
+ React24.useEffect(() => {
7500
+ if (process.env.NODE_ENV !== "production" && options.length > 300 && !virtualized && searchMode !== "manual" && maxInitialOptions === void 0) {
7501
+ console.warn(
7502
+ '[Underverse UI] Combobox received more than 300 options without virtualization, manual search, or maxInitialOptions. Use virtualized, searchMode="manual", or maxInitialOptions to avoid rendering a large dropdown.'
7503
+ );
7504
+ }
7505
+ }, [maxInitialOptions, options.length, searchMode, virtualized]);
7506
+ const selectedOption = findOptionByValue(options, value) ?? (selectedOptionProp && getOptionValue(selectedOptionProp) === value ? selectedOptionProp : void 0);
7439
7507
  const displayValue = selectedOption ? getOptionLabel(selectedOption) : "";
7440
7508
  const selectedIcon = selectedOption ? getOptionIcon(selectedOption) : void 0;
7441
7509
  const hasValue = value !== void 0 && value !== null && value !== "";
@@ -7448,13 +7516,13 @@ var Combobox = ({
7448
7516
  const groupedOptions = React24.useMemo(() => {
7449
7517
  if (!groupBy) return null;
7450
7518
  const groups = {};
7451
- filteredOptions.forEach((opt) => {
7519
+ renderLimitedOptions.forEach((opt) => {
7452
7520
  const group = groupBy(opt);
7453
7521
  if (!groups[group]) groups[group] = [];
7454
7522
  groups[group].push(opt);
7455
7523
  });
7456
7524
  return groups;
7457
- }, [filteredOptions, groupBy]);
7525
+ }, [renderLimitedOptions, groupBy]);
7458
7526
  const itemSizeStyles = {
7459
7527
  sm: "px-2.5 py-1.5 text-xs gap-2",
7460
7528
  md: "px-3 py-2.5 text-sm gap-3",
@@ -7470,60 +7538,75 @@ var Combobox = ({
7470
7538
  md: "h-4 w-4",
7471
7539
  lg: "h-5 w-5"
7472
7540
  };
7473
- const renderOptionItem = (item, index) => {
7541
+ const renderOptionItem = (item, index, virtualItem) => {
7474
7542
  const itemValue = getOptionValue(item);
7475
7543
  const itemLabel = getOptionLabel(item);
7476
7544
  const itemIcon = getOptionIcon(item);
7477
7545
  const itemDescription = getOptionDescription(item);
7478
7546
  const itemDisabled = getOptionDisabled(item);
7479
7547
  const isSelected = itemValue === value;
7480
- return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("li", { className: "list-none", children: /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)(
7481
- "button",
7548
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
7549
+ "li",
7482
7550
  {
7483
- id: `combobox-item-${index}`,
7484
- type: "button",
7485
- role: "option",
7486
- tabIndex: -1,
7487
- disabled: itemDisabled,
7488
- "aria-selected": isSelected,
7489
- onClick: () => handleSelect(item),
7490
- style: {
7491
- animationDelay: open ? `${Math.min(index * 15, 150)}ms` : "0ms"
7492
- },
7493
- className: cn(
7494
- "dropdown-item group flex w-full items-center rounded-full text-left",
7495
- itemSizeStyles[size],
7496
- "outline-none focus:outline-none focus-visible:outline-none",
7497
- "transition-all duration-150",
7498
- !itemDisabled && "cursor-pointer hover:bg-accent/70 hover:shadow-sm",
7499
- !itemDisabled && "focus:bg-accent/80 focus:text-accent-foreground",
7500
- index === activeIndex && !itemDisabled && "bg-accent/60",
7501
- isSelected && "bg-primary/10 text-primary font-medium",
7502
- itemDisabled && "opacity-50 cursor-not-allowed"
7503
- ),
7504
- children: [
7505
- itemIcon && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
7506
- "span",
7507
- {
7508
- className: cn("shrink-0 flex items-center justify-center", iconSizeStyles[size], isSelected ? "text-primary" : "text-muted-foreground"),
7509
- children: itemIcon
7510
- }
7511
- ),
7512
- renderOption ? /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: "flex-1 min-w-0", children: renderOption(item, isSelected) }) : /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("div", { className: "flex-1 min-w-0", children: [
7513
- /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("span", { className: "block truncate", children: itemLabel }),
7514
- itemDescription && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("span", { className: cn("block text-muted-foreground truncate mt-0.5", size === "sm" ? "text-[10px]" : "text-xs"), children: itemDescription })
7515
- ] }),
7516
- isSelected && showSelectedIcon && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("span", { className: "shrink-0 ml-auto", children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(import_lucide_react13.Check, { className: cn(checkIconSizeStyles[size], "text-primary") }) })
7517
- ]
7518
- }
7519
- ) }, `${itemValue}-${index}`);
7551
+ ref: virtualItem ? optionVirtualizer.measureElement : void 0,
7552
+ "data-index": virtualItem?.index,
7553
+ className: "list-none",
7554
+ style: virtualItem ? {
7555
+ position: "absolute",
7556
+ top: 0,
7557
+ left: 0,
7558
+ width: "100%",
7559
+ transform: `translateY(${virtualItem.start}px)`
7560
+ } : void 0,
7561
+ children: /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)(
7562
+ "button",
7563
+ {
7564
+ id: `${resolvedId}-item-${index}`,
7565
+ type: "button",
7566
+ role: "option",
7567
+ tabIndex: -1,
7568
+ disabled: itemDisabled,
7569
+ "aria-selected": isSelected,
7570
+ onClick: () => handleSelect(item),
7571
+ style: {
7572
+ animationDelay: open ? `${Math.min(index * 15, 150)}ms` : "0ms"
7573
+ },
7574
+ className: cn(
7575
+ "dropdown-item group flex w-full items-center rounded-full text-left",
7576
+ itemSizeStyles[size],
7577
+ "outline-none focus:outline-none focus-visible:outline-none",
7578
+ "transition-all duration-150",
7579
+ !itemDisabled && "cursor-pointer hover:bg-accent/70 hover:shadow-sm",
7580
+ !itemDisabled && "focus:bg-accent/80 focus:text-accent-foreground",
7581
+ index === activeIndex && !itemDisabled && "bg-accent/60",
7582
+ isSelected && "bg-primary/10 text-primary font-medium",
7583
+ itemDisabled && "opacity-50 cursor-not-allowed"
7584
+ ),
7585
+ children: [
7586
+ itemIcon && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
7587
+ "span",
7588
+ {
7589
+ className: cn("shrink-0 flex items-center justify-center", iconSizeStyles[size], isSelected ? "text-primary" : "text-muted-foreground"),
7590
+ children: itemIcon
7591
+ }
7592
+ ),
7593
+ renderOption ? /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: "flex-1 min-w-0", children: renderOption(item, isSelected) }) : /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("div", { className: "flex-1 min-w-0", children: [
7594
+ /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("span", { className: "block truncate", children: itemLabel }),
7595
+ itemDescription && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("span", { className: cn("block text-muted-foreground truncate mt-0.5", size === "sm" ? "text-[10px]" : "text-xs"), children: itemDescription })
7596
+ ] }),
7597
+ isSelected && showSelectedIcon && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("span", { className: "shrink-0 ml-auto", children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(import_lucide_react13.Check, { className: cn(checkIconSizeStyles[size], "text-primary") }) })
7598
+ ]
7599
+ }
7600
+ )
7601
+ },
7602
+ `${itemValue}-${index}`
7603
+ );
7520
7604
  };
7521
7605
  const dropdownBody = /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)(
7522
7606
  "div",
7523
7607
  {
7524
7608
  "data-combobox-dropdown": true,
7525
7609
  "data-state": open ? "open" : "closed",
7526
- id: `${resolvedId}-listbox`,
7527
7610
  className: "w-full rounded-2xl md:rounded-3xl overflow-hidden",
7528
7611
  children: [
7529
7612
  enableSearch && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: cn("relative border-b border-border/30", size === "sm" ? "p-2" : size === "lg" ? "p-3" : "p-2.5"), children: /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("div", { className: "relative", children: [
@@ -7544,24 +7627,19 @@ var Combobox = ({
7544
7627
  onChange: (e) => {
7545
7628
  setQuery(e.target.value);
7546
7629
  setActiveIndex(null);
7630
+ scrollVirtualListToStart();
7547
7631
  },
7548
7632
  onKeyDown: (e) => {
7549
7633
  if (e.key === "ArrowDown") {
7550
7634
  e.preventDefault();
7551
- setActiveIndex((prev) => {
7552
- const next = prev === null ? 0 : prev + 1;
7553
- return next >= filteredOptions.length ? 0 : next;
7554
- });
7635
+ moveActiveIndex(1);
7555
7636
  } else if (e.key === "ArrowUp") {
7556
7637
  e.preventDefault();
7557
- setActiveIndex((prev) => {
7558
- const next = prev === null ? filteredOptions.length - 1 : prev - 1;
7559
- return next < 0 ? filteredOptions.length - 1 : next;
7560
- });
7638
+ moveActiveIndex(-1);
7561
7639
  } else if (e.key === "Enter") {
7562
7640
  e.preventDefault();
7563
- if (activeIndex !== null && filteredOptions[activeIndex] && !getOptionDisabled(filteredOptions[activeIndex])) {
7564
- handleSelect(filteredOptions[activeIndex]);
7641
+ if (activeIndex !== null && renderLimitedOptions[activeIndex] && !getOptionDisabled(renderLimitedOptions[activeIndex])) {
7642
+ handleSelect(renderLimitedOptions[activeIndex]);
7565
7643
  }
7566
7644
  } else if (e.key === "Escape") {
7567
7645
  e.preventDefault();
@@ -7584,7 +7662,10 @@ var Combobox = ({
7584
7662
  "button",
7585
7663
  {
7586
7664
  type: "button",
7587
- onClick: () => setQuery(""),
7665
+ onClick: () => {
7666
+ setQuery("");
7667
+ scrollVirtualListToStart();
7668
+ },
7588
7669
  className: "absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors",
7589
7670
  children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(import_lucide_react13.X, { className: cn(size === "sm" ? "h-3 w-3" : size === "lg" ? "h-4 w-4" : "h-3.5 w-3.5") })
7590
7671
  }
@@ -7594,6 +7675,7 @@ var Combobox = ({
7594
7675
  "div",
7595
7676
  {
7596
7677
  ref: optionsViewportRef,
7678
+ id: `${resolvedId}-listbox`,
7597
7679
  role: "listbox",
7598
7680
  "aria-labelledby": labelId,
7599
7681
  className: "overflow-y-auto overscroll-contain",
@@ -7601,7 +7683,14 @@ var Combobox = ({
7601
7683
  children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: cn(size === "sm" ? "p-1" : size === "lg" ? "p-2" : "p-1.5"), children: loading2 ? /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: "px-3 py-10 text-center", children: /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("div", { className: "flex flex-col items-center gap-3 animate-in fade-in-0 zoom-in-95 duration-300", children: [
7602
7684
  /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: "relative", children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: "w-10 h-10 rounded-full border-2 border-primary/20 border-t-primary animate-spin" }) }),
7603
7685
  /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("span", { className: "text-sm text-muted-foreground", children: loadingText })
7604
- ] }) }) : filteredOptions.length > 0 ? groupedOptions ? (
7686
+ ] }) }) : shouldPromptForSearch ? /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: "px-3 py-10 text-center", children: /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("div", { className: "flex flex-col items-center gap-3 animate-in fade-in-0 zoom-in-95 duration-300", children: [
7687
+ /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: "w-12 h-12 rounded-full bg-muted/50 flex items-center justify-center", children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(import_lucide_react13.Search, { className: "h-6 w-6 text-muted-foreground/60" }) }),
7688
+ /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: "space-y-1", children: /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("span", { className: "block text-sm font-medium text-foreground", children: [
7689
+ "Type at least ",
7690
+ minSearchLength,
7691
+ " characters to search"
7692
+ ] }) })
7693
+ ] }) }) : renderLimitedOptions.length > 0 ? groupedOptions ? (
7605
7694
  // Render grouped options with global index tracking
7606
7695
  (() => {
7607
7696
  let globalIndex = 0;
@@ -7615,7 +7704,14 @@ var Combobox = ({
7615
7704
  })()
7616
7705
  ) : (
7617
7706
  // Render flat options
7618
- /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("ul", { className: "space-y-0.5", children: filteredOptions.map((item, index) => renderOptionItem(item, index)) })
7707
+ /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
7708
+ "ul",
7709
+ {
7710
+ className: "space-y-0.5",
7711
+ style: canVirtualize ? { height: `${optionVirtualizer.getTotalSize()}px`, position: "relative" } : void 0,
7712
+ children: canVirtualize ? virtualItems.map((virtualItem) => renderOptionItem(renderLimitedOptions[virtualItem.index], virtualItem.index, virtualItem)) : renderLimitedOptions.map((item, index) => renderOptionItem(item, index))
7713
+ }
7714
+ )
7619
7715
  ) : /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: "px-3 py-10 text-center", children: /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("div", { className: "flex flex-col items-center gap-3 animate-in fade-in-0 zoom-in-95 duration-300", children: [
7620
7716
  /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: "w-12 h-12 rounded-full bg-muted/50 flex items-center justify-center", children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(import_lucide_react13.SearchX, { className: "h-6 w-6 text-muted-foreground/60" }) }),
7621
7717
  /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("div", { className: "space-y-1", children: [
@@ -7626,7 +7722,10 @@ var Combobox = ({
7626
7722
  "button",
7627
7723
  {
7628
7724
  type: "button",
7629
- onClick: () => setQuery(""),
7725
+ onClick: () => {
7726
+ setQuery("");
7727
+ scrollVirtualListToStart();
7728
+ },
7630
7729
  className: "px-3 py-1.5 text-xs font-medium text-primary bg-primary/10 rounded-full hover:bg-primary/20 transition-colors",
7631
7730
  children: "Clear search"
7632
7731
  }
@@ -7696,7 +7795,13 @@ var Combobox = ({
7696
7795
  tabIndex: 0,
7697
7796
  "aria-label": "Clear selection",
7698
7797
  onClick: handleClear,
7699
- onKeyDown: (e) => (e.key === "Enter" || e.key === " ") && handleClear(e),
7798
+ onKeyDown: (e) => {
7799
+ if (e.key === "Enter" || e.key === " ") {
7800
+ e.preventDefault();
7801
+ e.stopPropagation();
7802
+ clearValue();
7803
+ }
7804
+ },
7700
7805
  className: cn(
7701
7806
  "opacity-0 group-hover:opacity-100 transition-all duration-200",
7702
7807
  "p-1 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
@@ -15576,6 +15681,7 @@ function CalendarTimeline({
15576
15681
  // src/components/MultiCombobox.tsx
15577
15682
  var React39 = __toESM(require("react"), 1);
15578
15683
  var import_react20 = require("react");
15684
+ var import_react_virtual2 = require("@tanstack/react-virtual");
15579
15685
  var import_lucide_react24 = require("lucide-react");
15580
15686
  var import_jsx_runtime45 = require("react/jsx-runtime");
15581
15687
  var MultiCombobox = ({
@@ -15606,10 +15712,20 @@ var MultiCombobox = ({
15606
15712
  groupBy,
15607
15713
  renderOption,
15608
15714
  renderTag,
15715
+ selectedOptions: selectedOptionsProp,
15609
15716
  error,
15610
15717
  helperText,
15611
15718
  maxTagsVisible = 3,
15612
- useOverlayScrollbar = false
15719
+ useOverlayScrollbar = false,
15720
+ virtualized = false,
15721
+ estimatedItemHeight = 44,
15722
+ overscan = 8,
15723
+ searchMode = "auto",
15724
+ onSearchChange,
15725
+ searchDebounceMs = 0,
15726
+ minSearchLength = 0,
15727
+ maxInitialOptions,
15728
+ showSearchPromptWhenEmptyQuery = false
15613
15729
  }) => {
15614
15730
  const tv = useSmartTranslations("ValidationInput");
15615
15731
  const [query, setQuery] = React39.useState("");
@@ -15628,23 +15744,52 @@ var MultiCombobox = ({
15628
15744
  ),
15629
15745
  [options]
15630
15746
  );
15631
- const enableSearch = normalizedOptions.length > 10;
15632
- const filtered = React39.useMemo(
15633
- () => enableSearch ? normalizedOptions.filter(
15634
- (opt) => opt.label.toLowerCase().includes(query.trim().toLowerCase()) || opt.description?.toLowerCase().includes(query.trim().toLowerCase())
15635
- ) : normalizedOptions,
15636
- [normalizedOptions, query, enableSearch]
15637
- );
15747
+ const enableSearch = normalizedOptions.length > 10 || searchMode === "manual" || minSearchLength > 0 || !!onSearchChange;
15748
+ const trimmedQuery = query.trim();
15749
+ const queryMeetsMinimum = trimmedQuery.length >= minSearchLength;
15750
+ const shouldPromptForSearch = minSearchLength > 0 && !queryMeetsMinimum && (searchMode === "manual" || showSearchPromptWhenEmptyQuery);
15751
+ const filtered = React39.useMemo(() => {
15752
+ if (shouldPromptForSearch) return [];
15753
+ if (!enableSearch || searchMode === "manual") return normalizedOptions;
15754
+ const normalizedQuery = trimmedQuery.toLowerCase();
15755
+ if (!normalizedQuery) return normalizedOptions;
15756
+ return normalizedOptions.filter(
15757
+ (opt) => opt.label.toLowerCase().includes(normalizedQuery) || opt.description?.toLowerCase().includes(normalizedQuery)
15758
+ );
15759
+ }, [enableSearch, normalizedOptions, searchMode, shouldPromptForSearch, trimmedQuery]);
15760
+ const renderLimitedOptions = React39.useMemo(() => {
15761
+ if (trimmedQuery || maxInitialOptions === void 0 || maxInitialOptions < 1) {
15762
+ return filtered;
15763
+ }
15764
+ return filtered.slice(0, maxInitialOptions);
15765
+ }, [filtered, maxInitialOptions, trimmedQuery]);
15766
+ const canVirtualize = virtualized && !groupBy;
15767
+ const optionVirtualizer = (0, import_react_virtual2.useVirtualizer)({
15768
+ count: canVirtualize ? renderLimitedOptions.length : 0,
15769
+ getScrollElement: () => optionsListRef.current,
15770
+ estimateSize: () => estimatedItemHeight,
15771
+ initialRect: { width: 0, height: maxHeight },
15772
+ overscan,
15773
+ enabled: canVirtualize
15774
+ });
15775
+ const virtualItems = canVirtualize ? optionVirtualizer.getVirtualItems() : [];
15776
+ const scrollVirtualListToIndex = React39.useCallback((index) => {
15777
+ if (!canVirtualize || renderLimitedOptions.length === 0) return;
15778
+ optionVirtualizer.scrollToIndex(index, { align: "auto" });
15779
+ }, [canVirtualize, optionVirtualizer, renderLimitedOptions.length]);
15780
+ const scrollVirtualListToStart = React39.useCallback(() => {
15781
+ scrollVirtualListToIndex(0);
15782
+ }, [scrollVirtualListToIndex]);
15638
15783
  const groupedOptions = React39.useMemo(() => {
15639
15784
  if (!groupBy) return null;
15640
15785
  const groups = /* @__PURE__ */ new Map();
15641
- filtered.forEach((opt) => {
15786
+ renderLimitedOptions.forEach((opt) => {
15642
15787
  const group = groupBy(opt);
15643
15788
  if (!groups.has(group)) groups.set(group, []);
15644
15789
  groups.get(group).push(opt);
15645
15790
  });
15646
15791
  return groups;
15647
- }, [filtered, groupBy]);
15792
+ }, [renderLimitedOptions, groupBy]);
15648
15793
  const toggleSelect = (optionValue) => {
15649
15794
  const option = normalizedOptions.find((o) => o.value === optionValue);
15650
15795
  if (option?.disabled || disabledOptions.includes(optionValue)) return;
@@ -15662,11 +15807,26 @@ var MultiCombobox = ({
15662
15807
  };
15663
15808
  const handleKeyDown2 = (e) => {
15664
15809
  if (!open) setOpen(true);
15665
- if (e.key === "Enter") {
15810
+ if (e.key === "ArrowDown") {
15666
15811
  e.preventDefault();
15667
- if (activeIndex !== null && filtered[activeIndex]) {
15668
- toggleSelect(filtered[activeIndex].value);
15812
+ if (renderLimitedOptions.length === 0) return;
15813
+ const next = activeIndex === null ? 0 : (activeIndex + 1) % renderLimitedOptions.length;
15814
+ setActiveIndex(next);
15815
+ scrollVirtualListToIndex(next);
15816
+ } else if (e.key === "ArrowUp") {
15817
+ e.preventDefault();
15818
+ if (renderLimitedOptions.length === 0) return;
15819
+ const next = activeIndex === null ? renderLimitedOptions.length - 1 : (activeIndex - 1 + renderLimitedOptions.length) % renderLimitedOptions.length;
15820
+ setActiveIndex(next);
15821
+ scrollVirtualListToIndex(next);
15822
+ } else if (e.key === "Enter") {
15823
+ e.preventDefault();
15824
+ if (activeIndex !== null && renderLimitedOptions[activeIndex]) {
15825
+ toggleSelect(renderLimitedOptions[activeIndex].value);
15669
15826
  }
15827
+ } else if (e.key === "Escape") {
15828
+ e.preventDefault();
15829
+ setOpen(false);
15670
15830
  }
15671
15831
  };
15672
15832
  const handleClearAll = () => {
@@ -15683,8 +15843,24 @@ var MultiCombobox = ({
15683
15843
  setTimeout(() => {
15684
15844
  inputRef.current?.focus();
15685
15845
  }, 100);
15846
+ } else if (!open) {
15847
+ setQuery("");
15848
+ setActiveIndex(null);
15849
+ scrollVirtualListToStart();
15686
15850
  }
15687
- }, [open, enableSearch]);
15851
+ }, [enableSearch, open, scrollVirtualListToStart]);
15852
+ React39.useEffect(() => {
15853
+ if (!onSearchChange) return void 0;
15854
+ const timeoutId = window.setTimeout(() => onSearchChange(query), searchDebounceMs);
15855
+ return () => window.clearTimeout(timeoutId);
15856
+ }, [onSearchChange, query, searchDebounceMs]);
15857
+ React39.useEffect(() => {
15858
+ if (process.env.NODE_ENV !== "production" && normalizedOptions.length > 300 && !virtualized && searchMode !== "manual" && maxInitialOptions === void 0) {
15859
+ console.warn(
15860
+ '[Underverse UI] MultiCombobox received more than 300 options without virtualization, manual search, or maxInitialOptions. Use virtualized, searchMode="manual", or maxInitialOptions to avoid rendering a large dropdown.'
15861
+ );
15862
+ }
15863
+ }, [maxInitialOptions, normalizedOptions.length, searchMode, virtualized]);
15688
15864
  const sizeStyles8 = {
15689
15865
  sm: {
15690
15866
  trigger: "h-8 px-3 py-1.5 text-sm md:h-7 md:text-xs",
@@ -15718,25 +15894,38 @@ var MultiCombobox = ({
15718
15894
  const labelId = label ? `${resolvedId}-label` : void 0;
15719
15895
  const labelSize = size === "sm" ? "text-xs" : size === "lg" ? "text-base" : "text-sm";
15720
15896
  const listboxId = `${resolvedId}-listbox`;
15721
- const renderOptionItem = (item, index) => {
15897
+ const renderOptionItem = (item, index, virtualItem) => {
15722
15898
  const isSelected = value.includes(item.value);
15723
15899
  const isDisabled = item.disabled || disabledOptions.includes(item.value);
15724
15900
  const optionIcon = item.icon;
15725
15901
  const optionDesc = item.description;
15902
+ const itemStyle = {
15903
+ animationDelay: open ? `${Math.min(index * 20, 200)}ms` : "0ms",
15904
+ ...virtualItem ? {
15905
+ position: "absolute",
15906
+ top: 0,
15907
+ left: 0,
15908
+ width: "100%",
15909
+ transform: `translateY(${virtualItem.start}px)`
15910
+ } : {}
15911
+ };
15912
+ const measureRef = virtualItem ? optionVirtualizer.measureElement : void 0;
15726
15913
  if (renderOption) {
15727
15914
  return /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
15728
15915
  "li",
15729
15916
  {
15730
15917
  ref: (node) => {
15918
+ measureRef?.(node);
15731
15919
  listRef.current[index] = node;
15732
15920
  },
15921
+ "data-index": virtualItem?.index,
15922
+ style: itemStyle,
15733
15923
  onClick: (e) => {
15734
15924
  e.preventDefault();
15735
15925
  e.stopPropagation();
15736
15926
  if (!isDisabled) toggleSelect(item.value);
15737
15927
  inputRef.current?.focus();
15738
15928
  },
15739
- style: { animationDelay: open ? `${Math.min(index * 20, 200)}ms` : "0ms" },
15740
15929
  className: cn("dropdown-item", isDisabled && "opacity-50 cursor-not-allowed pointer-events-none"),
15741
15930
  children: renderOption(item, isSelected)
15742
15931
  },
@@ -15747,15 +15936,17 @@ var MultiCombobox = ({
15747
15936
  "li",
15748
15937
  {
15749
15938
  ref: (node) => {
15939
+ measureRef?.(node);
15750
15940
  listRef.current[index] = node;
15751
15941
  },
15942
+ "data-index": virtualItem?.index,
15943
+ style: itemStyle,
15752
15944
  onClick: (e) => {
15753
15945
  e.preventDefault();
15754
15946
  e.stopPropagation();
15755
15947
  if (!isDisabled) toggleSelect(item.value);
15756
15948
  inputRef.current?.focus();
15757
15949
  },
15758
- style: { animationDelay: open ? `${Math.min(index * 20, 200)}ms` : "0ms" },
15759
15950
  className: cn(
15760
15951
  "dropdown-item flex cursor-pointer items-center gap-3 rounded-full transition-all duration-200",
15761
15952
  sizeStyles8[size].item,
@@ -15819,6 +16010,7 @@ var MultiCombobox = ({
15819
16010
  onChange: (e) => {
15820
16011
  setQuery(e.target.value);
15821
16012
  setActiveIndex(null);
16013
+ scrollVirtualListToStart();
15822
16014
  },
15823
16015
  onKeyDown: handleKeyDown2,
15824
16016
  placeholder: searchPlaceholder,
@@ -15829,7 +16021,10 @@ var MultiCombobox = ({
15829
16021
  "button",
15830
16022
  {
15831
16023
  type: "button",
15832
- onClick: () => setQuery(""),
16024
+ onClick: () => {
16025
+ setQuery("");
16026
+ scrollVirtualListToStart();
16027
+ },
15833
16028
  className: "absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors",
15834
16029
  children: /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(import_lucide_react24.X, { className: "w-4 h-4" })
15835
16030
  }
@@ -15850,30 +16045,52 @@ var MultiCombobox = ({
15850
16045
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(import_lucide_react24.Sparkles, { className: "h-4 w-4 text-primary/60 absolute -top-1 -right-1 animate-pulse" })
15851
16046
  ] }),
15852
16047
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { className: "text-muted-foreground font-medium", children: loadingText })
15853
- ] }) }) : filtered.length ? groupedOptions ? (
16048
+ ] }) }) : shouldPromptForSearch ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("li", { className: "px-3 py-8 text-center text-muted-foreground", children: /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("div", { className: "flex flex-col items-center gap-3 animate-in fade-in-0 zoom-in-95 duration-300", children: [
16049
+ /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(import_lucide_react24.Search, { className: "h-10 w-10 opacity-30 text-muted-foreground" }),
16050
+ /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("span", { className: "font-medium block text-foreground", children: [
16051
+ "Type at least ",
16052
+ minSearchLength,
16053
+ " characters to search"
16054
+ ] })
16055
+ ] }) }) : renderLimitedOptions.length ? groupedOptions ? (
15854
16056
  // Render grouped options
15855
16057
  Array.from(groupedOptions.entries()).map(([group, items]) => /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("li", { className: "mb-2", children: [
15856
16058
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("div", { className: "px-3 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider sticky top-0 bg-popover/95 backdrop-blur-sm", children: group }),
15857
- /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("ul", { children: items.map((item) => renderOptionItem(item, filtered.indexOf(item))) })
16059
+ /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("ul", { children: items.map((item) => renderOptionItem(item, renderLimitedOptions.indexOf(item))) })
15858
16060
  ] }, group))
15859
16061
  ) : (
15860
16062
  // Render flat options
15861
- filtered.map((item, index) => renderOptionItem(item, index))
16063
+ canVirtualize ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("li", { role: "presentation", className: "list-none p-0", children: /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("ul", { className: "relative", style: { height: `${optionVirtualizer.getTotalSize()}px` }, children: virtualItems.map((virtualItem) => renderOptionItem(renderLimitedOptions[virtualItem.index], virtualItem.index, virtualItem)) }) }) : renderLimitedOptions.map((item, index) => renderOptionItem(item, index))
15862
16064
  ) : /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("li", { className: cn("px-3 py-8 text-center text-muted-foreground"), children: /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("div", { className: "flex flex-col items-center gap-3 animate-in fade-in-0 zoom-in-95 duration-300", children: [
15863
16065
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(import_lucide_react24.SearchX, { className: "h-10 w-10 opacity-30 text-muted-foreground" }),
15864
16066
  /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("div", { className: "space-y-1", children: [
15865
16067
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { className: "font-medium block", children: emptyText }),
15866
16068
  query && /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { className: "text-xs opacity-60", children: "Try a different search term" })
15867
16069
  ] }),
15868
- query && /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("button", { type: "button", onClick: () => setQuery(""), className: "text-xs text-primary hover:underline flex items-center gap-1", children: [
15869
- /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(import_lucide_react24.X, { className: "w-3 h-3" }),
15870
- "Clear search"
15871
- ] })
16070
+ query && /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)(
16071
+ "button",
16072
+ {
16073
+ type: "button",
16074
+ onClick: () => {
16075
+ setQuery("");
16076
+ scrollVirtualListToStart();
16077
+ },
16078
+ className: "text-xs text-primary hover:underline flex items-center gap-1",
16079
+ children: [
16080
+ /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(import_lucide_react24.X, { className: "w-3 h-3" }),
16081
+ "Clear search"
16082
+ ]
16083
+ }
16084
+ )
15872
16085
  ] }) })
15873
16086
  }
15874
16087
  )
15875
16088
  ] });
15876
- const selectedOptions = value.map((v) => normalizedOptions.find((o) => o.value === v)).filter(Boolean);
16089
+ const selectedOptionFallbackMap = React39.useMemo(
16090
+ () => new Map((selectedOptionsProp ?? []).map((option) => [option.value, option])),
16091
+ [selectedOptionsProp]
16092
+ );
16093
+ const selectedOptions = value.map((v) => normalizedOptions.find((o) => o.value === v) ?? selectedOptionFallbackMap.get(v)).filter(Boolean);
15877
16094
  const visibleTags = maxTagsVisible ? selectedOptions.slice(0, maxTagsVisible) : selectedOptions;
15878
16095
  const hiddenCount = maxTagsVisible ? Math.max(0, selectedOptions.length - maxTagsVisible) : 0;
15879
16096
  const triggerButton = /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)(