@skalfa/skalfa-component 1.0.24 → 1.0.25

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.
@@ -13,7 +13,7 @@ export interface SelectProps {
13
13
  tip?: string | ReactNode;
14
14
  leftIcon?: any;
15
15
  rightIcon?: any;
16
- value?: string | number | (string | number)[];
16
+ value?: string | number | (string | number)[] | Record<string, any>;
17
17
  invalid?: string;
18
18
  disabled?: boolean;
19
19
  validations?: ValidationRules;
@@ -36,7 +36,9 @@ export interface SelectProps {
36
36
  tempOptions?: SelectOptionProps[];
37
37
  newOption?: SelectOptionProps;
38
38
  maxShowOption?: number;
39
- onChange?: (value: string | number | (string | number)[], data?: any) => any;
39
+ creatable?: boolean | string;
40
+ creatableLabel?: string;
41
+ onChange?: (value: any, data?: any) => any;
40
42
  register?: (name: string, validations?: ValidationRules) => void;
41
43
  unregister?: (name: string) => void;
42
44
  onFocus?: () => void;
@@ -44,4 +46,4 @@ export interface SelectProps {
44
46
  /** Use custom class with: "label::", "tip::", "error::", "icon::", "suggest::", "suggest-item::". */
45
47
  className?: string;
46
48
  }
47
- export declare function SelectComponent({ name, label, placeholder, tip, leftIcon, rightIcon, value, invalid, disabled, validations, multiple, autoFocus, clearable, options, searchable, serverOptionControl, idbOptionControl, serverSearchable, includedOptions, exceptOptions, tempOptions, newOption, maxShowOption, register, unregister, onChange, onFocus, onBlur, className, }: SelectProps): import("@types/react").JSX.Element;
49
+ export declare function SelectComponent({ name, label, placeholder, tip, leftIcon, rightIcon, value, invalid, disabled, validations, multiple, autoFocus, clearable, options, searchable, serverOptionControl, idbOptionControl, serverSearchable, includedOptions, exceptOptions, tempOptions, newOption, maxShowOption, creatable, creatableLabel, register, unregister, onChange, onFocus, onBlur, className, }: SelectProps): import("@types/react").JSX.Element;
@@ -6,8 +6,9 @@ const jsx_runtime_1 = require("react/jsx-runtime");
6
6
  const skalfa_icon_1 = require("@skalfa/skalfa-icon");
7
7
  const react_1 = require("react");
8
8
  const _utils_1 = require("@utils");
9
+ const OutsideClick_component_1 = require("../wrap/OutsideClick.component");
9
10
  ;
10
- function SelectComponent({ name, label, placeholder, tip, leftIcon, rightIcon, value, invalid, disabled, validations, multiple, autoFocus, clearable, options = [], searchable, serverOptionControl, idbOptionControl, serverSearchable, includedOptions = [], exceptOptions = [], tempOptions, newOption, maxShowOption = 10, register, unregister, onChange, onFocus, onBlur, className = "", }) {
11
+ function SelectComponent({ name, label, placeholder, tip, leftIcon, rightIcon, value, invalid, disabled, validations, multiple, autoFocus, clearable, options = [], searchable, serverOptionControl, idbOptionControl, serverSearchable, includedOptions = [], exceptOptions = [], tempOptions, newOption, maxShowOption = 10, creatable, creatableLabel, register, unregister, onChange, onFocus, onBlur, className = "", }) {
11
12
  const [inputShowValue, setInputShowValue] = (0, react_1.useState)("");
12
13
  const [keydown, setKeydown] = (0, react_1.useState)(false);
13
14
  const [useTemp, setUseTemp] = (0, react_1.useState)(true);
@@ -18,6 +19,26 @@ function SelectComponent({ name, label, placeholder, tip, leftIcon, rightIcon, v
18
19
  const [showOption, setShowOption] = (0, react_1.useState)(false);
19
20
  const [keyword, setKeyword] = (0, react_1.useState)("");
20
21
  const [keywordSearch] = (0, _utils_1.useLazySearch)(keyword);
22
+ const creatableKey = typeof creatable === "string" ? (creatable || "name") : (creatable ? "name" : null);
23
+ const [isCreatingNew, setIsCreatingNew] = (0, react_1.useState)(false);
24
+ const [customText, setCustomText] = (0, react_1.useState)("");
25
+ const [dropdownCustomText, setDropdownCustomText] = (0, react_1.useState)("");
26
+ const isFocusingCreatable = (0, react_1.useRef)(false);
27
+ const handleCreateNewSubmit = () => {
28
+ if (!dropdownCustomText.trim() || !creatableKey)
29
+ return;
30
+ isFocusingCreatable.current = false;
31
+ const valText = dropdownCustomText.trim();
32
+ setIsCreatingNew(true);
33
+ setCustomText(valText);
34
+ setInputShowValue(valText);
35
+ setShowOption(false);
36
+ setFilteredOptions([]);
37
+ const objVal = { [creatableKey]: valText };
38
+ inputHandler.setValue(objVal);
39
+ inputHandler.setIdle(false);
40
+ onChange?.(objVal);
41
+ };
21
42
  // =========================>
22
43
  // ## Initial
23
44
  // =========================>
@@ -28,29 +49,43 @@ function SelectComponent({ name, label, placeholder, tip, leftIcon, rightIcon, v
28
49
  // =========================>
29
50
  const [invalidMessage] = (0, _utils_1.useValidation)(inputHandler.value, validations, invalid, inputHandler.idle);
30
51
  // =========================>
31
- // ## change value handler
52
+ // ## options handler
32
53
  // =========================>
54
+ const optionsKey = typeof options === "object" ? JSON.stringify(options) : String(options || "");
55
+ const includedOptionsKey = typeof includedOptions === "object" ? JSON.stringify(includedOptions) : String(includedOptions || "");
56
+ const exceptOptionsKey = typeof exceptOptions === "object" ? JSON.stringify(exceptOptions) : String(exceptOptions || "");
33
57
  (0, react_1.useEffect)(() => {
34
- if (value) {
35
- inputHandler.setValue(value);
36
- Array.isArray(dataOptions) && setInputShowValue((newOption ? [newOption, ...dataOptions] : dataOptions)?.find((option) => option.value == value)?.label || "");
37
- inputHandler.setIdle(false);
58
+ let base = options?.length ? [...options, ...includedOptions] : [...includedOptions];
59
+ if (exceptOptions?.length) {
60
+ base = base.filter((op) => !exceptOptions.includes(op.value));
38
61
  }
39
- else {
40
- inputHandler.setValue("");
41
- setInputShowValue("");
42
- }
43
- }, [value, dataOptions]);
62
+ setDataOptions(base);
63
+ }, [optionsKey, includedOptionsKey, exceptOptionsKey]);
64
+ const dataOptionsKey = dataOptions.map((op) => String(op.value)).join("|");
44
65
  // =========================>
45
- // ## options handler
66
+ // ## change value handler
46
67
  // =========================>
47
68
  (0, react_1.useEffect)(() => {
48
- options?.length && setDataOptions([...options, ...includedOptions].filter((op) => !exceptOptions?.includes(op.value)));
49
- }, [options]);
69
+ const currentVal = inputHandler.value;
70
+ if (currentVal && typeof currentVal === "object" && !Array.isArray(currentVal) && creatableKey && creatableKey in currentVal) {
71
+ setIsCreatingNew(true);
72
+ const text = String(currentVal[creatableKey] || "");
73
+ setCustomText(text);
74
+ setInputShowValue(text);
75
+ }
76
+ else if (currentVal !== undefined && currentVal !== null && currentVal !== "" && typeof currentVal !== "object") {
77
+ setIsCreatingNew(false);
78
+ const matched = (newOption ? [newOption, ...dataOptions] : dataOptions)?.find((option) => option.value == currentVal);
79
+ setInputShowValue(matched?.label || currentVal);
80
+ }
81
+ else if (!currentVal && !isCreatingNew) {
82
+ setInputShowValue("");
83
+ }
84
+ }, [inputHandler.value, dataOptionsKey, creatableKey]);
50
85
  const filterOption = (e) => {
51
- if (dataOptions?.length) {
86
+ if (dataOptions?.length || creatableKey) {
52
87
  let newFilteredOptions = [];
53
- if (searchable && !serverSearchable) {
88
+ if (searchable && !serverSearchable && dataOptions?.length) {
54
89
  if (e.target.value) {
55
90
  newFilteredOptions = dataOptions.filter((Option) => Option.label?.toLowerCase().indexOf(e.target.value.toLowerCase()) > -1).slice(0, maxShowOption);
56
91
  }
@@ -59,7 +94,7 @@ function SelectComponent({ name, label, placeholder, tip, leftIcon, rightIcon, v
59
94
  }
60
95
  }
61
96
  else {
62
- newFilteredOptions = dataOptions;
97
+ newFilteredOptions = dataOptions || [];
63
98
  }
64
99
  setActiveOption(-1);
65
100
  setFilteredOptions(newFilteredOptions);
@@ -73,6 +108,16 @@ function SelectComponent({ name, label, placeholder, tip, leftIcon, rightIcon, v
73
108
  setActiveOption(-1);
74
109
  setFilteredOptions([]);
75
110
  setShowOption(false);
111
+ if (resultValue?.value === "__CREATE_NEW__" && creatableKey) {
112
+ setIsCreatingNew(true);
113
+ setCustomText("");
114
+ setInputShowValue("");
115
+ const objVal = { [creatableKey]: "" };
116
+ inputHandler.setValue(objVal);
117
+ onChange?.(objVal, resultValue);
118
+ e.preventDefault();
119
+ return;
120
+ }
76
121
  if (!multiple) {
77
122
  setInputShowValue(resultValue?.label || inputShowValue);
78
123
  inputHandler.setValue(resultValue?.value || inputShowValue);
@@ -176,25 +221,42 @@ function SelectComponent({ name, label, placeholder, tip, leftIcon, rightIcon, v
176
221
  }
177
222
  }
178
223
  }, [keywordSearch, serverOptionControl?.path, serverOptionControl?.url]);
179
- return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: (0, jsx_runtime_1.jsxs)("div", { className: "input-container", children: [(0, jsx_runtime_1.jsxs)("label", { htmlFor: randomId, className: (0, _utils_1.cn)("input-label", disabled && "input-label-disabled", inputHandler.focus && "input-label-focus", invalidMessage && "input-label-error", (0, _utils_1.pcn)(className, "label"), disabled && (0, _utils_1.pcn)(className, "label", "disabled"), inputHandler.focus && (0, _utils_1.pcn)(className, "label", "focus"), invalidMessage && (0, _utils_1.pcn)(className, "label", "error")), children: [label, validations && _utils_1.validation.hasRules(validations, "required") && (0, jsx_runtime_1.jsx)("span", { className: "text-danger ml-1", children: "*" })] }), tip && ((0, jsx_runtime_1.jsx)("small", { className: (0, _utils_1.cn)("input-tip", disabled && "input-tip-disabled", (0, _utils_1.pcn)(className, "tip"), disabled && (0, _utils_1.pcn)(className, "tip", "disabled")), children: tip })), (0, jsx_runtime_1.jsxs)("div", { className: "relative", children: [(0, jsx_runtime_1.jsx)("input", { type: "hidden", value: !multiple ? String(inputHandler.value) : Array().concat(inputHandler.value).map((val) => String(val)), name: name }), (0, jsx_runtime_1.jsx)("input", { type: "text", readOnly: !searchable, id: randomId, placeholder: !inputHandler.value || (Array.isArray(inputHandler.value) && !inputHandler.value.length) ? placeholder : "", disabled: disabled, className: (0, _utils_1.cn)("input cursor-pointer", leftIcon && "input-with-left-icon", rightIcon && "input-with-right-icon", invalidMessage && "input-error", (0, _utils_1.pcn)(className, "input"), invalidMessage && (0, _utils_1.pcn)(className, "input", "error")), value: (useTemp && tempOptions ? tempOptions.at(0)?.label : serverSearchable ? keyword : inputShowValue), onChange: (e) => {
224
+ return ((0, jsx_runtime_1.jsx)(OutsideClick_component_1.OutsideClickComponent, { onOutsideClick: () => {
225
+ isFocusingCreatable.current = false;
226
+ setShowOption(false);
227
+ inputHandler.setFocus(false);
228
+ }, children: (0, jsx_runtime_1.jsxs)("div", { className: "input-container", children: [(0, jsx_runtime_1.jsxs)("label", { htmlFor: randomId, className: (0, _utils_1.cn)("input-label", disabled && "input-label-disabled", inputHandler.focus && "input-label-focus", invalidMessage && "input-label-error", (0, _utils_1.pcn)(className, "label"), disabled && (0, _utils_1.pcn)(className, "label", "disabled"), inputHandler.focus && (0, _utils_1.pcn)(className, "label", "focus"), invalidMessage && (0, _utils_1.pcn)(className, "label", "error")), children: [label, validations && _utils_1.validation.hasRules(validations, "required") && (0, jsx_runtime_1.jsx)("span", { className: "text-danger ml-1", children: "*" })] }), tip && ((0, jsx_runtime_1.jsx)("small", { className: (0, _utils_1.cn)("input-tip", disabled && "input-tip-disabled", (0, _utils_1.pcn)(className, "tip"), disabled && (0, _utils_1.pcn)(className, "tip", "disabled")), children: tip })), (0, jsx_runtime_1.jsxs)("div", { className: "relative", children: [isCreatingNew && typeof inputHandler.value === "object" && inputHandler.value !== null ? (Object.entries(inputHandler.value).map(([subK, subV]) => ((0, jsx_runtime_1.jsx)("input", { type: "hidden", name: `${name.endsWith("_id") ? name.slice(0, -3) : name}[${subK}]`, value: String(subV ?? "") }, subK)))) : ((0, jsx_runtime_1.jsx)("input", { type: "hidden", value: !multiple ? (typeof inputHandler.value === "object" ? JSON.stringify(inputHandler.value) : String(inputHandler.value ?? "")) : Array().concat(inputHandler.value).map((val) => String(val)), name: name })), isCreatingNew && ((0, jsx_runtime_1.jsx)("div", { className: "absolute left-1.5 top-1/2 -translate-y-1/2 bg-stroke text-sm px-3.5 py-1.5 rounded-lg select-none pointer-events-none z-10 font-normal", children: "Baru" })), (0, jsx_runtime_1.jsx)("input", { type: "text", readOnly: !searchable, id: randomId, placeholder: !inputHandler.value || (Array.isArray(inputHandler.value) && !inputHandler.value.length) ? placeholder : "", disabled: disabled, className: (0, _utils_1.cn)("input cursor-pointer", leftIcon && "input-with-left-icon", rightIcon && "input-with-right-icon", isCreatingNew && "pl-[4.8rem]", invalidMessage && "input-error", (0, _utils_1.pcn)(className, "input"), invalidMessage && (0, _utils_1.pcn)(className, "input", "error")), value: (useTemp && tempOptions ? tempOptions.at(0)?.label : serverSearchable ? keyword : (isCreatingNew ? customText : inputShowValue)), onChange: (e) => {
180
229
  setUseTemp(false);
181
230
  searchable && setInputShowValue(e.target.value);
182
231
  serverSearchable && setKeyword(e.target.value);
183
232
  inputHandler.setIdle(false);
184
- dataOptions?.length && filterOption(e);
233
+ (dataOptions?.length || creatableKey) && filterOption(e);
185
234
  }, onFocus: (e) => {
186
235
  setUseTemp(false);
187
236
  inputHandler.setFocus(true);
188
237
  onFocus?.();
189
- dataOptions?.length && filterOption(e);
238
+ (dataOptions?.length || creatableKey) && filterOption(e);
190
239
  searchable && e.target.select();
191
240
  }, onBlur: (e) => {
192
241
  setUseTemp(false);
242
+ if (isFocusingCreatable.current)
243
+ return;
244
+ if (isCreatingNew) {
245
+ setTimeout(() => {
246
+ if (!isFocusingCreatable.current) {
247
+ inputHandler.setFocus(false);
248
+ }
249
+ }, 100);
250
+ onBlur?.();
251
+ return;
252
+ }
193
253
  const value = e.target.value;
194
254
  const valueOption = dataOptions?.find((option) => option.label?.toLowerCase() == value?.toLowerCase());
195
255
  if (!keydown) {
196
256
  if (!multiple) {
197
257
  setTimeout(() => {
258
+ if (isFocusingCreatable.current)
259
+ return;
198
260
  if (valueOption?.value) {
199
261
  setInputShowValue(valueOption.label);
200
262
  inputHandler.setValue(valueOption.value);
@@ -216,7 +278,9 @@ function SelectComponent({ name, label, placeholder, tip, leftIcon, rightIcon, v
216
278
  }
217
279
  }
218
280
  setTimeout(() => {
219
- inputHandler.setFocus(false);
281
+ if (!isFocusingCreatable.current) {
282
+ inputHandler.setFocus(false);
283
+ }
220
284
  }, 100);
221
285
  onBlur?.();
222
286
  }, onKeyDown: (e) => {
@@ -233,42 +297,79 @@ function SelectComponent({ name, label, placeholder, tip, leftIcon, rightIcon, v
233
297
  }
234
298
  } })] }, key));
235
299
  }) }) })), leftIcon && ((0, jsx_runtime_1.jsx)(skalfa_icon_1.Icon, { className: (0, _utils_1.cn)("input-icon", "input-icon-left", disabled && "input-icon-disabled", inputHandler.focus && "input-icon-focus", (0, _utils_1.pcn)(className, "icon"), disabled && (0, _utils_1.pcn)(className, "icon", "disabled"), inputHandler.focus && (0, _utils_1.pcn)(className, "icon", "focus")), icon: leftIcon })), !multiple && clearable && inputHandler.value && ((0, jsx_runtime_1.jsx)("div", { className: (0, _utils_1.cn)("input-icon", "input-icon-clear", disabled && "input-icon-disabled", (0, _utils_1.pcn)(className, "icon"), disabled && (0, _utils_1.pcn)(className, "icon", "disabled")), onClick: () => {
300
+ setIsCreatingNew(false);
301
+ setCustomText("");
302
+ setDropdownCustomText("");
236
303
  setInputShowValue("");
237
304
  inputHandler.setValue("");
238
305
  onChange?.("");
239
- }, children: (0, jsx_runtime_1.jsx)(skalfa_icon_1.Icon, { icon: "solid/times" }) })), (0, jsx_runtime_1.jsx)("label", { htmlFor: randomId, className: (0, _utils_1.cn)("input-icon", "input-icon-right", "select-icon-dropdown", disabled && "input-icon-disabled", (0, _utils_1.pcn)(className, "icon"), disabled && (0, _utils_1.pcn)(className, "icon", "disabled")), children: (0, jsx_runtime_1.jsx)(skalfa_icon_1.Icon, { icon: "solid/chevron-down" }) })] }), !!dataOptions?.length && showOption && !loadingOption && !!filteredOptions?.length && ((0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)("ul", { className: (0, _utils_1.cn)("input-suggest-container scroll-sm", inputHandler.focus && "input-suggest-container-active", (0, _utils_1.pcn)(className, "suggest")), children: (newOption ? [newOption, ...filteredOptions] : filteredOptions).map((option, key) => {
240
- const selected = !!((typeof inputHandler.value == "string" || typeof inputHandler.value == "number") && inputHandler.value == option.value) ||
241
- (Array.isArray(inputHandler.value) && Array().concat(inputHandler.value).find((val) => val == option.value));
242
- return ((0, jsx_runtime_1.jsxs)("li", { className: (0, _utils_1.cn)("input-suggest", (key == activeOption || selected) && "input-suggest-active", (0, _utils_1.pcn)(className, "suggest-item"), (key == activeOption || selected) && (0, _utils_1.pcn)(className, "suggest-item", "active")), onMouseDown: () => {
243
- setKeydown(true);
244
- setTimeout(() => inputHandler.setFocus(true), 110);
245
- }, onMouseUp: () => {
246
- setKeydown(false);
247
- setActiveOption(key);
248
- setFilteredOptions([]);
249
- setShowOption(false);
250
- if (!multiple) {
251
- setInputShowValue(option.label);
252
- serverSearchable && setKeyword(option.label);
253
- inputHandler.setValue(option.value);
254
- onChange?.(option.value, option);
255
- }
256
- else {
257
- const values = Array.isArray(inputHandler.value)
258
- ? Array().concat(inputHandler.value).filter((val) => val != option.value)
259
- : [];
260
- setInputShowValue("");
261
- serverSearchable && setKeyword("");
262
- if (Array.isArray(inputHandler.value) && Array().concat(inputHandler.value).find((val) => val == option.value)) {
263
- inputHandler.setValue(values);
264
- onChange?.(values);
306
+ }, children: (0, jsx_runtime_1.jsx)(skalfa_icon_1.Icon, { icon: "solid/times" }) })), (0, jsx_runtime_1.jsx)("label", { htmlFor: randomId, className: (0, _utils_1.cn)("input-icon", "input-icon-right", "select-icon-dropdown cursor-pointer", disabled && "input-icon-disabled", (0, _utils_1.pcn)(className, "icon"), disabled && (0, _utils_1.pcn)(className, "icon", "disabled")), onClick: (e) => {
307
+ (dataOptions?.length || creatableKey) && filterOption(e);
308
+ }, children: (0, jsx_runtime_1.jsx)(skalfa_icon_1.Icon, { icon: "solid/chevron-down" }) })] }), (!!dataOptions?.length || creatableKey) && showOption && !loadingOption && ((0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsxs)("ul", { className: (0, _utils_1.cn)("input-suggest-container scroll-sm", inputHandler.focus && "input-suggest-container-active", (0, _utils_1.pcn)(className, "suggest")), children: [creatableKey && ((0, jsx_runtime_1.jsxs)("li", { className: "p-2 border-b border-stroke flex items-center gap-2 cursor-default bg-white sticky top-0 z-10 mb-2", onMouseDown: (e) => {
309
+ isFocusingCreatable.current = true;
310
+ e.stopPropagation();
311
+ }, onMouseUp: (e) => {
312
+ e.stopPropagation();
313
+ }, onClick: (e) => {
314
+ e.stopPropagation();
315
+ }, children: [(0, jsx_runtime_1.jsx)("input", { type: "text", className: "input flex-1 text-sm py-1.5 px-3 rounded-lg border border-stroke focus:outline-none focus:border-primary placeholder:text-slate-300 text-slate-700 bg-white", placeholder: creatableLabel || "Masukkan pilihan baru...", value: dropdownCustomText, onMouseDown: (e) => {
316
+ isFocusingCreatable.current = true;
317
+ e.stopPropagation();
318
+ }, onFocus: () => {
319
+ isFocusingCreatable.current = true;
320
+ }, onBlur: () => {
321
+ setTimeout(() => {
322
+ if (!isFocusingCreatable.current) {
323
+ setShowOption(false);
324
+ inputHandler.setFocus(false);
325
+ }
326
+ }, 200);
327
+ }, onChange: (e) => setDropdownCustomText(e.target.value), onKeyDown: (e) => {
328
+ e.stopPropagation();
329
+ if (e.key === "Enter") {
330
+ e.preventDefault();
331
+ handleCreateNewSubmit();
332
+ }
333
+ } }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "bg-light-primary text-primary p-2 rounded-lg flex items-center justify-center transition-colors shrink-0 shadow-sm cursor-pointer border-none", onMouseDown: (e) => {
334
+ isFocusingCreatable.current = true;
335
+ e.stopPropagation();
336
+ }, onClick: (e) => {
337
+ e.stopPropagation();
338
+ handleCreateNewSubmit();
339
+ }, children: (0, jsx_runtime_1.jsx)(skalfa_icon_1.Icon, { icon: "solid/check", className: "w-4 h-4" }) })] })), (newOption ? [newOption, ...filteredOptions] : filteredOptions).map((option, key) => {
340
+ const selected = !isCreatingNew && (!!((typeof inputHandler.value == "string" || typeof inputHandler.value == "number") && inputHandler.value == option.value) ||
341
+ (Array.isArray(inputHandler.value) && Array().concat(inputHandler.value).find((val) => val == option.value)));
342
+ return ((0, jsx_runtime_1.jsxs)("li", { className: (0, _utils_1.cn)("input-suggest", (key == activeOption || selected) && "input-suggest-active", (0, _utils_1.pcn)(className, "suggest-item"), (key == activeOption || selected) && (0, _utils_1.pcn)(className, "suggest-item", "active")), onMouseDown: () => {
343
+ setKeydown(true);
344
+ setTimeout(() => inputHandler.setFocus(true), 110);
345
+ }, onMouseUp: () => {
346
+ setKeydown(false);
347
+ setActiveOption(key);
348
+ setFilteredOptions([]);
349
+ setShowOption(false);
350
+ setIsCreatingNew(false);
351
+ if (!multiple) {
352
+ setInputShowValue(option.label);
353
+ serverSearchable && setKeyword(option.label);
354
+ inputHandler.setValue(option.value);
355
+ onChange?.(option.value, option);
265
356
  }
266
357
  else {
267
- inputHandler.setValue([...Array().concat(values), option.value]);
268
- onChange?.([...Array().concat(values), option.value]);
358
+ const values = Array.isArray(inputHandler.value)
359
+ ? Array().concat(inputHandler.value).filter((val) => val != option.value)
360
+ : [];
361
+ setInputShowValue("");
362
+ serverSearchable && setKeyword("");
363
+ if (Array.isArray(inputHandler.value) && Array().concat(inputHandler.value).find((val) => val == option.value)) {
364
+ inputHandler.setValue(values);
365
+ onChange?.(values);
366
+ }
367
+ else {
368
+ inputHandler.setValue([...Array().concat(values), option.value]);
369
+ onChange?.([...Array().concat(values), option.value]);
370
+ }
269
371
  }
270
- }
271
- setTimeout(() => inputHandler.setFocus(false), 120);
272
- }, children: [selected && ((0, jsx_runtime_1.jsx)(skalfa_icon_1.Icon, { icon: "solid/check", className: "select-suggest-check" })), option.label] }, key));
273
- }) }) })), invalidMessage && ((0, jsx_runtime_1.jsx)("small", { className: (0, _utils_1.cn)("input-error-message", (0, _utils_1.pcn)(className, "error")), children: invalidMessage }))] }) }));
372
+ setTimeout(() => inputHandler.setFocus(false), 120);
373
+ }, children: [selected && ((0, jsx_runtime_1.jsx)(skalfa_icon_1.Icon, { icon: "solid/check", className: "select-suggest-check" })), option.label] }, key));
374
+ })] }) })), invalidMessage && ((0, jsx_runtime_1.jsx)("small", { className: (0, _utils_1.cn)("input-error-message", (0, _utils_1.pcn)(className, "error")), children: invalidMessage }))] }) }));
274
375
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skalfa/skalfa-component",
3
- "version": "1.0.24",
3
+ "version": "1.0.25",
4
4
  "description": "Reusable UI components for Skalfa App.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -2,8 +2,9 @@
2
2
  import { Icon, type IconName } from "@skalfa/skalfa-icon";
3
3
 
4
4
 
5
- import { ReactNode, useEffect, useState } from "react";
5
+ import { ReactNode, useEffect, useRef, useState } from "react";
6
6
  import { api, ApiType, cavity, cn, pcn, registry, useInputHandler, useInputRandomId, useLazySearch, useValidation, validation, ValidationRules,} from "@utils";
7
+ import { OutsideClickComponent } from "../wrap/OutsideClick.component";
7
8
 
8
9
 
9
10
 
@@ -24,7 +25,7 @@ export interface SelectProps {
24
25
  leftIcon ?: any;
25
26
  rightIcon ?: any;
26
27
 
27
- value ?: string | number | (string | number)[];
28
+ value ?: string | number | (string | number)[] | Record<string, any>;
28
29
  invalid ?: string;
29
30
  disabled ?: boolean;
30
31
  validations ?: ValidationRules;
@@ -42,8 +43,10 @@ export interface SelectProps {
42
43
  tempOptions ?: SelectOptionProps[];
43
44
  newOption ?: SelectOptionProps;
44
45
  maxShowOption ?: number;
46
+ creatable ?: boolean | string;
47
+ creatableLabel ?: string;
45
48
 
46
- onChange ?: (value: string | number | (string | number)[], data?: any) => any;
49
+ onChange ?: (value: any, data?: any) => any;
47
50
  register ?: (name: string, validations?: ValidationRules) => void;
48
51
  unregister ?: (name: string) => void;
49
52
  onFocus ?: () => void;
@@ -81,6 +84,8 @@ export function SelectComponent({
81
84
  tempOptions,
82
85
  newOption,
83
86
  maxShowOption = 10,
87
+ creatable,
88
+ creatableLabel,
84
89
 
85
90
  register,
86
91
  unregister,
@@ -101,6 +106,28 @@ export function SelectComponent({
101
106
  const [keyword, setKeyword] = useState("");
102
107
  const [keywordSearch] = useLazySearch(keyword);
103
108
 
109
+ const creatableKey = typeof creatable === "string" ? (creatable || "name") : (creatable ? "name" : null);
110
+ const [isCreatingNew, setIsCreatingNew] = useState(false);
111
+ const [customText, setCustomText] = useState("");
112
+ const [dropdownCustomText, setDropdownCustomText] = useState("");
113
+ const isFocusingCreatable = useRef(false);
114
+
115
+ const handleCreateNewSubmit = () => {
116
+ if (!dropdownCustomText.trim() || !creatableKey) return;
117
+
118
+ isFocusingCreatable.current = false;
119
+ const valText = dropdownCustomText.trim();
120
+ setIsCreatingNew(true);
121
+ setCustomText(valText);
122
+ setInputShowValue(valText);
123
+ setShowOption(false);
124
+ setFilteredOptions([]);
125
+
126
+ const objVal = { [creatableKey]: valText };
127
+ inputHandler.setValue(objVal);
128
+ inputHandler.setIdle(false);
129
+ onChange?.(objVal);
130
+ };
104
131
 
105
132
  // =========================>
106
133
  // ## Initial
@@ -116,40 +143,54 @@ export function SelectComponent({
116
143
 
117
144
 
118
145
  // =========================>
119
- // ## change value handler
146
+ // ## options handler
120
147
  // =========================>
148
+ const optionsKey = typeof options === "object" ? JSON.stringify(options) : String(options || "");
149
+ const includedOptionsKey = typeof includedOptions === "object" ? JSON.stringify(includedOptions) : String(includedOptions || "");
150
+ const exceptOptionsKey = typeof exceptOptions === "object" ? JSON.stringify(exceptOptions) : String(exceptOptions || "");
151
+
121
152
  useEffect(() => {
122
- if (value) {
123
- inputHandler.setValue(value);
124
- Array.isArray(dataOptions) && setInputShowValue((newOption ? [newOption, ...dataOptions] : dataOptions)?.find((option) => option.value == value)?.label || "");
125
- inputHandler.setIdle(false);
126
- } else {
127
- inputHandler.setValue("");
128
- setInputShowValue("");
153
+ let base = options?.length ? [...options, ...includedOptions] : [...includedOptions];
154
+ if (exceptOptions?.length) {
155
+ base = base.filter((op: SelectOptionProps) => !exceptOptions.includes(op.value));
129
156
  }
130
- }, [value, dataOptions]);
157
+ setDataOptions(base);
158
+ }, [optionsKey, includedOptionsKey, exceptOptionsKey]);
131
159
 
160
+ const dataOptionsKey = dataOptions.map((op) => String(op.value)).join("|");
132
161
 
133
162
  // =========================>
134
- // ## options handler
163
+ // ## change value handler
135
164
  // =========================>
136
165
  useEffect(() => {
137
- options?.length && setDataOptions([...options, ...includedOptions].filter((op: SelectOptionProps) => !exceptOptions?.includes(op.value)));
138
- }, [options]);
166
+ const currentVal = inputHandler.value;
167
+ if (currentVal && typeof currentVal === "object" && !Array.isArray(currentVal) && creatableKey && creatableKey in currentVal) {
168
+ setIsCreatingNew(true);
169
+ const text = String((currentVal as Record<string, any>)[creatableKey] || "");
170
+ setCustomText(text);
171
+ setInputShowValue(text);
172
+ } else if (currentVal !== undefined && currentVal !== null && currentVal !== "" && typeof currentVal !== "object") {
173
+ setIsCreatingNew(false);
174
+ const matched = (newOption ? [newOption, ...dataOptions] : dataOptions)?.find((option) => option.value == currentVal);
175
+ setInputShowValue(matched?.label || currentVal);
176
+ } else if (!currentVal && !isCreatingNew) {
177
+ setInputShowValue("");
178
+ }
179
+ }, [inputHandler.value, dataOptionsKey, creatableKey]);
139
180
 
140
181
 
141
182
  const filterOption = (e: any) => {
142
- if (dataOptions?.length) {
183
+ if (dataOptions?.length || creatableKey) {
143
184
  let newFilteredOptions: SelectOptionProps[] = [];
144
185
 
145
- if (searchable && !serverSearchable) {
186
+ if (searchable && !serverSearchable && dataOptions?.length) {
146
187
  if (e.target.value) {
147
188
  newFilteredOptions = dataOptions.filter((Option) => (Option.label as string)?.toLowerCase().indexOf(e.target.value.toLowerCase()) > -1).slice(0, maxShowOption);
148
189
  } else {
149
190
  newFilteredOptions = dataOptions.slice(0, maxShowOption);
150
191
  }
151
192
  } else {
152
- newFilteredOptions = dataOptions;
193
+ newFilteredOptions = dataOptions || [];
153
194
  }
154
195
 
155
196
  setActiveOption(-1);
@@ -166,6 +207,18 @@ export function SelectComponent({
166
207
  setActiveOption(-1);
167
208
  setFilteredOptions([]);
168
209
  setShowOption(false);
210
+
211
+ if (resultValue?.value === "__CREATE_NEW__" && creatableKey) {
212
+ setIsCreatingNew(true);
213
+ setCustomText("");
214
+ setInputShowValue("");
215
+ const objVal = { [creatableKey]: "" };
216
+ inputHandler.setValue(objVal);
217
+ onChange?.(objVal, resultValue);
218
+ e.preventDefault();
219
+ return;
220
+ }
221
+
169
222
  if (!multiple) {
170
223
  setInputShowValue(resultValue?.label || inputShowValue);
171
224
  inputHandler.setValue(resultValue?.value || inputShowValue);
@@ -284,7 +337,13 @@ export function SelectComponent({
284
337
  }, [keywordSearch, serverOptionControl?.path, serverOptionControl?.url]);
285
338
 
286
339
  return (
287
- <>
340
+ <OutsideClickComponent
341
+ onOutsideClick={() => {
342
+ isFocusingCreatable.current = false;
343
+ setShowOption(false);
344
+ inputHandler.setFocus(false);
345
+ }}
346
+ >
288
347
  <div className="input-container">
289
348
  <label
290
349
  htmlFor={randomId}
@@ -315,11 +374,27 @@ export function SelectComponent({
315
374
  )}
316
375
 
317
376
  <div className="relative">
318
- <input
319
- type="hidden"
320
- value={!multiple ? String(inputHandler.value) : Array().concat(inputHandler.value).map((val) => String(val))}
321
- name={name}
322
- />
377
+ {isCreatingNew && typeof inputHandler.value === "object" && inputHandler.value !== null ? (
378
+ Object.entries(inputHandler.value).map(([subK, subV]) => (
379
+ <input
380
+ key={subK}
381
+ type="hidden"
382
+ name={`${name.endsWith("_id") ? name.slice(0, -3) : name}[${subK}]`}
383
+ value={String(subV ?? "")}
384
+ />
385
+ ))
386
+ ) : (
387
+ <input
388
+ type="hidden"
389
+ value={!multiple ? (typeof inputHandler.value === "object" ? JSON.stringify(inputHandler.value) : String(inputHandler.value ?? "")) : Array().concat(inputHandler.value).map((val) => String(val))}
390
+ name={name}
391
+ />
392
+ )}
393
+ {isCreatingNew && (
394
+ <div className="absolute left-1.5 top-1/2 -translate-y-1/2 bg-stroke text-sm px-3.5 py-1.5 rounded-lg select-none pointer-events-none z-10 font-normal">
395
+ Baru
396
+ </div>
397
+ )}
323
398
  <input
324
399
  type="text"
325
400
  readOnly={!searchable}
@@ -330,33 +405,46 @@ export function SelectComponent({
330
405
  "input cursor-pointer",
331
406
  leftIcon && "input-with-left-icon",
332
407
  rightIcon && "input-with-right-icon",
408
+ isCreatingNew && "pl-[4.8rem]",
333
409
  invalidMessage && "input-error",
334
410
  pcn<CT>(className, "input"),
335
411
  invalidMessage && pcn<CT>(className, "input", "error")
336
412
  )}
337
- value={(useTemp && tempOptions ? tempOptions.at(0)?.label : serverSearchable ? keyword : inputShowValue) as string}
413
+ value={(useTemp && tempOptions ? tempOptions.at(0)?.label : serverSearchable ? keyword : (isCreatingNew ? customText : inputShowValue)) as string}
338
414
  onChange={(e) => {
339
415
  setUseTemp(false);
340
416
  searchable && setInputShowValue(e.target.value);
341
417
  serverSearchable && setKeyword(e.target.value);
342
418
  inputHandler.setIdle(false);
343
- dataOptions?.length && filterOption(e);
419
+ (dataOptions?.length || creatableKey) && filterOption(e);
344
420
  }}
345
421
  onFocus={(e) => {
346
422
  setUseTemp(false);
347
423
  inputHandler.setFocus(true);
348
424
  onFocus?.();
349
- dataOptions?.length && filterOption(e);
425
+ (dataOptions?.length || creatableKey) && filterOption(e);
350
426
  searchable && e.target.select();
351
427
  }}
352
428
  onBlur={(e) => {
353
429
  setUseTemp(false);
430
+ if (isFocusingCreatable.current) return;
431
+
432
+ if (isCreatingNew) {
433
+ setTimeout(() => {
434
+ if (!isFocusingCreatable.current) {
435
+ inputHandler.setFocus(false);
436
+ }
437
+ }, 100);
438
+ onBlur?.();
439
+ return;
440
+ }
354
441
  const value = e.target.value;
355
442
  const valueOption = dataOptions?.find((option) => (option.label as string)?.toLowerCase() == value?.toLowerCase());
356
443
 
357
444
  if (!keydown) {
358
445
  if (!multiple) {
359
446
  setTimeout(() => {
447
+ if (isFocusingCreatable.current) return;
360
448
  if (valueOption?.value) {
361
449
  setInputShowValue(valueOption.label);
362
450
  inputHandler.setValue(valueOption.value);
@@ -377,7 +465,9 @@ export function SelectComponent({
377
465
  }
378
466
 
379
467
  setTimeout(() => {
380
- inputHandler.setFocus(false);
468
+ if (!isFocusingCreatable.current) {
469
+ inputHandler.setFocus(false);
470
+ }
381
471
  }, 100);
382
472
 
383
473
  onBlur?.();
@@ -452,6 +542,9 @@ export function SelectComponent({
452
542
  disabled && pcn<CT>(className, "icon", "disabled")
453
543
  )}
454
544
  onClick={() => {
545
+ setIsCreatingNew(false);
546
+ setCustomText("");
547
+ setDropdownCustomText("");
455
548
  setInputShowValue("");
456
549
  inputHandler.setValue("");
457
550
  onChange?.("");
@@ -466,17 +559,20 @@ export function SelectComponent({
466
559
  className={cn(
467
560
  "input-icon",
468
561
  "input-icon-right",
469
- "select-icon-dropdown",
562
+ "select-icon-dropdown cursor-pointer",
470
563
  disabled && "input-icon-disabled",
471
564
  pcn<CT>(className, "icon"),
472
565
  disabled && pcn<CT>(className, "icon", "disabled")
473
566
  )}
567
+ onClick={(e) => {
568
+ (dataOptions?.length || creatableKey) && filterOption(e);
569
+ }}
474
570
  >
475
571
  <Icon icon="solid/chevron-down" />
476
572
  </label>
477
573
  </div>
478
574
 
479
- {!!dataOptions?.length && showOption && !loadingOption && !!filteredOptions?.length && (
575
+ {(!!dataOptions?.length || creatableKey) && showOption && !loadingOption && (
480
576
  <div>
481
577
  <ul
482
578
  className={cn(
@@ -485,9 +581,71 @@ export function SelectComponent({
485
581
  pcn<CT>(className, "suggest"),
486
582
  )}
487
583
  >
488
- {(newOption ? [newOption, ...filteredOptions] : filteredOptions ).map((option, key) => {
489
- const selected = !!((typeof inputHandler.value == "string" || typeof inputHandler.value == "number") && inputHandler.value == option.value) ||
490
- (Array.isArray(inputHandler.value) && Array().concat(inputHandler.value).find((val: string | number) => val == option.value));
584
+ {creatableKey && (
585
+ <li
586
+ className="p-2 border-b border-stroke flex items-center gap-2 cursor-default bg-white sticky top-0 z-10 mb-2"
587
+ onMouseDown={(e) => {
588
+ isFocusingCreatable.current = true;
589
+ e.stopPropagation();
590
+ }}
591
+ onMouseUp={(e) => {
592
+ e.stopPropagation();
593
+ }}
594
+ onClick={(e) => {
595
+ e.stopPropagation();
596
+ }}
597
+ >
598
+ <input
599
+ type="text"
600
+ className="input flex-1 text-sm py-1.5 px-3 rounded-lg border border-stroke focus:outline-none focus:border-primary placeholder:text-slate-300 text-slate-700 bg-white"
601
+ placeholder={creatableLabel || "Masukkan pilihan baru..."}
602
+ value={dropdownCustomText}
603
+ onMouseDown={(e) => {
604
+ isFocusingCreatable.current = true;
605
+ e.stopPropagation();
606
+ }}
607
+ onFocus={() => {
608
+ isFocusingCreatable.current = true;
609
+ }}
610
+ onBlur={() => {
611
+ setTimeout(() => {
612
+ if (!isFocusingCreatable.current) {
613
+ setShowOption(false);
614
+ inputHandler.setFocus(false);
615
+ }
616
+ }, 200);
617
+ }}
618
+ onChange={(e) => setDropdownCustomText(e.target.value)}
619
+ onKeyDown={(e) => {
620
+ e.stopPropagation();
621
+ if (e.key === "Enter") {
622
+ e.preventDefault();
623
+ handleCreateNewSubmit();
624
+ }
625
+ }}
626
+ />
627
+ <button
628
+ type="button"
629
+ className="bg-light-primary text-primary p-2 rounded-lg flex items-center justify-center transition-colors shrink-0 shadow-sm cursor-pointer border-none"
630
+ onMouseDown={(e) => {
631
+ isFocusingCreatable.current = true;
632
+ e.stopPropagation();
633
+ }}
634
+ onClick={(e) => {
635
+ e.stopPropagation();
636
+ handleCreateNewSubmit();
637
+ }}
638
+ >
639
+ <Icon icon="solid/check" className="w-4 h-4" />
640
+ </button>
641
+ </li>
642
+ )}
643
+
644
+ {(newOption ? [newOption, ...filteredOptions] : filteredOptions).map((option, key) => {
645
+ const selected = !isCreatingNew && (
646
+ !!((typeof inputHandler.value == "string" || typeof inputHandler.value == "number") && inputHandler.value == option.value) ||
647
+ (Array.isArray(inputHandler.value) && Array().concat(inputHandler.value).find((val: string | number) => val == option.value))
648
+ );
491
649
 
492
650
  return (
493
651
  <li
@@ -508,6 +666,8 @@ export function SelectComponent({
508
666
  setFilteredOptions([]);
509
667
  setShowOption(false);
510
668
 
669
+ setIsCreatingNew(false);
670
+
511
671
  if (!multiple) {
512
672
  setInputShowValue(option.label);
513
673
  serverSearchable && setKeyword(option.label as string);
@@ -552,6 +712,6 @@ export function SelectComponent({
552
712
  <small className={cn("input-error-message", pcn<CT>(className, "error"))}>{invalidMessage}</small>
553
713
  )}
554
714
  </div>
555
- </>
715
+ </OutsideClickComponent>
556
716
  );
557
717
  }