@rufous/ui 0.3.39 → 0.3.41

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/main.cjs CHANGED
@@ -1422,14 +1422,14 @@ var SubmitButton = ({
1422
1422
  setLoading(false);
1423
1423
  }
1424
1424
  };
1425
- const handleClick = (e) => runProtected(e, onClick);
1426
- const handleDoubleClick = (e) => runProtected(e, onDoubleClick);
1425
+ const handleClick = onClick ? (e) => runProtected(e, onClick) : void 0;
1426
+ const handleDoubleClick = onDoubleClick ? (e) => runProtected(e, onDoubleClick) : void 0;
1427
1427
  const showLoader = loading || isLoading;
1428
1428
  return /* @__PURE__ */ React60.createElement(
1429
1429
  "button",
1430
1430
  {
1431
1431
  ...props,
1432
- type: onClick ? void 0 : type,
1432
+ type,
1433
1433
  className: ["btn submit-btn", bgGradiant ? "submit-btn-gradiant" : "", sxClass, className].filter(Boolean).join(" "),
1434
1434
  disabled: props.disabled || showLoader,
1435
1435
  onClick: handleClick,
@@ -10665,10 +10665,20 @@ function buildLookup(options, getChildren, getValue, map = /* @__PURE__ */ new M
10665
10665
  }
10666
10666
  return map;
10667
10667
  }
10668
+ function safeLabel(getOptionLabel, option) {
10669
+ try {
10670
+ const l = getOptionLabel(option);
10671
+ return typeof l === "string" ? l : "";
10672
+ } catch {
10673
+ return "";
10674
+ }
10675
+ }
10668
10676
  function SmartSelect({
10669
10677
  options,
10670
10678
  value,
10671
10679
  onChange,
10680
+ inputValue: inputValueProp,
10681
+ onInputChange,
10672
10682
  onSearchChange,
10673
10683
  searchResults = [],
10674
10684
  debounceMs = 300,
@@ -10679,6 +10689,7 @@ function SmartSelect({
10679
10689
  getOptionChildren,
10680
10690
  multiple = false,
10681
10691
  allowChildNodesSelection = true,
10692
+ strictSelection = false,
10682
10693
  loading = false,
10683
10694
  loadingText,
10684
10695
  filterOptions: filterOptionsProp,
@@ -10698,17 +10709,10 @@ function SmartSelect({
10698
10709
  sx
10699
10710
  }) {
10700
10711
  const debounceTimer = (0, import_react54.useRef)(null);
10712
+ const isControlled = inputValueProp !== void 0;
10701
10713
  (0, import_react54.useEffect)(() => () => {
10702
10714
  if (debounceTimer.current) clearTimeout(debounceTimer.current);
10703
10715
  }, []);
10704
- const [inputValue, setInputValue] = (0, import_react54.useState)(
10705
- () => !multiple && value != null ? getOptionLabel(value) : ""
10706
- );
10707
- (0, import_react54.useEffect)(() => {
10708
- if (!multiple) {
10709
- setInputValue(value != null ? getOptionLabel(value) : "");
10710
- }
10711
- }, [value, multiple, getOptionLabel]);
10712
10716
  const getValue = (0, import_react54.useCallback)(
10713
10717
  (o) => getOptionValue ? getOptionValue(o) : String(getOptionLabel(o)),
10714
10718
  [getOptionValue, getOptionLabel]
@@ -10718,6 +10722,55 @@ function SmartSelect({
10718
10722
  return flattenTree(options, getOptionChildren);
10719
10723
  }, [options, getOptionChildren]);
10720
10724
  const flatOptionsList = (0, import_react54.useMemo)(() => flatItems.map((f) => f.option), [flatItems]);
10725
+ const strictValidation = (0, import_react54.useMemo)(() => {
10726
+ if (!strictSelection || multiple) return { valid: true, canonicalValue: value };
10727
+ if (value == null || value === "") {
10728
+ return { valid: false, displayText: "" };
10729
+ }
10730
+ const invalidDisplayText = (v) => {
10731
+ const fromLabel = safeLabel(getOptionLabel, v).trim();
10732
+ if (fromLabel) return fromLabel;
10733
+ if (typeof v === "string" || typeof v === "number") return String(v);
10734
+ return "";
10735
+ };
10736
+ try {
10737
+ const key = getValue(value);
10738
+ const canonicalOpt = flatOptionsList.find((o) => getValue(o) === key) ?? searchResults.find((o) => getValue(o) === key);
10739
+ if (!canonicalOpt) {
10740
+ return { valid: false, displayText: invalidDisplayText(value) };
10741
+ }
10742
+ if (typeof canonicalOpt === "object" && canonicalOpt !== null && typeof value === "object" && value !== null) {
10743
+ const src = canonicalOpt;
10744
+ const inc = value;
10745
+ const fullMatch = Object.keys(src).every((k) => k in inc && inc[k] === src[k]);
10746
+ if (!fullMatch) {
10747
+ return { valid: false, displayText: invalidDisplayText(value) };
10748
+ }
10749
+ }
10750
+ return { valid: true, canonicalValue: canonicalOpt };
10751
+ } catch {
10752
+ return { valid: false, displayText: invalidDisplayText(value) };
10753
+ }
10754
+ }, [strictSelection, multiple, value, getValue, flatOptionsList, searchResults, getOptionLabel]);
10755
+ const [internalInput, setInternalInput] = (0, import_react54.useState)(
10756
+ () => !multiple && value != null ? getOptionLabel(value) : ""
10757
+ );
10758
+ const strictValidationRef = (0, import_react54.useRef)(strictValidation);
10759
+ strictValidationRef.current = strictValidation;
10760
+ const lastSeededValue = (0, import_react54.useRef)(/* @__PURE__ */ Symbol("init"));
10761
+ (0, import_react54.useEffect)(() => {
10762
+ if (isControlled || multiple) return;
10763
+ if (strictSelection && !strictValidationRef.current.valid) {
10764
+ if (Object.is(value, lastSeededValue.current)) return;
10765
+ lastSeededValue.current = value;
10766
+ const sv = strictValidationRef.current;
10767
+ setInternalInput(sv.displayText);
10768
+ return;
10769
+ }
10770
+ lastSeededValue.current = value;
10771
+ setInternalInput(value != null ? getOptionLabel(value) : "");
10772
+ }, [value, multiple, getOptionLabel, isControlled, strictSelection]);
10773
+ const activeInput = isControlled ? inputValueProp : internalInput;
10721
10774
  const displayOptions = (0, import_react54.useMemo)(() => {
10722
10775
  let base = flatOptionsList;
10723
10776
  if (searchResults.length) {
@@ -10725,17 +10778,26 @@ function SmartSelect({
10725
10778
  const serverOnly = searchResults.filter((o) => !localKeys.has(getValue(o)));
10726
10779
  base = [...flatOptionsList, ...serverOnly];
10727
10780
  }
10728
- if (!multiple && value != null) {
10729
- const key = getValue(value);
10730
- if (!base.some((o) => getValue(o) === key)) base = [value, ...base];
10731
- }
10732
- if (multiple && Array.isArray(value) && value.length > 0) {
10781
+ if (!multiple) {
10782
+ if (strictSelection) {
10783
+ const injectValue = strictValidation.valid ? strictValidation.canonicalValue : null;
10784
+ if (injectValue != null) {
10785
+ const key = getValue(injectValue);
10786
+ if (!base.some((o) => getValue(o) === key)) base = [injectValue, ...base];
10787
+ }
10788
+ } else {
10789
+ if (value != null) {
10790
+ const key = getValue(value);
10791
+ if (!base.some((o) => getValue(o) === key)) base = [value, ...base];
10792
+ }
10793
+ }
10794
+ } else if (Array.isArray(value) && value.length > 0) {
10733
10795
  const baseKeys = new Set(base.map((o) => getValue(o)));
10734
10796
  const missing = value.filter((v) => !baseKeys.has(getValue(v)));
10735
10797
  if (missing.length > 0) base = [...base, ...missing];
10736
10798
  }
10737
10799
  return base;
10738
- }, [flatOptionsList, searchResults, getValue, value, multiple]);
10800
+ }, [flatOptionsList, searchResults, getValue, value, multiple, strictSelection, strictValidation]);
10739
10801
  const depthMap = (0, import_react54.useMemo)(() => {
10740
10802
  const map = /* @__PURE__ */ new Map();
10741
10803
  flatItems.forEach(({ option, depth }) => map.set(getValue(option), depth));
@@ -10749,18 +10811,40 @@ function SmartSelect({
10749
10811
  if (multiple) {
10750
10812
  return new Set((Array.isArray(value) ? value : []).map((v) => getValue(v)));
10751
10813
  }
10814
+ if (strictSelection) {
10815
+ const sv = strictValidation;
10816
+ if (!strictValidation.valid || sv.canonicalValue == null) return /* @__PURE__ */ new Set();
10817
+ return /* @__PURE__ */ new Set([getValue(sv.canonicalValue)]);
10818
+ }
10752
10819
  return value != null ? /* @__PURE__ */ new Set([getValue(value)]) : /* @__PURE__ */ new Set();
10753
- }, [multiple, value, getValue]);
10754
- const handleInputChange = (0, import_react54.useCallback)((_, inputValue2, reason) => {
10755
- setInputValue(inputValue2);
10820
+ }, [multiple, value, getValue, strictSelection, strictValidation]);
10821
+ let autocompleteValue;
10822
+ let autocompleteInputValue;
10823
+ if (strictSelection && !multiple) {
10824
+ const sv = strictValidation;
10825
+ if (!strictValidation.valid) {
10826
+ autocompleteValue = null;
10827
+ autocompleteInputValue = isControlled ? inputValueProp : internalInput;
10828
+ } else {
10829
+ autocompleteValue = sv.canonicalValue ?? null;
10830
+ autocompleteInputValue = isControlled ? inputValueProp : activeInput;
10831
+ }
10832
+ } else {
10833
+ autocompleteValue = value ?? (multiple ? [] : null);
10834
+ autocompleteInputValue = multiple ? isControlled ? inputValueProp : void 0 : activeInput;
10835
+ }
10836
+ const handleInputChange = (0, import_react54.useCallback)((_, val, reason) => {
10837
+ const resolvedReason = reason ?? "input";
10838
+ if (!isControlled) setInternalInput(val);
10839
+ onInputChange?.(val, resolvedReason);
10756
10840
  if (!onSearchChange) return;
10757
10841
  if (debounceTimer.current) clearTimeout(debounceTimer.current);
10758
- if (reason !== "input") {
10759
- if (reason === "clear") onSearchChange("", 0);
10842
+ if (resolvedReason !== "input") {
10843
+ if (resolvedReason === "clear") onSearchChange("", 0);
10760
10844
  return;
10761
10845
  }
10762
- if (!inputValue2) return;
10763
- const q = inputValue2.toLowerCase();
10846
+ if (!val) return;
10847
+ const q = val.toLowerCase();
10764
10848
  let localCount = 0;
10765
10849
  for (const opt of flatOptionsList) {
10766
10850
  if (getOptionLabel(opt).toLowerCase().includes(q) || (getOptionSubLabel?.(opt) ?? "").toLowerCase().includes(q)) {
@@ -10771,13 +10855,11 @@ function SmartSelect({
10771
10855
  if (localCount >= searchThreshold) return;
10772
10856
  const needed = searchThreshold - localCount;
10773
10857
  if (debounceMs <= 0) {
10774
- onSearchChange(inputValue2, needed);
10858
+ onSearchChange(val, needed);
10775
10859
  } else {
10776
- debounceTimer.current = setTimeout(() => {
10777
- onSearchChange(inputValue2, needed);
10778
- }, debounceMs);
10860
+ debounceTimer.current = setTimeout(() => onSearchChange(val, needed), debounceMs);
10779
10861
  }
10780
- }, [onSearchChange, debounceMs, searchThreshold, flatOptionsList, getOptionLabel, getOptionSubLabel]);
10862
+ }, [isControlled, onInputChange, onSearchChange, debounceMs, searchThreshold, flatOptionsList, getOptionLabel, getOptionSubLabel]);
10781
10863
  const handleChange = (0, import_react54.useCallback)((_, newValue) => {
10782
10864
  if (!multiple || !allowChildNodesSelection || !getOptionChildren) {
10783
10865
  onChange?.(newValue);
@@ -10853,24 +10935,33 @@ function SmartSelect({
10853
10935
  /* @__PURE__ */ import_react54.default.createElement("span", { className: "rf-select__option-check", "aria-hidden": "true" }, /* @__PURE__ */ import_react54.default.createElement(CheckIcon3, null))
10854
10936
  );
10855
10937
  }, [depthMap, getValue, getOptionLabel, getOptionSubLabel, selectedKeys]);
10856
- const computedFilterOptions = (0, import_react54.useCallback)((opts, inputValue2) => {
10857
- if (filterOptionsProp) return filterOptionsProp(opts, inputValue2);
10938
+ const computedFilterOptions = (0, import_react54.useCallback)((opts, inputVal) => {
10939
+ if (filterOptionsProp) return filterOptionsProp(opts, inputVal);
10940
+ if (strictSelection && !strictValidation.valid) {
10941
+ const sv = strictValidation;
10942
+ if (!inputVal || inputVal === sv.displayText) return opts;
10943
+ const q2 = inputVal.toLowerCase();
10944
+ return opts.filter(
10945
+ (opt) => getOptionLabel(opt).toLowerCase().includes(q2) || (getOptionSubLabel?.(opt) ?? "").toLowerCase().includes(q2)
10946
+ ).slice(0, searchThreshold);
10947
+ }
10858
10948
  if (multiple) {
10859
10949
  const selected = opts.filter((o) => selectedKeys.has(getValue(o)));
10860
10950
  const unselected = opts.filter((o) => !selectedKeys.has(getValue(o)));
10861
- if (!inputValue2) return [...selected, ...unselected];
10862
- const q2 = inputValue2.toLowerCase();
10951
+ if (!inputVal) return [...selected, ...unselected];
10952
+ const q2 = inputVal.toLowerCase();
10863
10953
  const filteredUnselected = unselected.filter(
10864
10954
  (opt) => getOptionLabel(opt).toLowerCase().includes(q2) || (getOptionSubLabel?.(opt) ?? "").toLowerCase().includes(q2)
10865
10955
  ).slice(0, searchThreshold);
10866
10956
  return [...selected, ...filteredUnselected];
10867
10957
  }
10868
- if (value != null) {
10869
- const selectedKey = getValue(value);
10870
- const selectedLabel = getOptionLabel(value);
10958
+ const effectiveVal = strictSelection ? strictValidation.canonicalValue : value;
10959
+ if (effectiveVal != null) {
10960
+ const selectedKey = getValue(effectiveVal);
10961
+ const selectedLabel = getOptionLabel(effectiveVal);
10871
10962
  const inOpts = opts.some((o) => getValue(o) === selectedKey);
10872
- const selectedFallback = inOpts ? [] : [value];
10873
- if (!inputValue2 || inputValue2 === selectedLabel) {
10963
+ const selectedFallback = inOpts ? [] : [effectiveVal];
10964
+ if (!inputVal || inputVal === selectedLabel) {
10874
10965
  return [
10875
10966
  ...selectedFallback,
10876
10967
  ...opts.filter((o) => getValue(o) === selectedKey),
@@ -10878,26 +10969,32 @@ function SmartSelect({
10878
10969
  ];
10879
10970
  }
10880
10971
  }
10881
- if (!inputValue2) return opts;
10882
- const q = inputValue2.toLowerCase();
10972
+ if (!inputVal) return opts;
10973
+ const q = inputVal.toLowerCase();
10883
10974
  return opts.filter(
10884
10975
  (opt) => getOptionLabel(opt).toLowerCase().includes(q) || (getOptionSubLabel?.(opt) ?? "").toLowerCase().includes(q)
10885
10976
  ).slice(0, searchThreshold);
10886
- }, [filterOptionsProp, multiple, selectedKeys, getValue, getOptionLabel, getOptionSubLabel, value, searchThreshold]);
10977
+ }, [filterOptionsProp, multiple, selectedKeys, getValue, getOptionLabel, getOptionSubLabel, value, searchThreshold, strictSelection, strictValidation]);
10887
10978
  return /* @__PURE__ */ import_react54.default.createElement(
10888
10979
  Autocomplete,
10889
10980
  {
10890
10981
  options: displayOptions,
10891
- value: value ?? (multiple ? [] : null),
10982
+ value: autocompleteValue,
10892
10983
  onChange: handleChange,
10893
- inputValue: multiple ? void 0 : inputValue,
10984
+ inputValue: autocompleteInputValue,
10894
10985
  onInputChange: handleInputChange,
10895
10986
  multiple,
10896
10987
  limitTags,
10897
10988
  loading,
10898
10989
  loadingText: loadingText ?? /* @__PURE__ */ import_react54.default.createElement("span", { style: { fontSize: "0.875rem", color: "var(--text-secondary)" } }, "Loading\u2026"),
10899
10990
  getOptionLabel,
10900
- isOptionEqualToValue: (opt, val) => getValue(opt) === getValue(val),
10991
+ isOptionEqualToValue: (opt, val) => {
10992
+ try {
10993
+ return getValue(opt) === getValue(val);
10994
+ } catch {
10995
+ return false;
10996
+ }
10997
+ },
10901
10998
  filterOptions: computedFilterOptions,
10902
10999
  renderOption: renderOptionProp ?? defaultRenderOption,
10903
11000
  label,
package/dist/main.d.cts CHANGED
@@ -2240,10 +2240,31 @@ interface SmartSelectProps<T = any> {
2240
2240
  value?: T | T[] | null;
2241
2241
  /** Called when selection changes */
2242
2242
  onChange?: (value: T | T[] | null) => void;
2243
+ /**
2244
+ * Controlled text shown in the search input.
2245
+ * When provided the input is fully controlled — update it via `onInputChange`.
2246
+ * When omitted SmartSelect manages the input text internally.
2247
+ */
2248
+ inputValue?: string;
2249
+ /**
2250
+ * Called whenever the input text changes.
2251
+ * - `reason: 'input'` — user typed
2252
+ * - `reason: 'reset'` — option selected or dropdown closed
2253
+ * - `reason: 'clear'` — field was cleared
2254
+ *
2255
+ * Pair with `inputValue` for full controlled mode:
2256
+ * ```
2257
+ * const [search, setSearch] = useState('');
2258
+ * <SmartSelect
2259
+ * inputValue={search}
2260
+ * onInputChange={(val, reason) => { if (reason !== 'reset') setSearch(val); }}
2261
+ * />
2262
+ * ```
2263
+ */
2264
+ onInputChange?: (value: string, reason: 'input' | 'reset' | 'clear') => void;
2243
2265
  /**
2244
2266
  * Called when local matches fall below `searchThreshold`.
2245
- * Receives the current query and how many more records are needed to fill the threshold.
2246
- * Use this to trigger an API / server search and update `searchResults`.
2267
+ * Receives the current query and how many more records are needed.
2247
2268
  */
2248
2269
  onSearchChange?: (query: string, needed: number) => void;
2249
2270
  /**
@@ -2253,14 +2274,14 @@ interface SmartSelectProps<T = any> {
2253
2274
  */
2254
2275
  searchResults?: T[];
2255
2276
  /**
2256
- * Debounce delay in ms before `onSearchChange` fires.
2257
- * Defaults to 300 ms. Pass 0 to disable debouncing.
2277
+ * Debounce delay in ms before `onSearchChange` fires. Default: 300.
2278
+ * Pass 0 to disable.
2258
2279
  */
2259
2280
  debounceMs?: number;
2260
2281
  /**
2261
2282
  * Max results to show when a query is active.
2262
- * If local matches are fewer than this, `onSearchChange` fires with how many more are needed.
2263
- * Defaults to 10. Pass 0 to always call the API.
2283
+ * `onSearchChange` fires only when local matches < this threshold.
2284
+ * Default: 10. Pass 0 to always call the API.
2264
2285
  */
2265
2286
  searchThreshold?: number;
2266
2287
  /** Primary display label for an option (required) */
@@ -2282,6 +2303,22 @@ interface SmartSelectProps<T = any> {
2282
2303
  * Only relevant when getOptionChildren is provided and multiple is true.
2283
2304
  */
2284
2305
  allowChildNodesSelection?: boolean;
2306
+ /**
2307
+ * When **true**, the incoming `value` is validated against the source options
2308
+ * before being used. Three cases are handled strictly:
2309
+ *
2310
+ * - **Case 1 — Empty**: `value` is `null`, `undefined`, or `""` → treated as no selection.
2311
+ * - **Case 2 — Missing**: value's key is not present in `options` or `searchResults`
2312
+ * → ignored; not injected into the dropdown list.
2313
+ * - **Case 3 — Incomplete**: value's key exists in the source but the canonical source
2314
+ * option returns an empty label from `getOptionLabel` → not treated as selected.
2315
+ *
2316
+ * When invalid, `null` is passed to Autocomplete (nothing selected) and the raw text
2317
+ * is shown in the input field. No `onChange` fires.
2318
+ *
2319
+ * Only applies to single-select mode. Default: **false**.
2320
+ */
2321
+ strictSelection?: boolean;
2285
2322
  /** Show a loading spinner in the dropdown */
2286
2323
  loading?: boolean;
2287
2324
  /** Content shown while loading */
@@ -2316,7 +2353,7 @@ interface SmartSelectProps<T = any> {
2316
2353
  style?: CSSProperties;
2317
2354
  sx?: SxProp;
2318
2355
  }
2319
- declare function SmartSelect<T = any>({ options, value, onChange, onSearchChange, searchResults, debounceMs, searchThreshold, getOptionLabel, getOptionValue, getOptionSubLabel, getOptionChildren, multiple, allowChildNodesSelection, loading, loadingText, filterOptions: filterOptionsProp, renderOption: renderOptionProp, limitTags, label, placeholder, variant, size, disabled, error, helperText, fullWidth, required, className, style, sx, }: SmartSelectProps<T>): React__default.JSX.Element;
2356
+ declare function SmartSelect<T = any>({ options, value, onChange, inputValue: inputValueProp, onInputChange, onSearchChange, searchResults, debounceMs, searchThreshold, getOptionLabel, getOptionValue, getOptionSubLabel, getOptionChildren, multiple, allowChildNodesSelection, strictSelection, loading, loadingText, filterOptions: filterOptionsProp, renderOption: renderOptionProp, limitTags, label, placeholder, variant, size, disabled, error, helperText, fullWidth, required, className, style, sx, }: SmartSelectProps<T>): React__default.JSX.Element;
2320
2357
 
2321
2358
  type ToolbarButton = 'undo' | 'redo' | 'ai' | 'paragraph' | 'fontsize' | 'font' | 'color' | 'bold' | 'italic' | 'strike' | 'link' | 'lineheight' | 'ul' | 'ol' | 'align' | 'indent' | 'outdent' | 'table' | 'image' | 'video' | 'cut' | 'copy' | 'paste' | 'specialchars' | 'code' | 'fullscreen' | 'tts' | 'stt' | 'translate' | 'todo' | '|';
2322
2359
  type EditorVariant = 'default' | 'basic';
package/dist/main.d.ts CHANGED
@@ -2240,10 +2240,31 @@ interface SmartSelectProps<T = any> {
2240
2240
  value?: T | T[] | null;
2241
2241
  /** Called when selection changes */
2242
2242
  onChange?: (value: T | T[] | null) => void;
2243
+ /**
2244
+ * Controlled text shown in the search input.
2245
+ * When provided the input is fully controlled — update it via `onInputChange`.
2246
+ * When omitted SmartSelect manages the input text internally.
2247
+ */
2248
+ inputValue?: string;
2249
+ /**
2250
+ * Called whenever the input text changes.
2251
+ * - `reason: 'input'` — user typed
2252
+ * - `reason: 'reset'` — option selected or dropdown closed
2253
+ * - `reason: 'clear'` — field was cleared
2254
+ *
2255
+ * Pair with `inputValue` for full controlled mode:
2256
+ * ```
2257
+ * const [search, setSearch] = useState('');
2258
+ * <SmartSelect
2259
+ * inputValue={search}
2260
+ * onInputChange={(val, reason) => { if (reason !== 'reset') setSearch(val); }}
2261
+ * />
2262
+ * ```
2263
+ */
2264
+ onInputChange?: (value: string, reason: 'input' | 'reset' | 'clear') => void;
2243
2265
  /**
2244
2266
  * Called when local matches fall below `searchThreshold`.
2245
- * Receives the current query and how many more records are needed to fill the threshold.
2246
- * Use this to trigger an API / server search and update `searchResults`.
2267
+ * Receives the current query and how many more records are needed.
2247
2268
  */
2248
2269
  onSearchChange?: (query: string, needed: number) => void;
2249
2270
  /**
@@ -2253,14 +2274,14 @@ interface SmartSelectProps<T = any> {
2253
2274
  */
2254
2275
  searchResults?: T[];
2255
2276
  /**
2256
- * Debounce delay in ms before `onSearchChange` fires.
2257
- * Defaults to 300 ms. Pass 0 to disable debouncing.
2277
+ * Debounce delay in ms before `onSearchChange` fires. Default: 300.
2278
+ * Pass 0 to disable.
2258
2279
  */
2259
2280
  debounceMs?: number;
2260
2281
  /**
2261
2282
  * Max results to show when a query is active.
2262
- * If local matches are fewer than this, `onSearchChange` fires with how many more are needed.
2263
- * Defaults to 10. Pass 0 to always call the API.
2283
+ * `onSearchChange` fires only when local matches < this threshold.
2284
+ * Default: 10. Pass 0 to always call the API.
2264
2285
  */
2265
2286
  searchThreshold?: number;
2266
2287
  /** Primary display label for an option (required) */
@@ -2282,6 +2303,22 @@ interface SmartSelectProps<T = any> {
2282
2303
  * Only relevant when getOptionChildren is provided and multiple is true.
2283
2304
  */
2284
2305
  allowChildNodesSelection?: boolean;
2306
+ /**
2307
+ * When **true**, the incoming `value` is validated against the source options
2308
+ * before being used. Three cases are handled strictly:
2309
+ *
2310
+ * - **Case 1 — Empty**: `value` is `null`, `undefined`, or `""` → treated as no selection.
2311
+ * - **Case 2 — Missing**: value's key is not present in `options` or `searchResults`
2312
+ * → ignored; not injected into the dropdown list.
2313
+ * - **Case 3 — Incomplete**: value's key exists in the source but the canonical source
2314
+ * option returns an empty label from `getOptionLabel` → not treated as selected.
2315
+ *
2316
+ * When invalid, `null` is passed to Autocomplete (nothing selected) and the raw text
2317
+ * is shown in the input field. No `onChange` fires.
2318
+ *
2319
+ * Only applies to single-select mode. Default: **false**.
2320
+ */
2321
+ strictSelection?: boolean;
2285
2322
  /** Show a loading spinner in the dropdown */
2286
2323
  loading?: boolean;
2287
2324
  /** Content shown while loading */
@@ -2316,7 +2353,7 @@ interface SmartSelectProps<T = any> {
2316
2353
  style?: CSSProperties;
2317
2354
  sx?: SxProp;
2318
2355
  }
2319
- declare function SmartSelect<T = any>({ options, value, onChange, onSearchChange, searchResults, debounceMs, searchThreshold, getOptionLabel, getOptionValue, getOptionSubLabel, getOptionChildren, multiple, allowChildNodesSelection, loading, loadingText, filterOptions: filterOptionsProp, renderOption: renderOptionProp, limitTags, label, placeholder, variant, size, disabled, error, helperText, fullWidth, required, className, style, sx, }: SmartSelectProps<T>): React__default.JSX.Element;
2356
+ declare function SmartSelect<T = any>({ options, value, onChange, inputValue: inputValueProp, onInputChange, onSearchChange, searchResults, debounceMs, searchThreshold, getOptionLabel, getOptionValue, getOptionSubLabel, getOptionChildren, multiple, allowChildNodesSelection, strictSelection, loading, loadingText, filterOptions: filterOptionsProp, renderOption: renderOptionProp, limitTags, label, placeholder, variant, size, disabled, error, helperText, fullWidth, required, className, style, sx, }: SmartSelectProps<T>): React__default.JSX.Element;
2320
2357
 
2321
2358
  type ToolbarButton = 'undo' | 'redo' | 'ai' | 'paragraph' | 'fontsize' | 'font' | 'color' | 'bold' | 'italic' | 'strike' | 'link' | 'lineheight' | 'ul' | 'ol' | 'align' | 'indent' | 'outdent' | 'table' | 'image' | 'video' | 'cut' | 'copy' | 'paste' | 'specialchars' | 'code' | 'fullscreen' | 'tts' | 'stt' | 'translate' | 'todo' | '|';
2322
2359
  type EditorVariant = 'default' | 'basic';
package/dist/main.js CHANGED
@@ -1209,14 +1209,14 @@ var SubmitButton = ({
1209
1209
  setLoading(false);
1210
1210
  }
1211
1211
  };
1212
- const handleClick = (e) => runProtected(e, onClick);
1213
- const handleDoubleClick = (e) => runProtected(e, onDoubleClick);
1212
+ const handleClick = onClick ? (e) => runProtected(e, onClick) : void 0;
1213
+ const handleDoubleClick = onDoubleClick ? (e) => runProtected(e, onDoubleClick) : void 0;
1214
1214
  const showLoader = loading || isLoading;
1215
1215
  return /* @__PURE__ */ React60.createElement(
1216
1216
  "button",
1217
1217
  {
1218
1218
  ...props,
1219
- type: onClick ? void 0 : type,
1219
+ type,
1220
1220
  className: ["btn submit-btn", bgGradiant ? "submit-btn-gradiant" : "", sxClass, className].filter(Boolean).join(" "),
1221
1221
  disabled: props.disabled || showLoader,
1222
1222
  onClick: handleClick,
@@ -10548,10 +10548,20 @@ function buildLookup(options, getChildren, getValue, map = /* @__PURE__ */ new M
10548
10548
  }
10549
10549
  return map;
10550
10550
  }
10551
+ function safeLabel(getOptionLabel, option) {
10552
+ try {
10553
+ const l = getOptionLabel(option);
10554
+ return typeof l === "string" ? l : "";
10555
+ } catch {
10556
+ return "";
10557
+ }
10558
+ }
10551
10559
  function SmartSelect({
10552
10560
  options,
10553
10561
  value,
10554
10562
  onChange,
10563
+ inputValue: inputValueProp,
10564
+ onInputChange,
10555
10565
  onSearchChange,
10556
10566
  searchResults = [],
10557
10567
  debounceMs = 300,
@@ -10562,6 +10572,7 @@ function SmartSelect({
10562
10572
  getOptionChildren,
10563
10573
  multiple = false,
10564
10574
  allowChildNodesSelection = true,
10575
+ strictSelection = false,
10565
10576
  loading = false,
10566
10577
  loadingText,
10567
10578
  filterOptions: filterOptionsProp,
@@ -10581,17 +10592,10 @@ function SmartSelect({
10581
10592
  sx
10582
10593
  }) {
10583
10594
  const debounceTimer = useRef25(null);
10595
+ const isControlled = inputValueProp !== void 0;
10584
10596
  useEffect21(() => () => {
10585
10597
  if (debounceTimer.current) clearTimeout(debounceTimer.current);
10586
10598
  }, []);
10587
- const [inputValue, setInputValue] = useState27(
10588
- () => !multiple && value != null ? getOptionLabel(value) : ""
10589
- );
10590
- useEffect21(() => {
10591
- if (!multiple) {
10592
- setInputValue(value != null ? getOptionLabel(value) : "");
10593
- }
10594
- }, [value, multiple, getOptionLabel]);
10595
10599
  const getValue = useCallback12(
10596
10600
  (o) => getOptionValue ? getOptionValue(o) : String(getOptionLabel(o)),
10597
10601
  [getOptionValue, getOptionLabel]
@@ -10601,6 +10605,55 @@ function SmartSelect({
10601
10605
  return flattenTree(options, getOptionChildren);
10602
10606
  }, [options, getOptionChildren]);
10603
10607
  const flatOptionsList = useMemo3(() => flatItems.map((f) => f.option), [flatItems]);
10608
+ const strictValidation = useMemo3(() => {
10609
+ if (!strictSelection || multiple) return { valid: true, canonicalValue: value };
10610
+ if (value == null || value === "") {
10611
+ return { valid: false, displayText: "" };
10612
+ }
10613
+ const invalidDisplayText = (v) => {
10614
+ const fromLabel = safeLabel(getOptionLabel, v).trim();
10615
+ if (fromLabel) return fromLabel;
10616
+ if (typeof v === "string" || typeof v === "number") return String(v);
10617
+ return "";
10618
+ };
10619
+ try {
10620
+ const key = getValue(value);
10621
+ const canonicalOpt = flatOptionsList.find((o) => getValue(o) === key) ?? searchResults.find((o) => getValue(o) === key);
10622
+ if (!canonicalOpt) {
10623
+ return { valid: false, displayText: invalidDisplayText(value) };
10624
+ }
10625
+ if (typeof canonicalOpt === "object" && canonicalOpt !== null && typeof value === "object" && value !== null) {
10626
+ const src = canonicalOpt;
10627
+ const inc = value;
10628
+ const fullMatch = Object.keys(src).every((k) => k in inc && inc[k] === src[k]);
10629
+ if (!fullMatch) {
10630
+ return { valid: false, displayText: invalidDisplayText(value) };
10631
+ }
10632
+ }
10633
+ return { valid: true, canonicalValue: canonicalOpt };
10634
+ } catch {
10635
+ return { valid: false, displayText: invalidDisplayText(value) };
10636
+ }
10637
+ }, [strictSelection, multiple, value, getValue, flatOptionsList, searchResults, getOptionLabel]);
10638
+ const [internalInput, setInternalInput] = useState27(
10639
+ () => !multiple && value != null ? getOptionLabel(value) : ""
10640
+ );
10641
+ const strictValidationRef = useRef25(strictValidation);
10642
+ strictValidationRef.current = strictValidation;
10643
+ const lastSeededValue = useRef25(/* @__PURE__ */ Symbol("init"));
10644
+ useEffect21(() => {
10645
+ if (isControlled || multiple) return;
10646
+ if (strictSelection && !strictValidationRef.current.valid) {
10647
+ if (Object.is(value, lastSeededValue.current)) return;
10648
+ lastSeededValue.current = value;
10649
+ const sv = strictValidationRef.current;
10650
+ setInternalInput(sv.displayText);
10651
+ return;
10652
+ }
10653
+ lastSeededValue.current = value;
10654
+ setInternalInput(value != null ? getOptionLabel(value) : "");
10655
+ }, [value, multiple, getOptionLabel, isControlled, strictSelection]);
10656
+ const activeInput = isControlled ? inputValueProp : internalInput;
10604
10657
  const displayOptions = useMemo3(() => {
10605
10658
  let base = flatOptionsList;
10606
10659
  if (searchResults.length) {
@@ -10608,17 +10661,26 @@ function SmartSelect({
10608
10661
  const serverOnly = searchResults.filter((o) => !localKeys.has(getValue(o)));
10609
10662
  base = [...flatOptionsList, ...serverOnly];
10610
10663
  }
10611
- if (!multiple && value != null) {
10612
- const key = getValue(value);
10613
- if (!base.some((o) => getValue(o) === key)) base = [value, ...base];
10614
- }
10615
- if (multiple && Array.isArray(value) && value.length > 0) {
10664
+ if (!multiple) {
10665
+ if (strictSelection) {
10666
+ const injectValue = strictValidation.valid ? strictValidation.canonicalValue : null;
10667
+ if (injectValue != null) {
10668
+ const key = getValue(injectValue);
10669
+ if (!base.some((o) => getValue(o) === key)) base = [injectValue, ...base];
10670
+ }
10671
+ } else {
10672
+ if (value != null) {
10673
+ const key = getValue(value);
10674
+ if (!base.some((o) => getValue(o) === key)) base = [value, ...base];
10675
+ }
10676
+ }
10677
+ } else if (Array.isArray(value) && value.length > 0) {
10616
10678
  const baseKeys = new Set(base.map((o) => getValue(o)));
10617
10679
  const missing = value.filter((v) => !baseKeys.has(getValue(v)));
10618
10680
  if (missing.length > 0) base = [...base, ...missing];
10619
10681
  }
10620
10682
  return base;
10621
- }, [flatOptionsList, searchResults, getValue, value, multiple]);
10683
+ }, [flatOptionsList, searchResults, getValue, value, multiple, strictSelection, strictValidation]);
10622
10684
  const depthMap = useMemo3(() => {
10623
10685
  const map = /* @__PURE__ */ new Map();
10624
10686
  flatItems.forEach(({ option, depth }) => map.set(getValue(option), depth));
@@ -10632,18 +10694,40 @@ function SmartSelect({
10632
10694
  if (multiple) {
10633
10695
  return new Set((Array.isArray(value) ? value : []).map((v) => getValue(v)));
10634
10696
  }
10697
+ if (strictSelection) {
10698
+ const sv = strictValidation;
10699
+ if (!strictValidation.valid || sv.canonicalValue == null) return /* @__PURE__ */ new Set();
10700
+ return /* @__PURE__ */ new Set([getValue(sv.canonicalValue)]);
10701
+ }
10635
10702
  return value != null ? /* @__PURE__ */ new Set([getValue(value)]) : /* @__PURE__ */ new Set();
10636
- }, [multiple, value, getValue]);
10637
- const handleInputChange = useCallback12((_, inputValue2, reason) => {
10638
- setInputValue(inputValue2);
10703
+ }, [multiple, value, getValue, strictSelection, strictValidation]);
10704
+ let autocompleteValue;
10705
+ let autocompleteInputValue;
10706
+ if (strictSelection && !multiple) {
10707
+ const sv = strictValidation;
10708
+ if (!strictValidation.valid) {
10709
+ autocompleteValue = null;
10710
+ autocompleteInputValue = isControlled ? inputValueProp : internalInput;
10711
+ } else {
10712
+ autocompleteValue = sv.canonicalValue ?? null;
10713
+ autocompleteInputValue = isControlled ? inputValueProp : activeInput;
10714
+ }
10715
+ } else {
10716
+ autocompleteValue = value ?? (multiple ? [] : null);
10717
+ autocompleteInputValue = multiple ? isControlled ? inputValueProp : void 0 : activeInput;
10718
+ }
10719
+ const handleInputChange = useCallback12((_, val, reason) => {
10720
+ const resolvedReason = reason ?? "input";
10721
+ if (!isControlled) setInternalInput(val);
10722
+ onInputChange?.(val, resolvedReason);
10639
10723
  if (!onSearchChange) return;
10640
10724
  if (debounceTimer.current) clearTimeout(debounceTimer.current);
10641
- if (reason !== "input") {
10642
- if (reason === "clear") onSearchChange("", 0);
10725
+ if (resolvedReason !== "input") {
10726
+ if (resolvedReason === "clear") onSearchChange("", 0);
10643
10727
  return;
10644
10728
  }
10645
- if (!inputValue2) return;
10646
- const q = inputValue2.toLowerCase();
10729
+ if (!val) return;
10730
+ const q = val.toLowerCase();
10647
10731
  let localCount = 0;
10648
10732
  for (const opt of flatOptionsList) {
10649
10733
  if (getOptionLabel(opt).toLowerCase().includes(q) || (getOptionSubLabel?.(opt) ?? "").toLowerCase().includes(q)) {
@@ -10654,13 +10738,11 @@ function SmartSelect({
10654
10738
  if (localCount >= searchThreshold) return;
10655
10739
  const needed = searchThreshold - localCount;
10656
10740
  if (debounceMs <= 0) {
10657
- onSearchChange(inputValue2, needed);
10741
+ onSearchChange(val, needed);
10658
10742
  } else {
10659
- debounceTimer.current = setTimeout(() => {
10660
- onSearchChange(inputValue2, needed);
10661
- }, debounceMs);
10743
+ debounceTimer.current = setTimeout(() => onSearchChange(val, needed), debounceMs);
10662
10744
  }
10663
- }, [onSearchChange, debounceMs, searchThreshold, flatOptionsList, getOptionLabel, getOptionSubLabel]);
10745
+ }, [isControlled, onInputChange, onSearchChange, debounceMs, searchThreshold, flatOptionsList, getOptionLabel, getOptionSubLabel]);
10664
10746
  const handleChange = useCallback12((_, newValue) => {
10665
10747
  if (!multiple || !allowChildNodesSelection || !getOptionChildren) {
10666
10748
  onChange?.(newValue);
@@ -10736,24 +10818,33 @@ function SmartSelect({
10736
10818
  /* @__PURE__ */ React109.createElement("span", { className: "rf-select__option-check", "aria-hidden": "true" }, /* @__PURE__ */ React109.createElement(CheckIcon3, null))
10737
10819
  );
10738
10820
  }, [depthMap, getValue, getOptionLabel, getOptionSubLabel, selectedKeys]);
10739
- const computedFilterOptions = useCallback12((opts, inputValue2) => {
10740
- if (filterOptionsProp) return filterOptionsProp(opts, inputValue2);
10821
+ const computedFilterOptions = useCallback12((opts, inputVal) => {
10822
+ if (filterOptionsProp) return filterOptionsProp(opts, inputVal);
10823
+ if (strictSelection && !strictValidation.valid) {
10824
+ const sv = strictValidation;
10825
+ if (!inputVal || inputVal === sv.displayText) return opts;
10826
+ const q2 = inputVal.toLowerCase();
10827
+ return opts.filter(
10828
+ (opt) => getOptionLabel(opt).toLowerCase().includes(q2) || (getOptionSubLabel?.(opt) ?? "").toLowerCase().includes(q2)
10829
+ ).slice(0, searchThreshold);
10830
+ }
10741
10831
  if (multiple) {
10742
10832
  const selected = opts.filter((o) => selectedKeys.has(getValue(o)));
10743
10833
  const unselected = opts.filter((o) => !selectedKeys.has(getValue(o)));
10744
- if (!inputValue2) return [...selected, ...unselected];
10745
- const q2 = inputValue2.toLowerCase();
10834
+ if (!inputVal) return [...selected, ...unselected];
10835
+ const q2 = inputVal.toLowerCase();
10746
10836
  const filteredUnselected = unselected.filter(
10747
10837
  (opt) => getOptionLabel(opt).toLowerCase().includes(q2) || (getOptionSubLabel?.(opt) ?? "").toLowerCase().includes(q2)
10748
10838
  ).slice(0, searchThreshold);
10749
10839
  return [...selected, ...filteredUnselected];
10750
10840
  }
10751
- if (value != null) {
10752
- const selectedKey = getValue(value);
10753
- const selectedLabel = getOptionLabel(value);
10841
+ const effectiveVal = strictSelection ? strictValidation.canonicalValue : value;
10842
+ if (effectiveVal != null) {
10843
+ const selectedKey = getValue(effectiveVal);
10844
+ const selectedLabel = getOptionLabel(effectiveVal);
10754
10845
  const inOpts = opts.some((o) => getValue(o) === selectedKey);
10755
- const selectedFallback = inOpts ? [] : [value];
10756
- if (!inputValue2 || inputValue2 === selectedLabel) {
10846
+ const selectedFallback = inOpts ? [] : [effectiveVal];
10847
+ if (!inputVal || inputVal === selectedLabel) {
10757
10848
  return [
10758
10849
  ...selectedFallback,
10759
10850
  ...opts.filter((o) => getValue(o) === selectedKey),
@@ -10761,26 +10852,32 @@ function SmartSelect({
10761
10852
  ];
10762
10853
  }
10763
10854
  }
10764
- if (!inputValue2) return opts;
10765
- const q = inputValue2.toLowerCase();
10855
+ if (!inputVal) return opts;
10856
+ const q = inputVal.toLowerCase();
10766
10857
  return opts.filter(
10767
10858
  (opt) => getOptionLabel(opt).toLowerCase().includes(q) || (getOptionSubLabel?.(opt) ?? "").toLowerCase().includes(q)
10768
10859
  ).slice(0, searchThreshold);
10769
- }, [filterOptionsProp, multiple, selectedKeys, getValue, getOptionLabel, getOptionSubLabel, value, searchThreshold]);
10860
+ }, [filterOptionsProp, multiple, selectedKeys, getValue, getOptionLabel, getOptionSubLabel, value, searchThreshold, strictSelection, strictValidation]);
10770
10861
  return /* @__PURE__ */ React109.createElement(
10771
10862
  Autocomplete,
10772
10863
  {
10773
10864
  options: displayOptions,
10774
- value: value ?? (multiple ? [] : null),
10865
+ value: autocompleteValue,
10775
10866
  onChange: handleChange,
10776
- inputValue: multiple ? void 0 : inputValue,
10867
+ inputValue: autocompleteInputValue,
10777
10868
  onInputChange: handleInputChange,
10778
10869
  multiple,
10779
10870
  limitTags,
10780
10871
  loading,
10781
10872
  loadingText: loadingText ?? /* @__PURE__ */ React109.createElement("span", { style: { fontSize: "0.875rem", color: "var(--text-secondary)" } }, "Loading\u2026"),
10782
10873
  getOptionLabel,
10783
- isOptionEqualToValue: (opt, val) => getValue(opt) === getValue(val),
10874
+ isOptionEqualToValue: (opt, val) => {
10875
+ try {
10876
+ return getValue(opt) === getValue(val);
10877
+ } catch {
10878
+ return false;
10879
+ }
10880
+ },
10784
10881
  filterOptions: computedFilterOptions,
10785
10882
  renderOption: renderOptionProp ?? defaultRenderOption,
10786
10883
  label,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rufous/ui",
3
3
  "private": false,
4
- "version": "0.3.39",
4
+ "version": "0.3.41",
5
5
  "type": "module",
6
6
  "description": "Experimental: A lightweight React UI component library (Beta)",
7
7
  "style": "./dist/main.css",
@@ -68,6 +68,8 @@
68
68
  "tippy.js": "^6.3.7"
69
69
  },
70
70
  "devDependencies": {
71
+ "react": "^18.3.1",
72
+ "react-dom": "^18.3.1",
71
73
  "react-router-dom": "^7.13.1",
72
74
  "@eslint/js": "^9.30.1",
73
75
  "@types/node": "^24.1.0",