@xsolla/xui-autocomplete 0.65.0 → 0.66.0-pr91.1768892553

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.
@@ -1,11 +1,21 @@
1
1
  import React from 'react';
2
2
 
3
+ interface AutocompleteOption {
4
+ id: string;
5
+ label: string;
6
+ description?: string;
7
+ icon?: React.ReactNode;
8
+ disabled?: boolean;
9
+ }
3
10
  interface AutocompleteProps {
4
11
  value?: string;
5
12
  placeholder?: string;
6
13
  onValueChange?: (value: string) => void;
7
- onSelect?: (option: string) => void;
14
+ onSelect?: (option: string | AutocompleteOption) => void;
15
+ /** Simple string options for basic usage */
8
16
  options?: string[];
17
+ /** Rich options with id, label, description, icon */
18
+ list?: AutocompleteOption[];
9
19
  isLoading?: boolean;
10
20
  size?: "xl" | "l" | "m" | "s" | "xs";
11
21
  state?: "default" | "hover" | "focus" | "disable" | "error";
@@ -14,7 +24,13 @@ interface AutocompleteProps {
14
24
  iconLeft?: React.ReactNode;
15
25
  chevronRight?: boolean;
16
26
  filled?: boolean;
27
+ /** Maximum height of the dropdown (default: 250) */
28
+ maxHeight?: number;
29
+ /** Width of the dropdown (default: matches input width) */
30
+ dropdownWidth?: number | string;
31
+ /** Empty state message when no options match */
32
+ emptyMessage?: string;
17
33
  }
18
34
  declare const Autocomplete: React.FC<AutocompleteProps>;
19
35
 
20
- export { Autocomplete, type AutocompleteProps };
36
+ export { Autocomplete, type AutocompleteOption, type AutocompleteProps };
package/native/index.d.ts CHANGED
@@ -1,11 +1,21 @@
1
1
  import React from 'react';
2
2
 
3
+ interface AutocompleteOption {
4
+ id: string;
5
+ label: string;
6
+ description?: string;
7
+ icon?: React.ReactNode;
8
+ disabled?: boolean;
9
+ }
3
10
  interface AutocompleteProps {
4
11
  value?: string;
5
12
  placeholder?: string;
6
13
  onValueChange?: (value: string) => void;
7
- onSelect?: (option: string) => void;
14
+ onSelect?: (option: string | AutocompleteOption) => void;
15
+ /** Simple string options for basic usage */
8
16
  options?: string[];
17
+ /** Rich options with id, label, description, icon */
18
+ list?: AutocompleteOption[];
9
19
  isLoading?: boolean;
10
20
  size?: "xl" | "l" | "m" | "s" | "xs";
11
21
  state?: "default" | "hover" | "focus" | "disable" | "error";
@@ -14,7 +24,13 @@ interface AutocompleteProps {
14
24
  iconLeft?: React.ReactNode;
15
25
  chevronRight?: boolean;
16
26
  filled?: boolean;
27
+ /** Maximum height of the dropdown (default: 250) */
28
+ maxHeight?: number;
29
+ /** Width of the dropdown (default: matches input width) */
30
+ dropdownWidth?: number | string;
31
+ /** Empty state message when no options match */
32
+ emptyMessage?: string;
17
33
  }
18
34
  declare const Autocomplete: React.FC<AutocompleteProps>;
19
35
 
20
- export { Autocomplete, type AutocompleteProps };
36
+ export { Autocomplete, type AutocompleteOption, type AutocompleteProps };
package/native/index.js CHANGED
@@ -518,6 +518,7 @@ TextAreaPrimitive.displayName = "TextAreaPrimitive";
518
518
 
519
519
  // src/Autocomplete.tsx
520
520
  var import_xui_core = require("@xsolla/xui-core");
521
+ var import_xui_spinner = require("@xsolla/xui-spinner");
521
522
  var import_jsx_runtime8 = require("react/jsx-runtime");
522
523
  var SearchIcon = () => /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
523
524
  "svg",
@@ -623,12 +624,28 @@ var CloseIcon = () => /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
623
624
  ]
624
625
  }
625
626
  );
627
+ var mapToContextMenuSize = (size) => {
628
+ switch (size) {
629
+ case "xs":
630
+ case "s":
631
+ return "s";
632
+ case "m":
633
+ return "m";
634
+ case "l":
635
+ return "l";
636
+ case "xl":
637
+ return "xl";
638
+ default:
639
+ return "m";
640
+ }
641
+ };
626
642
  var Autocomplete = ({
627
643
  value: propValue,
628
644
  placeholder = "Search...",
629
645
  onValueChange,
630
646
  onSelect,
631
647
  options = [],
648
+ list,
632
649
  isLoading = false,
633
650
  size = "m",
634
651
  state: externalState,
@@ -636,12 +653,17 @@ var Autocomplete = ({
636
653
  errorLabel,
637
654
  iconLeft = /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(SearchIcon, {}),
638
655
  chevronRight = true,
639
- filled = true
656
+ filled = true,
657
+ maxHeight = 250,
658
+ dropdownWidth,
659
+ emptyMessage = "No results found"
640
660
  }) => {
641
661
  const { theme } = (0, import_xui_core.useDesignSystem)();
642
662
  const [internalValue, setInternalValue] = (0, import_react4.useState)(propValue || "");
643
663
  const [isFocused, setIsFocused] = (0, import_react4.useState)(false);
664
+ const [activeIndex, setActiveIndex] = (0, import_react4.useState)(-1);
644
665
  const containerRef = (0, import_react4.useRef)(null);
666
+ const inputRef = (0, import_react4.useRef)(null);
645
667
  const value = propValue !== void 0 ? propValue : internalValue;
646
668
  const state = externalState || (isFocused ? "focus" : "default");
647
669
  const isDisable = state === "disable";
@@ -649,6 +671,19 @@ var Autocomplete = ({
649
671
  const isFocus = state === "focus";
650
672
  const sizeStyles = theme.sizing.input(size);
651
673
  const inputColors = theme.colors.control.input;
674
+ const contextMenuSize = mapToContextMenuSize(size);
675
+ const menuSizeStyles = theme.sizing.contextMenu(contextMenuSize);
676
+ const normalizedOptions = list || options.map((opt, index) => ({
677
+ id: String(index),
678
+ label: opt
679
+ }));
680
+ const hasOptions = normalizedOptions.length > 0;
681
+ const showDropdown = isFocus && (hasOptions || isLoading || value.length > 0);
682
+ (0, import_react4.useEffect)(() => {
683
+ if (!showDropdown) {
684
+ setActiveIndex(-1);
685
+ }
686
+ }, [showDropdown]);
652
687
  (0, import_react4.useEffect)(() => {
653
688
  const handleClickOutside = (event) => {
654
689
  if (containerRef.current && !containerRef.current.contains(event.target)) {
@@ -662,23 +697,81 @@ var Autocomplete = ({
662
697
  document.removeEventListener("mousedown", handleClickOutside);
663
698
  };
664
699
  }, [isFocused]);
700
+ (0, import_react4.useEffect)(() => {
701
+ const handleEscape = (event) => {
702
+ if (event.key === "Escape" && isFocused) {
703
+ setIsFocused(false);
704
+ }
705
+ };
706
+ if (isFocused) {
707
+ document.addEventListener("keydown", handleEscape);
708
+ }
709
+ return () => {
710
+ document.removeEventListener("keydown", handleEscape);
711
+ };
712
+ }, [isFocused]);
665
713
  const handleInputChange = (text) => {
666
714
  if (!isDisable) {
667
715
  setInternalValue(text);
668
716
  if (onValueChange) onValueChange(text);
669
- setIsFocused(true);
717
+ setActiveIndex(-1);
670
718
  }
671
719
  };
672
- const handleSelect = (option) => {
673
- setInternalValue(option);
674
- setIsFocused(false);
675
- if (onSelect) onSelect(option);
676
- if (onValueChange) onValueChange(option);
677
- };
720
+ const handleSelect = (0, import_react4.useCallback)(
721
+ (option) => {
722
+ setInternalValue(option.label);
723
+ setIsFocused(false);
724
+ if (onSelect) {
725
+ if (list) {
726
+ onSelect(option);
727
+ } else {
728
+ onSelect(option.label);
729
+ }
730
+ }
731
+ if (onValueChange) onValueChange(option.label);
732
+ },
733
+ [list, onSelect, onValueChange]
734
+ );
678
735
  const handleClear = (e) => {
679
736
  e.stopPropagation();
680
737
  handleInputChange("");
681
738
  };
739
+ const findNextEnabledIndex = (currentIndex, direction) => {
740
+ const length = normalizedOptions.length;
741
+ let nextIndex = currentIndex;
742
+ for (let i = 0; i < length; i++) {
743
+ nextIndex = (nextIndex + direction + length) % length;
744
+ if (!normalizedOptions[nextIndex].disabled) {
745
+ return nextIndex;
746
+ }
747
+ }
748
+ return -1;
749
+ };
750
+ const handleInputKeyDown = (event) => {
751
+ if (!showDropdown || normalizedOptions.length === 0) return;
752
+ switch (event.key) {
753
+ case "ArrowDown":
754
+ event.preventDefault();
755
+ setActiveIndex((prev) => findNextEnabledIndex(prev, 1));
756
+ break;
757
+ case "ArrowUp":
758
+ event.preventDefault();
759
+ setActiveIndex((prev) => findNextEnabledIndex(prev, -1));
760
+ break;
761
+ case "Enter":
762
+ event.preventDefault();
763
+ if (activeIndex >= 0 && activeIndex < normalizedOptions.length) {
764
+ const option = normalizedOptions[activeIndex];
765
+ if (!option.disabled) {
766
+ handleSelect(option);
767
+ }
768
+ }
769
+ break;
770
+ case "Tab":
771
+ setIsFocused(false);
772
+ break;
773
+ }
774
+ };
682
775
  let backgroundColor = inputColors.bg;
683
776
  let borderColor = inputColors.border;
684
777
  if (isDisable) {
@@ -699,6 +792,12 @@ var Autocomplete = ({
699
792
  const textColor = isDisable ? inputColors.textDisable : inputColors.text;
700
793
  const placeholderColor = inputColors.placeholder;
701
794
  const iconColor = isDisable ? inputColors.textDisable : inputColors.text;
795
+ const getItemBackgroundColor = (index, disabled) => {
796
+ if (activeIndex === index && !disabled) {
797
+ return theme.colors.control.input.bgHover;
798
+ }
799
+ return "transparent";
800
+ };
702
801
  return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
703
802
  Box,
704
803
  {
@@ -740,24 +839,49 @@ var Autocomplete = ({
740
839
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Box, { flex: 1, height: "100%", justifyContent: "center", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
741
840
  InputPrimitive,
742
841
  {
842
+ ref: inputRef,
743
843
  value,
744
844
  placeholder,
745
845
  onChangeText: handleInputChange,
746
846
  onFocus: () => !isDisable && setIsFocused(true),
847
+ onKeyDown: handleInputKeyDown,
747
848
  disabled: isDisable,
748
849
  color: textColor,
749
850
  fontSize: sizeStyles.fontSize,
750
- placeholderTextColor: placeholderColor
851
+ placeholderTextColor: placeholderColor,
852
+ role: "combobox",
853
+ "aria-expanded": showDropdown,
854
+ "aria-haspopup": "listbox",
855
+ "aria-controls": "autocomplete-listbox",
856
+ "aria-activedescendant": activeIndex >= 0 ? `autocomplete-option-${normalizedOptions[activeIndex]?.id}` : void 0
751
857
  }
752
858
  ) }),
753
859
  /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(Box, { flexDirection: "row", alignItems: "center", gap: 4, children: [
754
860
  value.length > 0 && !isDisable && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Box, { onPress: handleClear, padding: 2, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Icon, { size: sizeStyles.iconSize - 2, color: iconColor, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(CloseIcon, {}) }) }),
755
- isLoading ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Spinner, { size: sizeStyles.iconSize, color: iconColor }) : chevronRight && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Box, { alignItems: "center", justifyContent: "center", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Icon, { size: sizeStyles.iconSize, color: iconColor, children: isFocus ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(ChevronUp, {}) : /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(ChevronDown, {}) }) })
861
+ chevronRight && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
862
+ Box,
863
+ {
864
+ alignItems: "center",
865
+ justifyContent: "center",
866
+ onPress: (e) => {
867
+ e.stopPropagation();
868
+ if (!isDisable) {
869
+ const newFocusState = !isFocused;
870
+ setIsFocused(newFocusState);
871
+ if (newFocusState && inputRef.current) {
872
+ inputRef.current.focus();
873
+ }
874
+ }
875
+ },
876
+ style: { cursor: isDisable ? "not-allowed" : "pointer" },
877
+ children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Icon, { size: sizeStyles.iconSize, color: iconColor, children: isFocus ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(ChevronUp, {}) : /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(ChevronDown, {}) })
878
+ }
879
+ )
756
880
  ] })
757
881
  ]
758
882
  }
759
883
  ),
760
- isFocus && (options.length > 0 || isLoading) && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
884
+ showDropdown && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
761
885
  Box,
762
886
  {
763
887
  position: "absolute",
@@ -768,33 +892,96 @@ var Autocomplete = ({
768
892
  borderColor: theme.colors.border.secondary,
769
893
  borderWidth: 1,
770
894
  borderRadius: theme.radius.button,
771
- paddingVertical: 4,
895
+ paddingVertical: menuSizeStyles.paddingVertical,
896
+ width: dropdownWidth,
772
897
  style: {
773
898
  zIndex: 1e3,
774
- boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
775
- maxHeight: 250,
899
+ boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
900
+ maxHeight,
776
901
  overflowY: "auto"
777
902
  },
778
- children: isLoading && options.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Box, { padding: 12, alignItems: "center", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Spinner, { size: 24, color: theme.colors.content.secondary }) }) : options.map((option, index) => /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
903
+ id: "autocomplete-listbox",
904
+ role: "listbox",
905
+ "aria-label": "Autocomplete suggestions",
906
+ children: isLoading ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
779
907
  Box,
780
908
  {
781
- paddingHorizontal: sizeStyles.padding,
782
- paddingVertical: 8,
783
- onPress: () => handleSelect(option),
784
- hoverStyle: {
785
- backgroundColor: theme.colors.control.input.bgHover
909
+ padding: 16,
910
+ alignItems: "center",
911
+ justifyContent: "center",
912
+ minHeight: 60,
913
+ children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_xui_spinner.Spinner, { size: "m", color: theme.colors.control.brand.primary.bg })
914
+ }
915
+ ) : hasOptions ? normalizedOptions.map((option, index) => /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
916
+ Box,
917
+ {
918
+ id: `autocomplete-option-${option.id}`,
919
+ role: "option",
920
+ "aria-selected": activeIndex === index,
921
+ "aria-disabled": option.disabled,
922
+ flexDirection: "row",
923
+ alignItems: option.description ? "flex-start" : "center",
924
+ gap: menuSizeStyles.gap,
925
+ paddingHorizontal: menuSizeStyles.itemPaddingHorizontal,
926
+ paddingVertical: menuSizeStyles.itemPaddingVertical,
927
+ backgroundColor: getItemBackgroundColor(index, option.disabled),
928
+ hoverStyle: !option.disabled ? { backgroundColor: theme.colors.control.input.bgHover } : void 0,
929
+ pressStyle: !option.disabled ? { backgroundColor: theme.colors.control.input.bgDisable } : void 0,
930
+ onPress: () => !option.disabled && handleSelect(option),
931
+ style: {
932
+ cursor: option.disabled ? "not-allowed" : "pointer",
933
+ opacity: option.disabled ? 0.5 : 1
786
934
  },
787
- children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
788
- Text,
789
- {
790
- color: theme.colors.content.primary,
791
- fontSize: sizeStyles.fontSize,
792
- children: option
793
- }
794
- )
935
+ children: [
936
+ option.icon && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
937
+ Box,
938
+ {
939
+ width: menuSizeStyles.iconSize,
940
+ marginTop: option.description ? 2 : 0,
941
+ alignItems: "center",
942
+ justifyContent: "center",
943
+ children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
944
+ Icon,
945
+ {
946
+ size: menuSizeStyles.iconSize,
947
+ color: option.disabled ? theme.colors.content.tertiary : theme.colors.content.secondary,
948
+ children: option.icon
949
+ }
950
+ )
951
+ }
952
+ ),
953
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(Box, { flex: 1, flexDirection: "column", gap: 2, children: [
954
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
955
+ Text,
956
+ {
957
+ color: option.disabled ? theme.colors.control.input.textDisable : theme.colors.content.primary,
958
+ fontSize: menuSizeStyles.fontSize,
959
+ fontWeight: "400",
960
+ lineHeight: menuSizeStyles.fontSize + 2,
961
+ children: option.label
962
+ }
963
+ ),
964
+ option.description && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
965
+ Text,
966
+ {
967
+ color: theme.colors.content.tertiary,
968
+ fontSize: menuSizeStyles.descriptionFontSize,
969
+ lineHeight: menuSizeStyles.descriptionFontSize + 2,
970
+ children: option.description
971
+ }
972
+ )
973
+ ] })
974
+ ]
795
975
  },
796
- index
797
- ))
976
+ option.id
977
+ )) : /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Box, { padding: 16, alignItems: "center", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
978
+ Text,
979
+ {
980
+ color: theme.colors.content.tertiary,
981
+ fontSize: menuSizeStyles.fontSize,
982
+ children: emptyMessage
983
+ }
984
+ ) })
798
985
  }
799
986
  ),
800
987
  isError && errorLabel && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(