@skalfa/skalfa-app 1.0.0 → 1.0.1

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.
Files changed (87) hide show
  1. package/.env.example +43 -43
  2. package/.github/workflows/publish.yml +39 -0
  3. package/CONTRIBUTING.md +45 -0
  4. package/LICENSE +21 -0
  5. package/README.md +91 -28
  6. package/app/auth/edit/page.tsx +65 -65
  7. package/app/auth/login/page.tsx +63 -63
  8. package/app/auth/me/page.tsx +58 -58
  9. package/app/auth/register/page.tsx +69 -69
  10. package/app/auth/verify/page.tsx +53 -53
  11. package/app/dashboard/user/page.tsx +76 -76
  12. package/app/layout.tsx +37 -37
  13. package/app/manifest.ts +25 -0
  14. package/app/page.tsx +13 -13
  15. package/barrels.json +5 -5
  16. package/blueprints/starter.blueprint.json +102 -102
  17. package/bun.lock +916 -0
  18. package/components/base.components/chip/Chip.component.tsx +39 -39
  19. package/components/base.components/document/DocumentViewer.component.tsx +163 -163
  20. package/components/base.components/document/ExportExcel.component.tsx +340 -340
  21. package/components/base.components/document/ImportExcel.component.tsx +315 -315
  22. package/components/base.components/document/PrintTable.component.tsx +204 -204
  23. package/components/base.components/document/RenderPDF.component.tsx +415 -415
  24. package/components/base.components/input/Checkbox.component.tsx +109 -109
  25. package/components/base.components/input/Input.component.tsx +332 -332
  26. package/components/base.components/input/InputCheckbox.component.tsx +174 -174
  27. package/components/base.components/input/InputCurrency.component.tsx +163 -163
  28. package/components/base.components/input/InputDate.component.tsx +352 -352
  29. package/components/base.components/input/InputDatetime.component.tsx +260 -260
  30. package/components/base.components/input/InputDocument.component.tsx +351 -351
  31. package/components/base.components/input/InputImage.component.tsx +533 -533
  32. package/components/base.components/input/InputMap.component.tsx +317 -317
  33. package/components/base.components/input/InputNumber.component.tsx +192 -192
  34. package/components/base.components/input/InputOtp.component.tsx +169 -169
  35. package/components/base.components/input/InputPassword.component.tsx +236 -236
  36. package/components/base.components/input/InputRadio.component.tsx +175 -175
  37. package/components/base.components/input/InputTime.component.tsx +275 -275
  38. package/components/base.components/input/InputValues.component.tsx +68 -68
  39. package/components/base.components/input/Radio.component.tsx +102 -102
  40. package/components/base.components/input/Select.component.tsx +541 -541
  41. package/components/base.components/modal/BottomSheet.component.tsx +245 -245
  42. package/components/base.components/supervision/FormSupervision.component.tsx +433 -433
  43. package/components/base.components/supervision/TableSupervision.component.tsx +697 -697
  44. package/components/base.components/table/ControlBar.component.tsx +497 -497
  45. package/components/base.components/table/FilterComponent.tsx +518 -518
  46. package/components/base.components/table/Table.component.tsx +469 -469
  47. package/components/base.components/typography/TypographyArticle.component.tsx +26 -26
  48. package/components/base.components/typography/TypographyColumn.component.tsx +20 -20
  49. package/components/base.components/typography/TypographyContent.component.tsx +20 -20
  50. package/components/base.components/typography/TypographyTips.component.tsx +20 -20
  51. package/components/base.components/wrap/Draggable.component.tsx +303 -303
  52. package/components/base.components/wrap/IDBProvider.tsx +12 -12
  53. package/components/base.components/wrap/Image.component.tsx +9 -9
  54. package/components/base.components/wrap/ShortcutProvider.tsx +57 -57
  55. package/components/base.components/wrap/Swipe.component.tsx +93 -93
  56. package/components/index.ts +2 -2
  57. package/contexts/AppProvider.tsx +11 -11
  58. package/contexts/Auth.context.tsx +64 -64
  59. package/contexts/Toggle.context.tsx +44 -44
  60. package/next.config.ts +15 -1
  61. package/package.json +14 -13
  62. package/public/204.svg +19 -19
  63. package/public/500.svg +39 -39
  64. package/public/icon-192.png +0 -0
  65. package/public/icon-512.png +0 -0
  66. package/public/images/logo-fill.png +0 -0
  67. package/public/images/logo-full-fill.png +0 -0
  68. package/public/images/logo-full.png +0 -0
  69. package/public/images/logo.png +0 -0
  70. package/schema/idb/app.schema.ts +8 -8
  71. package/src-tauri/Cargo.toml +14 -0
  72. package/src-tauri/build.rs +3 -0
  73. package/src-tauri/capabilities/default.json +11 -0
  74. package/src-tauri/icons/128x128.png +0 -0
  75. package/src-tauri/icons/128x128@2x.png +0 -0
  76. package/src-tauri/icons/32x32.png +0 -0
  77. package/src-tauri/icons/icon.icns +0 -0
  78. package/src-tauri/icons/icon.ico +0 -0
  79. package/src-tauri/src/main.rs +7 -0
  80. package/src-tauri/tauri.conf.json +36 -0
  81. package/styles/globals.css +231 -231
  82. package/styles/tailwind.safelist +68 -68
  83. package/utils/commands/barrels.ts +27 -27
  84. package/utils/commands/light.ts +21 -21
  85. package/utils/commands/logger.ts +42 -42
  86. package/utils/commands/stubs/table-blueprint.stub +12 -12
  87. package/utils/commands/use-pdf.ts +29 -29
@@ -1,541 +1,541 @@
1
- "use client"
2
-
3
- import { ReactNode, useEffect, useState } from "react";
4
- import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
5
- import { faCheck, faChevronDown, faTimes,} from "@fortawesome/free-solid-svg-icons";
6
- import { api, ApiType, cavity, cn, pcn, registry, useInputHandler, useInputRandomId, useLazySearch, useValidation, validation, ValidationRules,} from "@utils";
7
-
8
-
9
-
10
- type CT = "label" | "tip" | "error" | "input" | "icon" | "suggest" | "suggest-item";
11
-
12
- export interface SelectOptionProps {
13
- label : string | ReactNode;
14
- value : string | number;
15
- searchable ?: string[];
16
- customLabel ?: ReactNode;
17
- };
18
-
19
- export interface SelectProps {
20
- name : string;
21
- label ?: string;
22
- placeholder ?: string;
23
- tip ?: string | ReactNode;
24
- leftIcon ?: any;
25
- rightIcon ?: any;
26
-
27
- value ?: string | number | (string | number)[];
28
- invalid ?: string;
29
- disabled ?: boolean;
30
- validations ?: ValidationRules;
31
- multiple ?: boolean;
32
- autoFocus ?: boolean;
33
- clearable ?: boolean;
34
-
35
- options ?: SelectOptionProps[];
36
- searchable ?: boolean;
37
- serverOptionControl ?: ApiType & { cacheName?: string | false };
38
- idbOptionControl ?: { store: string, labelKey: string, valueKey: string };
39
- serverSearchable ?: boolean;
40
- includedOptions ?: SelectOptionProps[];
41
- exceptOptions ?: (string | number)[];
42
- tempOptions ?: SelectOptionProps[];
43
- newOption ?: SelectOptionProps;
44
- maxShowOption ?: number;
45
-
46
- onChange ?: (value: string | number | (string | number)[], data?: any) => any;
47
- register ?: (name: string, validations?: ValidationRules) => void;
48
- unregister ?: (name: string) => void;
49
- onFocus ?: () => void;
50
- onBlur ?: () => void;
51
-
52
- /** Use custom class with: "label::", "tip::", "error::", "icon::", "suggest::", "suggest-item::". */
53
- className ?: string;
54
- }
55
-
56
-
57
-
58
- export function SelectComponent({
59
- name,
60
- label,
61
- placeholder,
62
- tip,
63
- leftIcon,
64
- rightIcon,
65
-
66
- value,
67
- invalid,
68
- disabled,
69
- validations,
70
- multiple,
71
- autoFocus,
72
- clearable,
73
-
74
- options = [],
75
- searchable,
76
- serverOptionControl,
77
- idbOptionControl,
78
- serverSearchable,
79
- includedOptions = [],
80
- exceptOptions = [],
81
- tempOptions,
82
- newOption,
83
- maxShowOption = 10,
84
-
85
- register,
86
- unregister,
87
- onChange,
88
- onFocus,
89
- onBlur,
90
-
91
- className = "",
92
- }: SelectProps) {
93
- const [inputShowValue, setInputShowValue] = useState<string | ReactNode>("");
94
- const [keydown, setKeydown] = useState(false);
95
- const [useTemp, setUseTemp] = useState(true);
96
- const [dataOptions, setDataOptions] = useState<SelectOptionProps[]>([]);
97
- const [filteredOptions, setFilteredOptions] = useState<SelectOptionProps[]>([]);
98
- const [loadingOption, setLoadingOption] = useState(false);
99
- const [activeOption, setActiveOption] = useState(0);
100
- const [showOption, setShowOption] = useState(false);
101
- const [keyword, setKeyword] = useState("");
102
- const [keywordSearch] = useLazySearch(keyword);
103
-
104
-
105
- // =========================>
106
- // ## Initial
107
- // =========================>
108
- const inputHandler = useInputHandler(name, value, validations, register, unregister, false)
109
- const randomId = useInputRandomId()
110
-
111
-
112
- // =========================>
113
- // ## Invalid handler
114
- // =========================>
115
- const [invalidMessage] = useValidation(inputHandler.value, validations, invalid, inputHandler.idle);
116
-
117
-
118
- // =========================>
119
- // ## change value handler
120
- // =========================>
121
- 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("");
129
- }
130
- }, [value, dataOptions]);
131
-
132
-
133
- // =========================>
134
- // ## options handler
135
- // =========================>
136
- useEffect(() => {
137
- options?.length && setDataOptions([...options, ...includedOptions].filter((op: SelectOptionProps) => !exceptOptions?.includes(op.value)));
138
- }, [options]);
139
-
140
-
141
- const filterOption = (e: any) => {
142
- if (dataOptions?.length) {
143
- let newFilteredOptions: SelectOptionProps[] = [];
144
-
145
- if (searchable && !serverSearchable) {
146
- if (e.target.value) {
147
- newFilteredOptions = dataOptions.filter((Option) => (Option.label as string)?.toLowerCase().indexOf(e.target.value.toLowerCase()) > -1).slice(0, maxShowOption);
148
- } else {
149
- newFilteredOptions = dataOptions.slice(0, maxShowOption);
150
- }
151
- } else {
152
- newFilteredOptions = dataOptions;
153
- }
154
-
155
- setActiveOption(-1);
156
- setFilteredOptions(newFilteredOptions);
157
- setShowOption(true);
158
- }
159
- };
160
-
161
-
162
- const onKeyDownOption = (e: any) => {
163
- if (dataOptions?.length) {
164
- if (e.keyCode === 13) {
165
- const resultValue = filteredOptions?.at(activeOption);
166
- setActiveOption(-1);
167
- setFilteredOptions([]);
168
- setShowOption(false);
169
- if (!multiple) {
170
- setInputShowValue(resultValue?.label || inputShowValue);
171
- inputHandler.setValue(resultValue?.value || inputShowValue);
172
- serverSearchable && setKeyword((resultValue?.label as string) || keyword);
173
- } else {
174
- if (resultValue?.value) {
175
- searchable ? setInputShowValue(resultValue.label) : searchable && setInputShowValue("");
176
- serverSearchable && setKeyword(resultValue.label as string);
177
-
178
- const values: string[] = Array.isArray(inputHandler.value) ? Array().concat(inputHandler.value)?.filter((val: string | number) => val != resultValue?.value) : [];
179
-
180
- if (values.find((val) => val == resultValue?.value)) {
181
- inputHandler.setValue(values);
182
- } else {
183
- inputHandler.setValue([...Array().concat(values), resultValue.value]);
184
- }
185
- }
186
- }
187
- e.preventDefault();
188
- } else if (e.keyCode === 38) {
189
- if (activeOption === 0) return;
190
- setActiveOption(activeOption - 1);
191
- } else if (e.keyCode === 40) {
192
- if (activeOption + 1 >= (filteredOptions?.length || 0)) return;
193
- setActiveOption(activeOption + 1);
194
- }
195
- }
196
- };
197
-
198
- const fetchOptions = async () => {
199
- setLoadingOption(true);
200
-
201
- const serverControl = {
202
- ...serverOptionControl,
203
- params: serverSearchable ? { search: keywordSearch, ...(serverOptionControl?.params || {}) } : (serverOptionControl?.params || {}),
204
- headers: { "X-Option": 1 }
205
- };
206
-
207
- const getCacheOptions = await cavity.get(serverOptionControl?.cacheName || `option_${serverOptionControl?.path}`)
208
- const cacheOptions = (getCacheOptions?.data || []) as SelectOptionProps[];
209
-
210
- if (cacheOptions?.length) {
211
- setDataOptions(
212
- [...cacheOptions, ...includedOptions].filter(
213
- (op: SelectOptionProps) => !exceptOptions?.includes(op.value)
214
- )
215
- );
216
- setLoadingOption(false);
217
- } else {
218
- const mutateOptions = await api(serverControl || {});
219
- setDataOptions(
220
- [...(mutateOptions?.data?.data || []), ...(includedOptions || [])].filter(
221
- (op: SelectOptionProps) => !exceptOptions?.includes(op.value)
222
- )
223
- );
224
- setShowOption(true);
225
-
226
- if(serverOptionControl?.cacheName != false) {
227
- cavity.set({
228
- key: serverOptionControl?.cacheName || `option_${serverOptionControl?.path}`,
229
- data: mutateOptions?.data,
230
- expired: 5,
231
- });
232
- }
233
- setLoadingOption(false);
234
- }
235
- };
236
-
237
- const fetchIdbOptions = async () => {
238
- setLoadingOption(true);
239
-
240
- if (idbOptionControl?.store) {
241
- const idb = registry.get("idb");
242
- if (!idb) {
243
- throw new Error("IndexedDB (IDB) extension is not installed.");
244
- }
245
- const getIdbOptions = await (await idb.query(idbOptionControl?.store)).get()
246
-
247
- const rows = getIdbOptions.map((row: Record<string,any>) => {
248
- const value = row[idbOptionControl.valueKey] || row["id"];
249
- const label = row[idbOptionControl.labelKey] || row["id"];
250
-
251
- return {
252
- label,
253
- value,
254
- ...row,
255
- }
256
- })
257
-
258
- setDataOptions(rows);
259
- setLoadingOption(false);
260
- }
261
- };
262
-
263
- useEffect(() => {
264
- if (!serverSearchable) {
265
- if (serverOptionControl?.path || serverOptionControl?.url) {
266
- fetchOptions();
267
- } else if (idbOptionControl?.store) {
268
- fetchIdbOptions();
269
- } else {
270
- !options && setDataOptions([]);
271
- }
272
- }
273
-
274
- }, [serverOptionControl?.path, serverOptionControl?.url]);
275
-
276
- useEffect(() => {
277
- if (serverSearchable) {
278
- if (serverOptionControl?.path || serverOptionControl?.url) {
279
- fetchOptions();
280
- } else {
281
- !options && setDataOptions([]);
282
- }
283
- }
284
- }, [keywordSearch, serverOptionControl?.path, serverOptionControl?.url]);
285
-
286
- return (
287
- <>
288
- <div className="relative flex flex-col gap-y-0.5">
289
- <label
290
- htmlFor={randomId}
291
- className={cn(
292
- "input-label",
293
- pcn<CT>(className, "label"),
294
- disabled && "opacity-50",
295
- disabled && pcn<CT>(className, "label", "disabled"),
296
- inputHandler.focus && "text-primary",
297
- inputHandler.focus && pcn<CT>(className, "label", "focus"),
298
- invalidMessage && "text-danger",
299
- invalidMessage && pcn<CT>(className, "label", "focus")
300
- )}
301
- >
302
- {label}
303
- {validations && validation.hasRules(validations, "required") && <span className="text-danger ml-1">*</span>}
304
- </label>
305
-
306
- {tip && (
307
- <small
308
- className={cn(
309
- "input-tip",
310
- pcn<CT>(className, "tip"),
311
- disabled && "opacity-60",
312
- disabled && pcn<CT>(className, "tip", "disabled")
313
- )}
314
- >{tip}</small>
315
- )}
316
-
317
- <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
- />
323
- <input
324
- type="text"
325
- readOnly={!searchable}
326
- id={randomId}
327
- placeholder={!inputHandler.value || (Array.isArray(inputHandler.value) && !inputHandler.value.length) ? placeholder : ""}
328
- disabled={disabled}
329
- className={cn(
330
- "input cursor-pointer",
331
- leftIcon && "pl-12",
332
- rightIcon && "pr-12",
333
- pcn<CT>(className, "input"),
334
- invalidMessage && "input-error",
335
- invalidMessage && pcn<CT>(className, "input", "error")
336
- )}
337
- value={(useTemp && tempOptions ? tempOptions.at(0)?.label : serverSearchable ? keyword : inputShowValue) as string}
338
- onChange={(e) => {
339
- setUseTemp(false);
340
- searchable && setInputShowValue(e.target.value);
341
- serverSearchable && setKeyword(e.target.value);
342
- inputHandler.setIdle(false);
343
- dataOptions?.length && filterOption(e);
344
- }}
345
- onFocus={(e) => {
346
- setUseTemp(false);
347
- inputHandler.setFocus(true);
348
- onFocus?.();
349
- dataOptions?.length && filterOption(e);
350
- searchable && e.target.select();
351
- }}
352
- onBlur={(e) => {
353
- setUseTemp(false);
354
- const value = e.target.value;
355
- const valueOption = dataOptions?.find((option) => (option.label as string)?.toLowerCase() == value?.toLowerCase());
356
-
357
- if (!keydown) {
358
- if (!multiple) {
359
- setTimeout(() => {
360
- if (valueOption?.value) {
361
- setInputShowValue(valueOption.label);
362
- inputHandler.setValue(valueOption.value);
363
- serverSearchable && setKeyword(valueOption.label as string);
364
- onChange?.(valueOption.value, valueOption);
365
- } else {
366
- setInputShowValue("");
367
- serverSearchable && setKeyword("");
368
- inputHandler.setValue("");
369
- onChange?.("");
370
- }
371
- }, 140);
372
- } else {
373
- setInputShowValue("");
374
- serverSearchable && setKeyword("");
375
- onChange?.("");
376
- }
377
- }
378
-
379
- setTimeout(() => {
380
- inputHandler.setFocus(false);
381
- }, 100);
382
-
383
- onBlur?.();
384
- }}
385
- onKeyDown={(e) => {
386
- dataOptions?.length && onKeyDownOption(e);
387
- }}
388
- autoComplete="off"
389
- autoFocus={autoFocus}
390
- />
391
-
392
-
393
- {(multiple && !searchable || (searchable && !inputHandler.focus)) && (
394
- <div
395
- className={`absolute top-1/2 -translate-y-1/2 overflow-x-auto py-1.5 input-scroll ${leftIcon ? "ml-[2.5rem]" : "ml-2"}`}
396
- style={{ maxWidth: `calc(100% - ${leftIcon ? "5.2rem" : "3.2rem"})` }}
397
- >
398
- <div className={`input-values-container`}>
399
- {multiple && typeof inputHandler.value != "string" && Array().concat(inputHandler.value)?.map((item, key) => {
400
- return (
401
- <div key={key} className={`input-values-item`}>
402
- <span className="">{dataOptions?.find((option) => option.value == item)?.label}</span>
403
- <FontAwesomeIcon
404
- icon={faTimes}
405
- className={`input-values-delete`}
406
- onClick={() => {
407
- const values = Array().concat(inputHandler.value);
408
- const index = values.findIndex((val: string | number) => val == item);
409
-
410
- inputHandler.setValue(values.filter((_, val) => val != index));
411
-
412
- if (!values.filter((_, val) => val != index)?.length) {
413
- setInputShowValue("");
414
- serverSearchable && setKeyword("");
415
- onChange?.("");
416
- }
417
- }}
418
- />
419
- </div>
420
- );
421
- })}
422
- </div>
423
- </div>
424
- )}
425
-
426
-
427
- {leftIcon && (
428
- <FontAwesomeIcon
429
- className={cn(
430
- "left-4 input-icon ",
431
- pcn<CT>(className, "icon"),
432
- disabled && "opacity-60",
433
- disabled && pcn<CT>(className, "icon", "disabled"),
434
- inputHandler.focus && "text-primary",
435
- inputHandler.focus && pcn<CT>(className, "icon", "focus")
436
- )}
437
- icon={leftIcon}
438
- />
439
- )}
440
-
441
- {!multiple && clearable && inputHandler.value && (
442
- <div
443
- className={cn(
444
- "right-12 input-icon cursor-pointer hover:text-danger",
445
- disabled && "opacity-60 pointer-events-none",
446
- disabled && pcn<CT>(className, "icon", "disabled")
447
- )}
448
- onClick={() => {
449
- setInputShowValue("");
450
- inputHandler.setValue("");
451
- onChange?.("");
452
- }}
453
- >
454
- <FontAwesomeIcon icon={faTimes} />
455
- </div>
456
- )}
457
-
458
- <label
459
- htmlFor={randomId}
460
- className={cn(
461
- "right-4 input-icon cursor-pointer hover:text-primary",
462
- disabled && "opacity-60 pointer-events-none",
463
- disabled && pcn<CT>(className, "icon", "disabled")
464
- )}
465
- >
466
- <FontAwesomeIcon icon={faChevronDown} />
467
- </label>
468
- </div>
469
-
470
- {!!dataOptions?.length && showOption && !loadingOption && !!filteredOptions?.length && (
471
- <div>
472
- <ul className={`input-suggest-container scroll-sm ${inputHandler.focus ? "opacity-100 scale-y-100" : "opacity-0 scale-y-0"}`}>
473
- {(newOption ? [newOption, ...filteredOptions] : filteredOptions ).map((option, key) => {
474
- const selected = !!((typeof inputHandler.value == "string" || typeof inputHandler.value == "number") && inputHandler.value == option.value) ||
475
- (Array.isArray(inputHandler.value) && Array().concat(inputHandler.value).find((val: string | number) => val == option.value));
476
-
477
- return (
478
- <li
479
- className={`
480
- cursor-pointer hover:bg-light-primary
481
- input-suggest
482
- ${(key == activeOption || selected) && "bg-light-primary text-primary"}
483
- `}
484
- key={key}
485
- onMouseDown={() => {
486
- setKeydown(true);
487
- setTimeout(() => inputHandler.setFocus(true), 110);
488
- }}
489
- onMouseUp={() => {
490
- setKeydown(false);
491
- setActiveOption(key);
492
- setFilteredOptions([]);
493
- setShowOption(false);
494
-
495
- if (!multiple) {
496
- setInputShowValue(option.label);
497
- serverSearchable && setKeyword(option.label as string);
498
- inputHandler.setValue(option.value);
499
- onChange?.(option.value, option);
500
- } else {
501
- const values: string[] | number[] = Array.isArray(inputHandler.value)
502
- ? Array().concat(inputHandler.value).filter((val) => val != option.value)
503
- : [];
504
-
505
- setInputShowValue("");
506
- serverSearchable && setKeyword("");
507
-
508
- if (
509
- Array.isArray(inputHandler.value) && Array().concat(inputHandler.value).find((val) => val == option.value)
510
- ) {
511
- inputHandler.setValue(values);
512
- onChange?.(values);
513
- } else {
514
- inputHandler.setValue([...Array().concat(values), option.value ]);
515
- onChange?.([...Array().concat(values), option.value]);
516
- }
517
- }
518
- setTimeout(() => inputHandler.setFocus(false), 120);
519
- }}
520
- >
521
- {selected && (
522
- <FontAwesomeIcon
523
- icon={faCheck}
524
- className="mr-2 text-sm"
525
- />
526
- )}
527
- {option.label}
528
- </li>
529
- );
530
- })}
531
- </ul>
532
- </div>
533
- )}
534
-
535
- {invalidMessage && (
536
- <small className={cn("input-error-message", pcn<CT>(className, "error"))}>{invalidMessage}</small>
537
- )}
538
- </div>
539
- </>
540
- );
541
- }
1
+ "use client"
2
+
3
+ import { ReactNode, useEffect, useState } from "react";
4
+ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
5
+ import { faCheck, faChevronDown, faTimes,} from "@fortawesome/free-solid-svg-icons";
6
+ import { api, ApiType, cavity, cn, pcn, registry, useInputHandler, useInputRandomId, useLazySearch, useValidation, validation, ValidationRules,} from "@utils";
7
+
8
+
9
+
10
+ type CT = "label" | "tip" | "error" | "input" | "icon" | "suggest" | "suggest-item";
11
+
12
+ export interface SelectOptionProps {
13
+ label : string | ReactNode;
14
+ value : string | number;
15
+ searchable ?: string[];
16
+ customLabel ?: ReactNode;
17
+ };
18
+
19
+ export interface SelectProps {
20
+ name : string;
21
+ label ?: string;
22
+ placeholder ?: string;
23
+ tip ?: string | ReactNode;
24
+ leftIcon ?: any;
25
+ rightIcon ?: any;
26
+
27
+ value ?: string | number | (string | number)[];
28
+ invalid ?: string;
29
+ disabled ?: boolean;
30
+ validations ?: ValidationRules;
31
+ multiple ?: boolean;
32
+ autoFocus ?: boolean;
33
+ clearable ?: boolean;
34
+
35
+ options ?: SelectOptionProps[];
36
+ searchable ?: boolean;
37
+ serverOptionControl ?: ApiType & { cacheName?: string | false };
38
+ idbOptionControl ?: { store: string, labelKey: string, valueKey: string };
39
+ serverSearchable ?: boolean;
40
+ includedOptions ?: SelectOptionProps[];
41
+ exceptOptions ?: (string | number)[];
42
+ tempOptions ?: SelectOptionProps[];
43
+ newOption ?: SelectOptionProps;
44
+ maxShowOption ?: number;
45
+
46
+ onChange ?: (value: string | number | (string | number)[], data?: any) => any;
47
+ register ?: (name: string, validations?: ValidationRules) => void;
48
+ unregister ?: (name: string) => void;
49
+ onFocus ?: () => void;
50
+ onBlur ?: () => void;
51
+
52
+ /** Use custom class with: "label::", "tip::", "error::", "icon::", "suggest::", "suggest-item::". */
53
+ className ?: string;
54
+ }
55
+
56
+
57
+
58
+ export function SelectComponent({
59
+ name,
60
+ label,
61
+ placeholder,
62
+ tip,
63
+ leftIcon,
64
+ rightIcon,
65
+
66
+ value,
67
+ invalid,
68
+ disabled,
69
+ validations,
70
+ multiple,
71
+ autoFocus,
72
+ clearable,
73
+
74
+ options = [],
75
+ searchable,
76
+ serverOptionControl,
77
+ idbOptionControl,
78
+ serverSearchable,
79
+ includedOptions = [],
80
+ exceptOptions = [],
81
+ tempOptions,
82
+ newOption,
83
+ maxShowOption = 10,
84
+
85
+ register,
86
+ unregister,
87
+ onChange,
88
+ onFocus,
89
+ onBlur,
90
+
91
+ className = "",
92
+ }: SelectProps) {
93
+ const [inputShowValue, setInputShowValue] = useState<string | ReactNode>("");
94
+ const [keydown, setKeydown] = useState(false);
95
+ const [useTemp, setUseTemp] = useState(true);
96
+ const [dataOptions, setDataOptions] = useState<SelectOptionProps[]>([]);
97
+ const [filteredOptions, setFilteredOptions] = useState<SelectOptionProps[]>([]);
98
+ const [loadingOption, setLoadingOption] = useState(false);
99
+ const [activeOption, setActiveOption] = useState(0);
100
+ const [showOption, setShowOption] = useState(false);
101
+ const [keyword, setKeyword] = useState("");
102
+ const [keywordSearch] = useLazySearch(keyword);
103
+
104
+
105
+ // =========================>
106
+ // ## Initial
107
+ // =========================>
108
+ const inputHandler = useInputHandler(name, value, validations, register, unregister, false)
109
+ const randomId = useInputRandomId()
110
+
111
+
112
+ // =========================>
113
+ // ## Invalid handler
114
+ // =========================>
115
+ const [invalidMessage] = useValidation(inputHandler.value, validations, invalid, inputHandler.idle);
116
+
117
+
118
+ // =========================>
119
+ // ## change value handler
120
+ // =========================>
121
+ 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("");
129
+ }
130
+ }, [value, dataOptions]);
131
+
132
+
133
+ // =========================>
134
+ // ## options handler
135
+ // =========================>
136
+ useEffect(() => {
137
+ options?.length && setDataOptions([...options, ...includedOptions].filter((op: SelectOptionProps) => !exceptOptions?.includes(op.value)));
138
+ }, [options]);
139
+
140
+
141
+ const filterOption = (e: any) => {
142
+ if (dataOptions?.length) {
143
+ let newFilteredOptions: SelectOptionProps[] = [];
144
+
145
+ if (searchable && !serverSearchable) {
146
+ if (e.target.value) {
147
+ newFilteredOptions = dataOptions.filter((Option) => (Option.label as string)?.toLowerCase().indexOf(e.target.value.toLowerCase()) > -1).slice(0, maxShowOption);
148
+ } else {
149
+ newFilteredOptions = dataOptions.slice(0, maxShowOption);
150
+ }
151
+ } else {
152
+ newFilteredOptions = dataOptions;
153
+ }
154
+
155
+ setActiveOption(-1);
156
+ setFilteredOptions(newFilteredOptions);
157
+ setShowOption(true);
158
+ }
159
+ };
160
+
161
+
162
+ const onKeyDownOption = (e: any) => {
163
+ if (dataOptions?.length) {
164
+ if (e.keyCode === 13) {
165
+ const resultValue = filteredOptions?.at(activeOption);
166
+ setActiveOption(-1);
167
+ setFilteredOptions([]);
168
+ setShowOption(false);
169
+ if (!multiple) {
170
+ setInputShowValue(resultValue?.label || inputShowValue);
171
+ inputHandler.setValue(resultValue?.value || inputShowValue);
172
+ serverSearchable && setKeyword((resultValue?.label as string) || keyword);
173
+ } else {
174
+ if (resultValue?.value) {
175
+ searchable ? setInputShowValue(resultValue.label) : searchable && setInputShowValue("");
176
+ serverSearchable && setKeyword(resultValue.label as string);
177
+
178
+ const values: string[] = Array.isArray(inputHandler.value) ? Array().concat(inputHandler.value)?.filter((val: string | number) => val != resultValue?.value) : [];
179
+
180
+ if (values.find((val) => val == resultValue?.value)) {
181
+ inputHandler.setValue(values);
182
+ } else {
183
+ inputHandler.setValue([...Array().concat(values), resultValue.value]);
184
+ }
185
+ }
186
+ }
187
+ e.preventDefault();
188
+ } else if (e.keyCode === 38) {
189
+ if (activeOption === 0) return;
190
+ setActiveOption(activeOption - 1);
191
+ } else if (e.keyCode === 40) {
192
+ if (activeOption + 1 >= (filteredOptions?.length || 0)) return;
193
+ setActiveOption(activeOption + 1);
194
+ }
195
+ }
196
+ };
197
+
198
+ const fetchOptions = async () => {
199
+ setLoadingOption(true);
200
+
201
+ const serverControl = {
202
+ ...serverOptionControl,
203
+ params: serverSearchable ? { search: keywordSearch, ...(serverOptionControl?.params || {}) } : (serverOptionControl?.params || {}),
204
+ headers: { "X-Option": 1 }
205
+ };
206
+
207
+ const getCacheOptions = await cavity.get(serverOptionControl?.cacheName || `option_${serverOptionControl?.path}`)
208
+ const cacheOptions = (getCacheOptions?.data || []) as SelectOptionProps[];
209
+
210
+ if (cacheOptions?.length) {
211
+ setDataOptions(
212
+ [...cacheOptions, ...includedOptions].filter(
213
+ (op: SelectOptionProps) => !exceptOptions?.includes(op.value)
214
+ )
215
+ );
216
+ setLoadingOption(false);
217
+ } else {
218
+ const mutateOptions = await api(serverControl || {});
219
+ setDataOptions(
220
+ [...(mutateOptions?.data?.data || []), ...(includedOptions || [])].filter(
221
+ (op: SelectOptionProps) => !exceptOptions?.includes(op.value)
222
+ )
223
+ );
224
+ setShowOption(true);
225
+
226
+ if(serverOptionControl?.cacheName != false) {
227
+ cavity.set({
228
+ key: serverOptionControl?.cacheName || `option_${serverOptionControl?.path}`,
229
+ data: mutateOptions?.data,
230
+ expired: 5,
231
+ });
232
+ }
233
+ setLoadingOption(false);
234
+ }
235
+ };
236
+
237
+ const fetchIdbOptions = async () => {
238
+ setLoadingOption(true);
239
+
240
+ if (idbOptionControl?.store) {
241
+ const idb = registry.get("idb");
242
+ if (!idb) {
243
+ throw new Error("IndexedDB (IDB) extension is not installed.");
244
+ }
245
+ const getIdbOptions = await (await idb.query(idbOptionControl?.store)).get()
246
+
247
+ const rows = getIdbOptions.map((row: Record<string,any>) => {
248
+ const value = row[idbOptionControl.valueKey] || row["id"];
249
+ const label = row[idbOptionControl.labelKey] || row["id"];
250
+
251
+ return {
252
+ label,
253
+ value,
254
+ ...row,
255
+ }
256
+ })
257
+
258
+ setDataOptions(rows);
259
+ setLoadingOption(false);
260
+ }
261
+ };
262
+
263
+ useEffect(() => {
264
+ if (!serverSearchable) {
265
+ if (serverOptionControl?.path || serverOptionControl?.url) {
266
+ fetchOptions();
267
+ } else if (idbOptionControl?.store) {
268
+ fetchIdbOptions();
269
+ } else {
270
+ !options && setDataOptions([]);
271
+ }
272
+ }
273
+
274
+ }, [serverOptionControl?.path, serverOptionControl?.url]);
275
+
276
+ useEffect(() => {
277
+ if (serverSearchable) {
278
+ if (serverOptionControl?.path || serverOptionControl?.url) {
279
+ fetchOptions();
280
+ } else {
281
+ !options && setDataOptions([]);
282
+ }
283
+ }
284
+ }, [keywordSearch, serverOptionControl?.path, serverOptionControl?.url]);
285
+
286
+ return (
287
+ <>
288
+ <div className="relative flex flex-col gap-y-0.5">
289
+ <label
290
+ htmlFor={randomId}
291
+ className={cn(
292
+ "input-label",
293
+ pcn<CT>(className, "label"),
294
+ disabled && "opacity-50",
295
+ disabled && pcn<CT>(className, "label", "disabled"),
296
+ inputHandler.focus && "text-primary",
297
+ inputHandler.focus && pcn<CT>(className, "label", "focus"),
298
+ invalidMessage && "text-danger",
299
+ invalidMessage && pcn<CT>(className, "label", "focus")
300
+ )}
301
+ >
302
+ {label}
303
+ {validations && validation.hasRules(validations, "required") && <span className="text-danger ml-1">*</span>}
304
+ </label>
305
+
306
+ {tip && (
307
+ <small
308
+ className={cn(
309
+ "input-tip",
310
+ pcn<CT>(className, "tip"),
311
+ disabled && "opacity-60",
312
+ disabled && pcn<CT>(className, "tip", "disabled")
313
+ )}
314
+ >{tip}</small>
315
+ )}
316
+
317
+ <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
+ />
323
+ <input
324
+ type="text"
325
+ readOnly={!searchable}
326
+ id={randomId}
327
+ placeholder={!inputHandler.value || (Array.isArray(inputHandler.value) && !inputHandler.value.length) ? placeholder : ""}
328
+ disabled={disabled}
329
+ className={cn(
330
+ "input cursor-pointer",
331
+ leftIcon && "pl-12",
332
+ rightIcon && "pr-12",
333
+ pcn<CT>(className, "input"),
334
+ invalidMessage && "input-error",
335
+ invalidMessage && pcn<CT>(className, "input", "error")
336
+ )}
337
+ value={(useTemp && tempOptions ? tempOptions.at(0)?.label : serverSearchable ? keyword : inputShowValue) as string}
338
+ onChange={(e) => {
339
+ setUseTemp(false);
340
+ searchable && setInputShowValue(e.target.value);
341
+ serverSearchable && setKeyword(e.target.value);
342
+ inputHandler.setIdle(false);
343
+ dataOptions?.length && filterOption(e);
344
+ }}
345
+ onFocus={(e) => {
346
+ setUseTemp(false);
347
+ inputHandler.setFocus(true);
348
+ onFocus?.();
349
+ dataOptions?.length && filterOption(e);
350
+ searchable && e.target.select();
351
+ }}
352
+ onBlur={(e) => {
353
+ setUseTemp(false);
354
+ const value = e.target.value;
355
+ const valueOption = dataOptions?.find((option) => (option.label as string)?.toLowerCase() == value?.toLowerCase());
356
+
357
+ if (!keydown) {
358
+ if (!multiple) {
359
+ setTimeout(() => {
360
+ if (valueOption?.value) {
361
+ setInputShowValue(valueOption.label);
362
+ inputHandler.setValue(valueOption.value);
363
+ serverSearchable && setKeyword(valueOption.label as string);
364
+ onChange?.(valueOption.value, valueOption);
365
+ } else {
366
+ setInputShowValue("");
367
+ serverSearchable && setKeyword("");
368
+ inputHandler.setValue("");
369
+ onChange?.("");
370
+ }
371
+ }, 140);
372
+ } else {
373
+ setInputShowValue("");
374
+ serverSearchable && setKeyword("");
375
+ onChange?.("");
376
+ }
377
+ }
378
+
379
+ setTimeout(() => {
380
+ inputHandler.setFocus(false);
381
+ }, 100);
382
+
383
+ onBlur?.();
384
+ }}
385
+ onKeyDown={(e) => {
386
+ dataOptions?.length && onKeyDownOption(e);
387
+ }}
388
+ autoComplete="off"
389
+ autoFocus={autoFocus}
390
+ />
391
+
392
+
393
+ {(multiple && !searchable || (searchable && !inputHandler.focus)) && (
394
+ <div
395
+ className={`absolute top-1/2 -translate-y-1/2 overflow-x-auto py-1.5 input-scroll ${leftIcon ? "ml-[2.5rem]" : "ml-2"}`}
396
+ style={{ maxWidth: `calc(100% - ${leftIcon ? "5.2rem" : "3.2rem"})` }}
397
+ >
398
+ <div className={`input-values-container`}>
399
+ {multiple && typeof inputHandler.value != "string" && Array().concat(inputHandler.value)?.map((item, key) => {
400
+ return (
401
+ <div key={key} className={`input-values-item`}>
402
+ <span className="">{dataOptions?.find((option) => option.value == item)?.label}</span>
403
+ <FontAwesomeIcon
404
+ icon={faTimes}
405
+ className={`input-values-delete`}
406
+ onClick={() => {
407
+ const values = Array().concat(inputHandler.value);
408
+ const index = values.findIndex((val: string | number) => val == item);
409
+
410
+ inputHandler.setValue(values.filter((_, val) => val != index));
411
+
412
+ if (!values.filter((_, val) => val != index)?.length) {
413
+ setInputShowValue("");
414
+ serverSearchable && setKeyword("");
415
+ onChange?.("");
416
+ }
417
+ }}
418
+ />
419
+ </div>
420
+ );
421
+ })}
422
+ </div>
423
+ </div>
424
+ )}
425
+
426
+
427
+ {leftIcon && (
428
+ <FontAwesomeIcon
429
+ className={cn(
430
+ "left-4 input-icon ",
431
+ pcn<CT>(className, "icon"),
432
+ disabled && "opacity-60",
433
+ disabled && pcn<CT>(className, "icon", "disabled"),
434
+ inputHandler.focus && "text-primary",
435
+ inputHandler.focus && pcn<CT>(className, "icon", "focus")
436
+ )}
437
+ icon={leftIcon}
438
+ />
439
+ )}
440
+
441
+ {!multiple && clearable && inputHandler.value && (
442
+ <div
443
+ className={cn(
444
+ "right-12 input-icon cursor-pointer hover:text-danger",
445
+ disabled && "opacity-60 pointer-events-none",
446
+ disabled && pcn<CT>(className, "icon", "disabled")
447
+ )}
448
+ onClick={() => {
449
+ setInputShowValue("");
450
+ inputHandler.setValue("");
451
+ onChange?.("");
452
+ }}
453
+ >
454
+ <FontAwesomeIcon icon={faTimes} />
455
+ </div>
456
+ )}
457
+
458
+ <label
459
+ htmlFor={randomId}
460
+ className={cn(
461
+ "right-4 input-icon cursor-pointer hover:text-primary",
462
+ disabled && "opacity-60 pointer-events-none",
463
+ disabled && pcn<CT>(className, "icon", "disabled")
464
+ )}
465
+ >
466
+ <FontAwesomeIcon icon={faChevronDown} />
467
+ </label>
468
+ </div>
469
+
470
+ {!!dataOptions?.length && showOption && !loadingOption && !!filteredOptions?.length && (
471
+ <div>
472
+ <ul className={`input-suggest-container scroll-sm ${inputHandler.focus ? "opacity-100 scale-y-100" : "opacity-0 scale-y-0"}`}>
473
+ {(newOption ? [newOption, ...filteredOptions] : filteredOptions ).map((option, key) => {
474
+ const selected = !!((typeof inputHandler.value == "string" || typeof inputHandler.value == "number") && inputHandler.value == option.value) ||
475
+ (Array.isArray(inputHandler.value) && Array().concat(inputHandler.value).find((val: string | number) => val == option.value));
476
+
477
+ return (
478
+ <li
479
+ className={`
480
+ cursor-pointer hover:bg-light-primary
481
+ input-suggest
482
+ ${(key == activeOption || selected) && "bg-light-primary text-primary"}
483
+ `}
484
+ key={key}
485
+ onMouseDown={() => {
486
+ setKeydown(true);
487
+ setTimeout(() => inputHandler.setFocus(true), 110);
488
+ }}
489
+ onMouseUp={() => {
490
+ setKeydown(false);
491
+ setActiveOption(key);
492
+ setFilteredOptions([]);
493
+ setShowOption(false);
494
+
495
+ if (!multiple) {
496
+ setInputShowValue(option.label);
497
+ serverSearchable && setKeyword(option.label as string);
498
+ inputHandler.setValue(option.value);
499
+ onChange?.(option.value, option);
500
+ } else {
501
+ const values: string[] | number[] = Array.isArray(inputHandler.value)
502
+ ? Array().concat(inputHandler.value).filter((val) => val != option.value)
503
+ : [];
504
+
505
+ setInputShowValue("");
506
+ serverSearchable && setKeyword("");
507
+
508
+ if (
509
+ Array.isArray(inputHandler.value) && Array().concat(inputHandler.value).find((val) => val == option.value)
510
+ ) {
511
+ inputHandler.setValue(values);
512
+ onChange?.(values);
513
+ } else {
514
+ inputHandler.setValue([...Array().concat(values), option.value ]);
515
+ onChange?.([...Array().concat(values), option.value]);
516
+ }
517
+ }
518
+ setTimeout(() => inputHandler.setFocus(false), 120);
519
+ }}
520
+ >
521
+ {selected && (
522
+ <FontAwesomeIcon
523
+ icon={faCheck}
524
+ className="mr-2 text-sm"
525
+ />
526
+ )}
527
+ {option.label}
528
+ </li>
529
+ );
530
+ })}
531
+ </ul>
532
+ </div>
533
+ )}
534
+
535
+ {invalidMessage && (
536
+ <small className={cn("input-error-message", pcn<CT>(className, "error"))}>{invalidMessage}</small>
537
+ )}
538
+ </div>
539
+ </>
540
+ );
541
+ }