@underverse-ui/underverse 1.0.107 → 1.0.110

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) => {
@@ -7363,6 +7364,20 @@ var getOptionDisabled = (option) => {
7363
7364
  var findOptionByValue = (options, value) => {
7364
7365
  return options.find((opt) => getOptionValue(opt) === value);
7365
7366
  };
7367
+ var comboboxScrollClassName = [
7368
+ "scrollbar-thin",
7369
+ "[scrollbar-width:thin]",
7370
+ "[scrollbar-color:color-mix(in_oklch,var(--muted-foreground)_28%,transparent)_transparent]",
7371
+ "[&::-webkit-scrollbar]:w-2",
7372
+ "[&::-webkit-scrollbar-track]:bg-transparent",
7373
+ "[&::-webkit-scrollbar-thumb]:rounded-full",
7374
+ "[&::-webkit-scrollbar-thumb]:border-2",
7375
+ "[&::-webkit-scrollbar-thumb]:border-solid",
7376
+ "[&::-webkit-scrollbar-thumb]:border-transparent",
7377
+ "[&::-webkit-scrollbar-thumb]:bg-clip-padding",
7378
+ "[&::-webkit-scrollbar-thumb]:bg-muted-foreground/25",
7379
+ "[&::-webkit-scrollbar-thumb:hover]:bg-muted-foreground/45"
7380
+ ].join(" ");
7366
7381
  var Combobox = ({
7367
7382
  id,
7368
7383
  options,
@@ -7388,9 +7403,19 @@ var Combobox = ({
7388
7403
  groupBy,
7389
7404
  renderOption,
7390
7405
  renderValue,
7406
+ selectedOption: selectedOptionProp,
7391
7407
  error,
7392
7408
  helperText,
7393
- useOverlayScrollbar = false
7409
+ useOverlayScrollbar = true,
7410
+ virtualized = false,
7411
+ estimatedItemHeight = 44,
7412
+ overscan = 8,
7413
+ searchMode = "auto",
7414
+ onSearchChange,
7415
+ searchDebounceMs = 0,
7416
+ minSearchLength = 0,
7417
+ maxInitialOptions,
7418
+ showSearchPromptWhenEmptyQuery = false
7394
7419
  }) => {
7395
7420
  const tv = useSmartTranslations("ValidationInput");
7396
7421
  const [open, setOpen] = React24.useState(false);
@@ -7400,16 +7425,57 @@ var Combobox = ({
7400
7425
  useShadCNAnimations();
7401
7426
  const inputRef = React24.useRef(null);
7402
7427
  const optionsViewportRef = React24.useRef(null);
7403
- useOverlayScrollbarTarget(optionsViewportRef, { enabled: useOverlayScrollbar });
7428
+ useOverlayScrollbarTarget(optionsViewportRef, { enabled: useOverlayScrollbar && !virtualized });
7404
7429
  const autoId = (0, import_react15.useId)();
7405
7430
  const resolvedId = id ? String(id) : `combobox-${autoId}`;
7406
7431
  const labelId = label ? `${resolvedId}-label` : void 0;
7407
- const enableSearch = options.length > 10;
7432
+ const enableSearch = options.length > 10 || searchMode === "manual" || minSearchLength > 0 || !!onSearchChange;
7433
+ const trimmedQuery = query.trim();
7434
+ const queryMeetsMinimum = trimmedQuery.length >= minSearchLength;
7435
+ const shouldPromptForSearch = minSearchLength > 0 && !queryMeetsMinimum && (searchMode === "manual" || showSearchPromptWhenEmptyQuery);
7408
7436
  const filteredOptions = React24.useMemo(
7409
- () => enableSearch ? options.filter((o) => getOptionLabel(o).toLowerCase().includes(query.trim().toLowerCase())) : options,
7410
- [options, query, enableSearch]
7437
+ () => {
7438
+ if (shouldPromptForSearch) return [];
7439
+ if (!enableSearch || searchMode === "manual") return options;
7440
+ const normalizedQuery = trimmedQuery.toLowerCase();
7441
+ if (!normalizedQuery) return options;
7442
+ return options.filter((o) => getOptionLabel(o).toLowerCase().includes(normalizedQuery));
7443
+ },
7444
+ [enableSearch, options, searchMode, shouldPromptForSearch, trimmedQuery]
7411
7445
  );
7446
+ const renderLimitedOptions = React24.useMemo(
7447
+ () => {
7448
+ if (trimmedQuery || maxInitialOptions === void 0 || maxInitialOptions < 1) {
7449
+ return filteredOptions;
7450
+ }
7451
+ return filteredOptions.slice(0, maxInitialOptions);
7452
+ },
7453
+ [filteredOptions, maxInitialOptions, trimmedQuery]
7454
+ );
7455
+ const canVirtualize = virtualized && !groupBy;
7456
+ const optionVirtualizer = (0, import_react_virtual.useVirtualizer)({
7457
+ count: canVirtualize ? renderLimitedOptions.length : 0,
7458
+ getScrollElement: () => optionsViewportRef.current,
7459
+ estimateSize: () => estimatedItemHeight,
7460
+ initialRect: { width: 0, height: maxHeight },
7461
+ overscan,
7462
+ enabled: canVirtualize
7463
+ });
7464
+ const virtualItems = canVirtualize ? optionVirtualizer.getVirtualItems() : [];
7412
7465
  const triggerRef = React24.useRef(null);
7466
+ const scrollVirtualListToIndex = React24.useCallback((index) => {
7467
+ if (!canVirtualize || renderLimitedOptions.length === 0) return;
7468
+ optionVirtualizer.scrollToIndex(index, { align: "auto" });
7469
+ }, [canVirtualize, optionVirtualizer, renderLimitedOptions.length]);
7470
+ const scrollVirtualListToStart = React24.useCallback(() => {
7471
+ scrollVirtualListToIndex(0);
7472
+ }, [scrollVirtualListToIndex]);
7473
+ const moveActiveIndex = React24.useCallback((direction) => {
7474
+ if (renderLimitedOptions.length === 0) return;
7475
+ const next = activeIndex === null ? direction === 1 ? 0 : renderLimitedOptions.length - 1 : (activeIndex + direction + renderLimitedOptions.length) % renderLimitedOptions.length;
7476
+ setActiveIndex(next);
7477
+ scrollVirtualListToIndex(next);
7478
+ }, [activeIndex, renderLimitedOptions.length, scrollVirtualListToIndex]);
7413
7479
  const handleSelect = (option) => {
7414
7480
  if (getOptionDisabled(option)) return;
7415
7481
  const val = getOptionValue(option);
@@ -7422,6 +7488,9 @@ var Combobox = ({
7422
7488
  };
7423
7489
  const handleClear = (e) => {
7424
7490
  e.stopPropagation();
7491
+ clearValue();
7492
+ };
7493
+ const clearValue = () => {
7425
7494
  onChange(null);
7426
7495
  setOpen(false);
7427
7496
  };
@@ -7429,13 +7498,26 @@ var Combobox = ({
7429
7498
  if (!open) {
7430
7499
  setQuery("");
7431
7500
  setActiveIndex(null);
7501
+ scrollVirtualListToStart();
7432
7502
  } else if (enableSearch) {
7433
7503
  setTimeout(() => {
7434
7504
  inputRef.current?.focus();
7435
7505
  }, 100);
7436
7506
  }
7437
- }, [open, enableSearch]);
7438
- const selectedOption = findOptionByValue(options, value);
7507
+ }, [enableSearch, open, scrollVirtualListToStart]);
7508
+ React24.useEffect(() => {
7509
+ if (!onSearchChange) return void 0;
7510
+ const timeoutId = window.setTimeout(() => onSearchChange(query), searchDebounceMs);
7511
+ return () => window.clearTimeout(timeoutId);
7512
+ }, [onSearchChange, query, searchDebounceMs]);
7513
+ React24.useEffect(() => {
7514
+ if (process.env.NODE_ENV !== "production" && options.length > 300 && !virtualized && searchMode !== "manual" && maxInitialOptions === void 0) {
7515
+ console.warn(
7516
+ '[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.'
7517
+ );
7518
+ }
7519
+ }, [maxInitialOptions, options.length, searchMode, virtualized]);
7520
+ const selectedOption = findOptionByValue(options, value) ?? (selectedOptionProp && getOptionValue(selectedOptionProp) === value ? selectedOptionProp : void 0);
7439
7521
  const displayValue = selectedOption ? getOptionLabel(selectedOption) : "";
7440
7522
  const selectedIcon = selectedOption ? getOptionIcon(selectedOption) : void 0;
7441
7523
  const hasValue = value !== void 0 && value !== null && value !== "";
@@ -7448,13 +7530,13 @@ var Combobox = ({
7448
7530
  const groupedOptions = React24.useMemo(() => {
7449
7531
  if (!groupBy) return null;
7450
7532
  const groups = {};
7451
- filteredOptions.forEach((opt) => {
7533
+ renderLimitedOptions.forEach((opt) => {
7452
7534
  const group = groupBy(opt);
7453
7535
  if (!groups[group]) groups[group] = [];
7454
7536
  groups[group].push(opt);
7455
7537
  });
7456
7538
  return groups;
7457
- }, [filteredOptions, groupBy]);
7539
+ }, [renderLimitedOptions, groupBy]);
7458
7540
  const itemSizeStyles = {
7459
7541
  sm: "px-2.5 py-1.5 text-xs gap-2",
7460
7542
  md: "px-3 py-2.5 text-sm gap-3",
@@ -7470,60 +7552,75 @@ var Combobox = ({
7470
7552
  md: "h-4 w-4",
7471
7553
  lg: "h-5 w-5"
7472
7554
  };
7473
- const renderOptionItem = (item, index) => {
7555
+ const renderOptionItem = (item, index, virtualItem) => {
7474
7556
  const itemValue = getOptionValue(item);
7475
7557
  const itemLabel = getOptionLabel(item);
7476
7558
  const itemIcon = getOptionIcon(item);
7477
7559
  const itemDescription = getOptionDescription(item);
7478
7560
  const itemDisabled = getOptionDisabled(item);
7479
7561
  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",
7562
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
7563
+ "li",
7482
7564
  {
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}`);
7565
+ ref: virtualItem ? optionVirtualizer.measureElement : void 0,
7566
+ "data-index": virtualItem?.index,
7567
+ className: "list-none",
7568
+ style: virtualItem ? {
7569
+ position: "absolute",
7570
+ top: 0,
7571
+ left: 0,
7572
+ width: "100%",
7573
+ transform: `translateY(${virtualItem.start}px)`
7574
+ } : void 0,
7575
+ children: /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)(
7576
+ "button",
7577
+ {
7578
+ id: `${resolvedId}-item-${index}`,
7579
+ type: "button",
7580
+ role: "option",
7581
+ tabIndex: -1,
7582
+ disabled: itemDisabled,
7583
+ "aria-selected": isSelected,
7584
+ onClick: () => handleSelect(item),
7585
+ style: {
7586
+ animationDelay: open ? `${Math.min(index * 15, 150)}ms` : "0ms"
7587
+ },
7588
+ className: cn(
7589
+ "dropdown-item group flex w-full items-center rounded-full text-left",
7590
+ itemSizeStyles[size],
7591
+ "outline-none focus:outline-none focus-visible:outline-none",
7592
+ "transition-all duration-150",
7593
+ !itemDisabled && "cursor-pointer hover:bg-accent/70 hover:shadow-sm",
7594
+ !itemDisabled && "focus:bg-accent/80 focus:text-accent-foreground",
7595
+ index === activeIndex && !itemDisabled && "bg-accent/60",
7596
+ isSelected && "bg-primary/10 text-primary font-medium",
7597
+ itemDisabled && "opacity-50 cursor-not-allowed"
7598
+ ),
7599
+ children: [
7600
+ itemIcon && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
7601
+ "span",
7602
+ {
7603
+ className: cn("shrink-0 flex items-center justify-center", iconSizeStyles[size], isSelected ? "text-primary" : "text-muted-foreground"),
7604
+ children: itemIcon
7605
+ }
7606
+ ),
7607
+ 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: [
7608
+ /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("span", { className: "block truncate", children: itemLabel }),
7609
+ 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 })
7610
+ ] }),
7611
+ 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") }) })
7612
+ ]
7613
+ }
7614
+ )
7615
+ },
7616
+ `${itemValue}-${index}`
7617
+ );
7520
7618
  };
7521
7619
  const dropdownBody = /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)(
7522
7620
  "div",
7523
7621
  {
7524
7622
  "data-combobox-dropdown": true,
7525
7623
  "data-state": open ? "open" : "closed",
7526
- id: `${resolvedId}-listbox`,
7527
7624
  className: "w-full rounded-2xl md:rounded-3xl overflow-hidden",
7528
7625
  children: [
7529
7626
  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 +7641,19 @@ var Combobox = ({
7544
7641
  onChange: (e) => {
7545
7642
  setQuery(e.target.value);
7546
7643
  setActiveIndex(null);
7644
+ scrollVirtualListToStart();
7547
7645
  },
7548
7646
  onKeyDown: (e) => {
7549
7647
  if (e.key === "ArrowDown") {
7550
7648
  e.preventDefault();
7551
- setActiveIndex((prev) => {
7552
- const next = prev === null ? 0 : prev + 1;
7553
- return next >= filteredOptions.length ? 0 : next;
7554
- });
7649
+ moveActiveIndex(1);
7555
7650
  } else if (e.key === "ArrowUp") {
7556
7651
  e.preventDefault();
7557
- setActiveIndex((prev) => {
7558
- const next = prev === null ? filteredOptions.length - 1 : prev - 1;
7559
- return next < 0 ? filteredOptions.length - 1 : next;
7560
- });
7652
+ moveActiveIndex(-1);
7561
7653
  } else if (e.key === "Enter") {
7562
7654
  e.preventDefault();
7563
- if (activeIndex !== null && filteredOptions[activeIndex] && !getOptionDisabled(filteredOptions[activeIndex])) {
7564
- handleSelect(filteredOptions[activeIndex]);
7655
+ if (activeIndex !== null && renderLimitedOptions[activeIndex] && !getOptionDisabled(renderLimitedOptions[activeIndex])) {
7656
+ handleSelect(renderLimitedOptions[activeIndex]);
7565
7657
  }
7566
7658
  } else if (e.key === "Escape") {
7567
7659
  e.preventDefault();
@@ -7584,7 +7676,10 @@ var Combobox = ({
7584
7676
  "button",
7585
7677
  {
7586
7678
  type: "button",
7587
- onClick: () => setQuery(""),
7679
+ onClick: () => {
7680
+ setQuery("");
7681
+ scrollVirtualListToStart();
7682
+ },
7588
7683
  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
7684
  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
7685
  }
@@ -7594,14 +7689,22 @@ var Combobox = ({
7594
7689
  "div",
7595
7690
  {
7596
7691
  ref: optionsViewportRef,
7692
+ id: `${resolvedId}-listbox`,
7597
7693
  role: "listbox",
7598
7694
  "aria-labelledby": labelId,
7599
- className: "overflow-y-auto overscroll-contain",
7695
+ className: cn("overflow-y-auto overscroll-contain", (!useOverlayScrollbar || virtualized) && comboboxScrollClassName),
7600
7696
  style: { maxHeight },
7601
7697
  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
7698
  /* @__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
7699
  /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("span", { className: "text-sm text-muted-foreground", children: loadingText })
7604
- ] }) }) : filteredOptions.length > 0 ? groupedOptions ? (
7700
+ ] }) }) : 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: [
7701
+ /* @__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" }) }),
7702
+ /* @__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: [
7703
+ "Type at least ",
7704
+ minSearchLength,
7705
+ " characters to search"
7706
+ ] }) })
7707
+ ] }) }) : renderLimitedOptions.length > 0 ? groupedOptions ? (
7605
7708
  // Render grouped options with global index tracking
7606
7709
  (() => {
7607
7710
  let globalIndex = 0;
@@ -7615,7 +7718,14 @@ var Combobox = ({
7615
7718
  })()
7616
7719
  ) : (
7617
7720
  // Render flat options
7618
- /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("ul", { className: "space-y-0.5", children: filteredOptions.map((item, index) => renderOptionItem(item, index)) })
7721
+ /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
7722
+ "ul",
7723
+ {
7724
+ className: "space-y-0.5",
7725
+ style: canVirtualize ? { height: `${optionVirtualizer.getTotalSize()}px`, position: "relative" } : void 0,
7726
+ children: canVirtualize ? virtualItems.map((virtualItem) => renderOptionItem(renderLimitedOptions[virtualItem.index], virtualItem.index, virtualItem)) : renderLimitedOptions.map((item, index) => renderOptionItem(item, index))
7727
+ }
7728
+ )
7619
7729
  ) : /* @__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
7730
  /* @__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
7731
  /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("div", { className: "space-y-1", children: [
@@ -7626,7 +7736,10 @@ var Combobox = ({
7626
7736
  "button",
7627
7737
  {
7628
7738
  type: "button",
7629
- onClick: () => setQuery(""),
7739
+ onClick: () => {
7740
+ setQuery("");
7741
+ scrollVirtualListToStart();
7742
+ },
7630
7743
  className: "px-3 py-1.5 text-xs font-medium text-primary bg-primary/10 rounded-full hover:bg-primary/20 transition-colors",
7631
7744
  children: "Clear search"
7632
7745
  }
@@ -7696,7 +7809,13 @@ var Combobox = ({
7696
7809
  tabIndex: 0,
7697
7810
  "aria-label": "Clear selection",
7698
7811
  onClick: handleClear,
7699
- onKeyDown: (e) => (e.key === "Enter" || e.key === " ") && handleClear(e),
7812
+ onKeyDown: (e) => {
7813
+ if (e.key === "Enter" || e.key === " ") {
7814
+ e.preventDefault();
7815
+ e.stopPropagation();
7816
+ clearValue();
7817
+ }
7818
+ },
7700
7819
  className: cn(
7701
7820
  "opacity-0 group-hover:opacity-100 transition-all duration-200",
7702
7821
  "p-1 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
@@ -15576,8 +15695,23 @@ function CalendarTimeline({
15576
15695
  // src/components/MultiCombobox.tsx
15577
15696
  var React39 = __toESM(require("react"), 1);
15578
15697
  var import_react20 = require("react");
15698
+ var import_react_virtual2 = require("@tanstack/react-virtual");
15579
15699
  var import_lucide_react24 = require("lucide-react");
15580
15700
  var import_jsx_runtime45 = require("react/jsx-runtime");
15701
+ var comboboxScrollClassName2 = [
15702
+ "scrollbar-thin",
15703
+ "[scrollbar-width:thin]",
15704
+ "[scrollbar-color:color-mix(in_oklch,var(--muted-foreground)_28%,transparent)_transparent]",
15705
+ "[&::-webkit-scrollbar]:w-2",
15706
+ "[&::-webkit-scrollbar-track]:bg-transparent",
15707
+ "[&::-webkit-scrollbar-thumb]:rounded-full",
15708
+ "[&::-webkit-scrollbar-thumb]:border-2",
15709
+ "[&::-webkit-scrollbar-thumb]:border-solid",
15710
+ "[&::-webkit-scrollbar-thumb]:border-transparent",
15711
+ "[&::-webkit-scrollbar-thumb]:bg-clip-padding",
15712
+ "[&::-webkit-scrollbar-thumb]:bg-muted-foreground/25",
15713
+ "[&::-webkit-scrollbar-thumb:hover]:bg-muted-foreground/45"
15714
+ ].join(" ");
15581
15715
  var MultiCombobox = ({
15582
15716
  id,
15583
15717
  options,
@@ -15606,10 +15740,20 @@ var MultiCombobox = ({
15606
15740
  groupBy,
15607
15741
  renderOption,
15608
15742
  renderTag,
15743
+ selectedOptions: selectedOptionsProp,
15609
15744
  error,
15610
15745
  helperText,
15611
15746
  maxTagsVisible = 3,
15612
- useOverlayScrollbar = false
15747
+ useOverlayScrollbar = true,
15748
+ virtualized = false,
15749
+ estimatedItemHeight = 44,
15750
+ overscan = 8,
15751
+ searchMode = "auto",
15752
+ onSearchChange,
15753
+ searchDebounceMs = 0,
15754
+ minSearchLength = 0,
15755
+ maxInitialOptions,
15756
+ showSearchPromptWhenEmptyQuery = false
15613
15757
  }) => {
15614
15758
  const tv = useSmartTranslations("ValidationInput");
15615
15759
  const [query, setQuery] = React39.useState("");
@@ -15619,7 +15763,7 @@ var MultiCombobox = ({
15619
15763
  const inputRef = React39.useRef(null);
15620
15764
  const listRef = React39.useRef([]);
15621
15765
  const optionsListRef = React39.useRef(null);
15622
- useOverlayScrollbarTarget(optionsListRef, { enabled: useOverlayScrollbar });
15766
+ useOverlayScrollbarTarget(optionsListRef, { enabled: useOverlayScrollbar && !virtualized });
15623
15767
  const triggerRef = React39.useRef(null);
15624
15768
  useShadCNAnimations();
15625
15769
  const normalizedOptions = React39.useMemo(
@@ -15628,23 +15772,52 @@ var MultiCombobox = ({
15628
15772
  ),
15629
15773
  [options]
15630
15774
  );
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
- );
15775
+ const enableSearch = normalizedOptions.length > 10 || searchMode === "manual" || minSearchLength > 0 || !!onSearchChange;
15776
+ const trimmedQuery = query.trim();
15777
+ const queryMeetsMinimum = trimmedQuery.length >= minSearchLength;
15778
+ const shouldPromptForSearch = minSearchLength > 0 && !queryMeetsMinimum && (searchMode === "manual" || showSearchPromptWhenEmptyQuery);
15779
+ const filtered = React39.useMemo(() => {
15780
+ if (shouldPromptForSearch) return [];
15781
+ if (!enableSearch || searchMode === "manual") return normalizedOptions;
15782
+ const normalizedQuery = trimmedQuery.toLowerCase();
15783
+ if (!normalizedQuery) return normalizedOptions;
15784
+ return normalizedOptions.filter(
15785
+ (opt) => opt.label.toLowerCase().includes(normalizedQuery) || opt.description?.toLowerCase().includes(normalizedQuery)
15786
+ );
15787
+ }, [enableSearch, normalizedOptions, searchMode, shouldPromptForSearch, trimmedQuery]);
15788
+ const renderLimitedOptions = React39.useMemo(() => {
15789
+ if (trimmedQuery || maxInitialOptions === void 0 || maxInitialOptions < 1) {
15790
+ return filtered;
15791
+ }
15792
+ return filtered.slice(0, maxInitialOptions);
15793
+ }, [filtered, maxInitialOptions, trimmedQuery]);
15794
+ const canVirtualize = virtualized && !groupBy;
15795
+ const optionVirtualizer = (0, import_react_virtual2.useVirtualizer)({
15796
+ count: canVirtualize ? renderLimitedOptions.length : 0,
15797
+ getScrollElement: () => optionsListRef.current,
15798
+ estimateSize: () => estimatedItemHeight,
15799
+ initialRect: { width: 0, height: maxHeight },
15800
+ overscan,
15801
+ enabled: canVirtualize
15802
+ });
15803
+ const virtualItems = canVirtualize ? optionVirtualizer.getVirtualItems() : [];
15804
+ const scrollVirtualListToIndex = React39.useCallback((index) => {
15805
+ if (!canVirtualize || renderLimitedOptions.length === 0) return;
15806
+ optionVirtualizer.scrollToIndex(index, { align: "auto" });
15807
+ }, [canVirtualize, optionVirtualizer, renderLimitedOptions.length]);
15808
+ const scrollVirtualListToStart = React39.useCallback(() => {
15809
+ scrollVirtualListToIndex(0);
15810
+ }, [scrollVirtualListToIndex]);
15638
15811
  const groupedOptions = React39.useMemo(() => {
15639
15812
  if (!groupBy) return null;
15640
15813
  const groups = /* @__PURE__ */ new Map();
15641
- filtered.forEach((opt) => {
15814
+ renderLimitedOptions.forEach((opt) => {
15642
15815
  const group = groupBy(opt);
15643
15816
  if (!groups.has(group)) groups.set(group, []);
15644
15817
  groups.get(group).push(opt);
15645
15818
  });
15646
15819
  return groups;
15647
- }, [filtered, groupBy]);
15820
+ }, [renderLimitedOptions, groupBy]);
15648
15821
  const toggleSelect = (optionValue) => {
15649
15822
  const option = normalizedOptions.find((o) => o.value === optionValue);
15650
15823
  if (option?.disabled || disabledOptions.includes(optionValue)) return;
@@ -15662,11 +15835,26 @@ var MultiCombobox = ({
15662
15835
  };
15663
15836
  const handleKeyDown2 = (e) => {
15664
15837
  if (!open) setOpen(true);
15665
- if (e.key === "Enter") {
15838
+ if (e.key === "ArrowDown") {
15839
+ e.preventDefault();
15840
+ if (renderLimitedOptions.length === 0) return;
15841
+ const next = activeIndex === null ? 0 : (activeIndex + 1) % renderLimitedOptions.length;
15842
+ setActiveIndex(next);
15843
+ scrollVirtualListToIndex(next);
15844
+ } else if (e.key === "ArrowUp") {
15666
15845
  e.preventDefault();
15667
- if (activeIndex !== null && filtered[activeIndex]) {
15668
- toggleSelect(filtered[activeIndex].value);
15846
+ if (renderLimitedOptions.length === 0) return;
15847
+ const next = activeIndex === null ? renderLimitedOptions.length - 1 : (activeIndex - 1 + renderLimitedOptions.length) % renderLimitedOptions.length;
15848
+ setActiveIndex(next);
15849
+ scrollVirtualListToIndex(next);
15850
+ } else if (e.key === "Enter") {
15851
+ e.preventDefault();
15852
+ if (activeIndex !== null && renderLimitedOptions[activeIndex]) {
15853
+ toggleSelect(renderLimitedOptions[activeIndex].value);
15669
15854
  }
15855
+ } else if (e.key === "Escape") {
15856
+ e.preventDefault();
15857
+ setOpen(false);
15670
15858
  }
15671
15859
  };
15672
15860
  const handleClearAll = () => {
@@ -15683,8 +15871,24 @@ var MultiCombobox = ({
15683
15871
  setTimeout(() => {
15684
15872
  inputRef.current?.focus();
15685
15873
  }, 100);
15874
+ } else if (!open) {
15875
+ setQuery("");
15876
+ setActiveIndex(null);
15877
+ scrollVirtualListToStart();
15878
+ }
15879
+ }, [enableSearch, open, scrollVirtualListToStart]);
15880
+ React39.useEffect(() => {
15881
+ if (!onSearchChange) return void 0;
15882
+ const timeoutId = window.setTimeout(() => onSearchChange(query), searchDebounceMs);
15883
+ return () => window.clearTimeout(timeoutId);
15884
+ }, [onSearchChange, query, searchDebounceMs]);
15885
+ React39.useEffect(() => {
15886
+ if (process.env.NODE_ENV !== "production" && normalizedOptions.length > 300 && !virtualized && searchMode !== "manual" && maxInitialOptions === void 0) {
15887
+ console.warn(
15888
+ '[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.'
15889
+ );
15686
15890
  }
15687
- }, [open, enableSearch]);
15891
+ }, [maxInitialOptions, normalizedOptions.length, searchMode, virtualized]);
15688
15892
  const sizeStyles8 = {
15689
15893
  sm: {
15690
15894
  trigger: "h-8 px-3 py-1.5 text-sm md:h-7 md:text-xs",
@@ -15718,25 +15922,38 @@ var MultiCombobox = ({
15718
15922
  const labelId = label ? `${resolvedId}-label` : void 0;
15719
15923
  const labelSize = size === "sm" ? "text-xs" : size === "lg" ? "text-base" : "text-sm";
15720
15924
  const listboxId = `${resolvedId}-listbox`;
15721
- const renderOptionItem = (item, index) => {
15925
+ const renderOptionItem = (item, index, virtualItem) => {
15722
15926
  const isSelected = value.includes(item.value);
15723
15927
  const isDisabled = item.disabled || disabledOptions.includes(item.value);
15724
15928
  const optionIcon = item.icon;
15725
15929
  const optionDesc = item.description;
15930
+ const itemStyle = {
15931
+ animationDelay: open ? `${Math.min(index * 20, 200)}ms` : "0ms",
15932
+ ...virtualItem ? {
15933
+ position: "absolute",
15934
+ top: 0,
15935
+ left: 0,
15936
+ width: "100%",
15937
+ transform: `translateY(${virtualItem.start}px)`
15938
+ } : {}
15939
+ };
15940
+ const measureRef = virtualItem ? optionVirtualizer.measureElement : void 0;
15726
15941
  if (renderOption) {
15727
15942
  return /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
15728
15943
  "li",
15729
15944
  {
15730
15945
  ref: (node) => {
15946
+ measureRef?.(node);
15731
15947
  listRef.current[index] = node;
15732
15948
  },
15949
+ "data-index": virtualItem?.index,
15950
+ style: itemStyle,
15733
15951
  onClick: (e) => {
15734
15952
  e.preventDefault();
15735
15953
  e.stopPropagation();
15736
15954
  if (!isDisabled) toggleSelect(item.value);
15737
15955
  inputRef.current?.focus();
15738
15956
  },
15739
- style: { animationDelay: open ? `${Math.min(index * 20, 200)}ms` : "0ms" },
15740
15957
  className: cn("dropdown-item", isDisabled && "opacity-50 cursor-not-allowed pointer-events-none"),
15741
15958
  children: renderOption(item, isSelected)
15742
15959
  },
@@ -15747,15 +15964,17 @@ var MultiCombobox = ({
15747
15964
  "li",
15748
15965
  {
15749
15966
  ref: (node) => {
15967
+ measureRef?.(node);
15750
15968
  listRef.current[index] = node;
15751
15969
  },
15970
+ "data-index": virtualItem?.index,
15971
+ style: itemStyle,
15752
15972
  onClick: (e) => {
15753
15973
  e.preventDefault();
15754
15974
  e.stopPropagation();
15755
15975
  if (!isDisabled) toggleSelect(item.value);
15756
15976
  inputRef.current?.focus();
15757
15977
  },
15758
- style: { animationDelay: open ? `${Math.min(index * 20, 200)}ms` : "0ms" },
15759
15978
  className: cn(
15760
15979
  "dropdown-item flex cursor-pointer items-center gap-3 rounded-full transition-all duration-200",
15761
15980
  sizeStyles8[size].item,
@@ -15819,6 +16038,7 @@ var MultiCombobox = ({
15819
16038
  onChange: (e) => {
15820
16039
  setQuery(e.target.value);
15821
16040
  setActiveIndex(null);
16041
+ scrollVirtualListToStart();
15822
16042
  },
15823
16043
  onKeyDown: handleKeyDown2,
15824
16044
  placeholder: searchPlaceholder,
@@ -15829,7 +16049,10 @@ var MultiCombobox = ({
15829
16049
  "button",
15830
16050
  {
15831
16051
  type: "button",
15832
- onClick: () => setQuery(""),
16052
+ onClick: () => {
16053
+ setQuery("");
16054
+ scrollVirtualListToStart();
16055
+ },
15833
16056
  className: "absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors",
15834
16057
  children: /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(import_lucide_react24.X, { className: "w-4 h-4" })
15835
16058
  }
@@ -15843,37 +16066,63 @@ var MultiCombobox = ({
15843
16066
  "aria-multiselectable": "true",
15844
16067
  ref: optionsListRef,
15845
16068
  style: { maxHeight },
15846
- className: cn("overflow-y-auto p-1.5", size === "lg" ? "text-base" : size === "sm" ? "text-xs" : "text-sm"),
16069
+ className: cn(
16070
+ "overflow-y-auto p-1.5",
16071
+ (!useOverlayScrollbar || virtualized) && comboboxScrollClassName2,
16072
+ size === "lg" ? "text-base" : size === "sm" ? "text-xs" : "text-sm"
16073
+ ),
15847
16074
  children: loading2 ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("li", { className: "px-3 py-8 text-center", 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: [
15848
16075
  /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("div", { className: "relative", children: [
15849
16076
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(import_lucide_react24.Loader2, { className: "h-8 w-8 animate-spin text-primary" }),
15850
16077
  /* @__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
16078
  ] }),
15852
16079
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { className: "text-muted-foreground font-medium", children: loadingText })
15853
- ] }) }) : filtered.length ? groupedOptions ? (
16080
+ ] }) }) : 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: [
16081
+ /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(import_lucide_react24.Search, { className: "h-10 w-10 opacity-30 text-muted-foreground" }),
16082
+ /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("span", { className: "font-medium block text-foreground", children: [
16083
+ "Type at least ",
16084
+ minSearchLength,
16085
+ " characters to search"
16086
+ ] })
16087
+ ] }) }) : renderLimitedOptions.length ? groupedOptions ? (
15854
16088
  // Render grouped options
15855
16089
  Array.from(groupedOptions.entries()).map(([group, items]) => /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("li", { className: "mb-2", children: [
15856
16090
  /* @__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))) })
16091
+ /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("ul", { children: items.map((item) => renderOptionItem(item, renderLimitedOptions.indexOf(item))) })
15858
16092
  ] }, group))
15859
16093
  ) : (
15860
16094
  // Render flat options
15861
- filtered.map((item, index) => renderOptionItem(item, index))
16095
+ 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
16096
  ) : /* @__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
16097
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(import_lucide_react24.SearchX, { className: "h-10 w-10 opacity-30 text-muted-foreground" }),
15864
16098
  /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("div", { className: "space-y-1", children: [
15865
16099
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { className: "font-medium block", children: emptyText }),
15866
16100
  query && /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { className: "text-xs opacity-60", children: "Try a different search term" })
15867
16101
  ] }),
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
- ] })
16102
+ query && /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)(
16103
+ "button",
16104
+ {
16105
+ type: "button",
16106
+ onClick: () => {
16107
+ setQuery("");
16108
+ scrollVirtualListToStart();
16109
+ },
16110
+ className: "text-xs text-primary hover:underline flex items-center gap-1",
16111
+ children: [
16112
+ /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(import_lucide_react24.X, { className: "w-3 h-3" }),
16113
+ "Clear search"
16114
+ ]
16115
+ }
16116
+ )
15872
16117
  ] }) })
15873
16118
  }
15874
16119
  )
15875
16120
  ] });
15876
- const selectedOptions = value.map((v) => normalizedOptions.find((o) => o.value === v)).filter(Boolean);
16121
+ const selectedOptionFallbackMap = React39.useMemo(
16122
+ () => new Map((selectedOptionsProp ?? []).map((option) => [option.value, option])),
16123
+ [selectedOptionsProp]
16124
+ );
16125
+ const selectedOptions = value.map((v) => normalizedOptions.find((o) => o.value === v) ?? selectedOptionFallbackMap.get(v)).filter(Boolean);
15877
16126
  const visibleTags = maxTagsVisible ? selectedOptions.slice(0, maxTagsVisible) : selectedOptions;
15878
16127
  const hiddenCount = maxTagsVisible ? Math.max(0, selectedOptions.length - maxTagsVisible) : 0;
15879
16128
  const triggerButton = /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)(