@skalfa/skalfa-component 1.0.26 → 1.0.28

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.
@@ -0,0 +1,194 @@
1
+ "use client"
2
+
3
+ import { TextareaHTMLAttributes, ReactNode, Ref, useEffect } from "react";
4
+ import { Icon, type IconName } from "@skalfa/skalfa-icon";
5
+ import { cn, pcn, useInputHandler, useInputRandomId, useValidation, validation, ValidationRules } from "@utils";
6
+
7
+ type CT = "label" | "tip" | "error" | "base" | "icon";
8
+
9
+ export interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange"> {
10
+ label ?: string;
11
+ tip ?: string | ReactNode;
12
+ leftIcon ?: IconName | any;
13
+ rightIcon ?: IconName | any;
14
+
15
+ value ?: any;
16
+ invalid ?: string;
17
+
18
+ validations ?: ValidationRules;
19
+ onlyAlphabet ?: boolean;
20
+ uppercase ?: boolean;
21
+ lowercase ?: boolean;
22
+
23
+ onChange ?: (value: any) => any;
24
+ register ?: (name: string, validations?: ValidationRules) => void;
25
+ unregister ?: (name: string) => void;
26
+
27
+ ref ?: Ref<HTMLTextAreaElement>;
28
+
29
+ /** Use custom class with: "label::", "tip::", "error::", "base::", "icon::". */
30
+ className ?: string;
31
+ }
32
+
33
+ export function TextareaComponent({
34
+ label,
35
+ tip,
36
+ leftIcon,
37
+ rightIcon,
38
+ className = "",
39
+
40
+ value,
41
+ invalid,
42
+
43
+ validations,
44
+ onlyAlphabet,
45
+ uppercase,
46
+ lowercase,
47
+
48
+ register,
49
+ unregister,
50
+ onChange,
51
+
52
+ ref,
53
+ ...props
54
+ }: TextareaProps) {
55
+
56
+ // =========================>
57
+ // ## Initial
58
+ // =========================>
59
+ const inputHandler = useInputHandler(props.name, value, validations, register, false, unregister);
60
+ const randomId = useInputRandomId();
61
+
62
+ // =========================>
63
+ // ## Invalid handler
64
+ // =========================>
65
+ const [invalidMessage] = useValidation(inputHandler.value, validations, invalid, inputHandler.idle);
66
+
67
+ // =========================>
68
+ // ## Change value handler
69
+ // =========================>
70
+ useEffect(() => {
71
+ if (inputHandler.value && typeof inputHandler.value === "string") {
72
+ let newVal = onlyAlphabet ? inputHandler.value.replace(/[^A-Za-z ]+/g, "") : inputHandler.value;
73
+
74
+ if (uppercase) newVal = newVal.toUpperCase();
75
+ if (lowercase) newVal = newVal.toLowerCase();
76
+
77
+ if (validations && validation.hasRules(validations, "max")) {
78
+ newVal = newVal.slice(0, parseInt(validation.getRules(validations, "max") || "0"));
79
+ }
80
+
81
+ inputHandler.setValue(newVal);
82
+ }
83
+ }, [inputHandler.value, onlyAlphabet, uppercase, lowercase, validations]);
84
+
85
+ return (
86
+ <div className="relative flex flex-col gap-y-0.5 w-full">
87
+ {label && (
88
+ <label
89
+ htmlFor={randomId}
90
+ className={cn(
91
+ "input-label",
92
+ props.disabled && "input-label-disabled",
93
+ inputHandler.focus && "input-label-focus",
94
+ !!invalidMessage && "input-label-error",
95
+ pcn<CT>(className, "label"),
96
+ props.disabled && pcn<CT>(className, "label", "disabled"),
97
+ inputHandler.focus && pcn<CT>(className, "label", "focus"),
98
+ !!invalidMessage && pcn<CT>(className, "label", "error"),
99
+ )}
100
+ >
101
+ {label}
102
+ {validations && validation.hasRules(validations, "required") && <span className="text-danger ml-1">*</span>}
103
+ </label>
104
+ )}
105
+
106
+ {tip && (
107
+ <small
108
+ className={cn(
109
+ "input-tip",
110
+ props.disabled && "input-tip-disabled",
111
+ pcn<CT>(className, "tip"),
112
+ props.disabled && pcn<CT>(className, "tip", "disabled"),
113
+ )}
114
+ >
115
+ {tip}
116
+ </small>
117
+ )}
118
+
119
+ <div className="relative">
120
+ <textarea
121
+ {...props}
122
+ ref={ref}
123
+ id={randomId}
124
+ value={inputHandler.value ?? ""}
125
+ onChange={(e) => {
126
+ inputHandler.setValue(e.target.value);
127
+ inputHandler.setIdle(false);
128
+ if (onChange) onChange(e.target.value);
129
+ }}
130
+ onFocus={(e) => {
131
+ props.onFocus?.(e);
132
+ inputHandler.setFocus(true);
133
+ }}
134
+ onBlur={(e) => {
135
+ props.onBlur?.(e);
136
+ setTimeout(() => inputHandler.setFocus(false), 100);
137
+ }}
138
+ className={cn(
139
+ "input textarea min-h-[80px] py-2",
140
+ leftIcon && "input-with-left-icon",
141
+ rightIcon && "input-with-right-icon",
142
+ props.disabled && "input-disabled",
143
+ !!invalidMessage && "input-error",
144
+ pcn<CT>(className, "base"),
145
+ !!invalidMessage && pcn<CT>(className, "base", "error"),
146
+ )}
147
+ />
148
+
149
+ {leftIcon && (
150
+ <Icon
151
+ className={cn(
152
+ "input-icon",
153
+ "input-icon-left",
154
+ "top-3 -translate-y-0",
155
+ props.disabled && "input-icon-disabled",
156
+ inputHandler.focus && "input-icon-focus",
157
+ pcn<CT>(className, "icon"),
158
+ props.disabled && pcn<CT>(className, "icon", "disabled"),
159
+ inputHandler.focus && pcn<CT>(className, "icon", "focus"),
160
+ )}
161
+ icon={leftIcon}
162
+ />
163
+ )}
164
+
165
+ {rightIcon && (
166
+ <Icon
167
+ className={cn(
168
+ "input-icon",
169
+ "input-icon-right",
170
+ "top-3 -translate-y-0",
171
+ props.disabled && "input-icon-disabled",
172
+ inputHandler.focus && "input-icon-focus",
173
+ pcn<CT>(className, "icon"),
174
+ props.disabled && pcn<CT>(className, "icon", "disabled"),
175
+ inputHandler.focus && pcn<CT>(className, "icon", "focus"),
176
+ )}
177
+ icon={rightIcon}
178
+ />
179
+ )}
180
+ </div>
181
+
182
+ {invalidMessage && (
183
+ <small
184
+ className={cn(
185
+ "input-error-message",
186
+ pcn<CT>(className, "error"),
187
+ )}
188
+ >
189
+ {invalidMessage}
190
+ </small>
191
+ )}
192
+ </div>
193
+ );
194
+ }
@@ -29,6 +29,10 @@ import {
29
29
  InputDatetimeComponent,
30
30
  InputTimeComponent,
31
31
  InputImageComponent,
32
+ TextareaComponent,
33
+ TextareaProps,
34
+ InputContentComponent,
35
+ InputContentProps,
32
36
  } from "../";
33
37
  import { Icon } from "@skalfa/skalfa-icon";
34
38
  import { useLang } from "@skalfa/skalfa-lang";
@@ -85,6 +89,8 @@ type ConstructionMap = {
85
89
  select : SelectProps;
86
90
  "enter-password" : InputPasswordProps;
87
91
  otp : InputOtpProps;
92
+ textarea : TextareaProps;
93
+ content : InputContentProps;
88
94
  custom : formCustomConstructionProps;
89
95
  };
90
96
 
@@ -366,6 +372,8 @@ export function FormSupervisionComponent({
366
372
  "enter-password" : InputPasswordComponent,
367
373
  otp : InputOtpComponent,
368
374
  image : InputImageComponent,
375
+ textarea : TextareaComponent,
376
+ content : InputContentComponent,
369
377
  cluster : () => null,
370
378
  custom : () => null,
371
379
  };
@@ -438,7 +446,7 @@ export function FormSupervisionComponent({
438
446
  {label && <p className="input-label">{label}</p>}
439
447
  {clusterError && <small className="input-error-message">{clusterError}</small>}
440
448
  {group.map((gIndex) => (
441
- <div key={gIndex} className={cn("relative pr-8", wrap && "p-4 rounded border", className)}>
449
+ <div key={gIndex} className={cn("relative pr-8", wrap && "form-supervision-cluster-item-wrapped", className)}>
442
450
  {label && <p className="input-label">{label} {gIndex + 1}</p>}
443
451
  {tip && <small className={cn("input-tip")}>{tip}</small>}
444
452
  {(label || tip) && <div className="mb-2"></div>}
@@ -453,7 +461,7 @@ export function FormSupervisionComponent({
453
461
  paint="danger"
454
462
  variant="outline"
455
463
  size="xs"
456
- className={cn("absolute top-10 right-2 translate-x-[50%] -translate-y-[50%]", wrap && "translate-x-0 -translate-y-0 top-1 right-1")}
464
+ className={cn("form-supervision-cluster-remove-btn", wrap && "form-supervision-cluster-remove-btn-wrapped")}
457
465
  onClick={() => removeGroup(gIndex)}
458
466
  />
459
467
  )}
@@ -463,7 +471,7 @@ export function FormSupervisionComponent({
463
471
  <div>
464
472
  <ButtonComponent
465
473
  icon={"solid/plus"}
466
- label={`${l.base.add ? l.base.add() : "Add"} ${label || mapName}`}
474
+ label={`${l.base.add ? l.base.add() : "Add"} ${label || "Lainnya"}`}
467
475
  variant="outline"
468
476
  size="sm"
469
477
  onClick={addGroup}
@@ -0,0 +1,241 @@
1
+ import { useMemo } from "react";
2
+ import { cn } from "@utils";
3
+
4
+ export interface ContentWrapperProps {
5
+ content : string;
6
+ className ?: string;
7
+ }
8
+
9
+ export const COLOR_MAP: Record<string, { label: string; tw: string; css: string }> = {
10
+ normal: { label: "Normal", tw: "text-foreground", css: "var(--color-foreground, #1f2937)" },
11
+ light: { label: "Light", tw: "text-light-foreground", css: "var(--color-light-foreground, #9ca3af)" },
12
+ primary: { label: "Primary", tw: "text-primary", css: "var(--color-primary, #3b82f6)" },
13
+ secondary: { label: "Secondary", tw: "text-secondary", css: "var(--color-secondary, #8b5cf6)" },
14
+ warning: { label: "Warning", tw: "text-warning", css: "var(--color-warning, #f59e0b)" },
15
+ danger: { label: "Danger", tw: "text-danger", css: "var(--color-danger, #ef4444)" },
16
+ };
17
+
18
+ export function parseInlineFormats(text: string): string {
19
+ if (!text) return "";
20
+ let result = text;
21
+
22
+ result = result.replace(/\[color:(\w+)\](.*?)\[color\]/g, (_, color, content) => {
23
+ const colorInfo = COLOR_MAP[color];
24
+ const twClass = colorInfo?.tw || "text-foreground";
25
+ return `<span class="${twClass}" data-color="${color}">${content}</span>`;
26
+ });
27
+
28
+ result = result.replace(/\[size:(\d+)\](.*?)\[size\]/g, (_, size, content) => {
29
+ return `<span style="font-size:${size}px" data-size="${size}">${content}</span>`;
30
+ });
31
+
32
+ result = result.replace(/\[link:(.*?)\](.*?)\[link\]/g, (_, url, content) => {
33
+ return `<a href="${url}" class="text-primary underline" data-link="${url}" target="_blank" rel="noopener noreferrer">${content}</a>`;
34
+ });
35
+
36
+ result = result.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
37
+ result = result.replace(/\/\/(.*?)\/\//g, "<em>$1</em>");
38
+ result = result.replace(/__(.*?)__/g, "<u>$1</u>");
39
+ result = result.replace(/--(.*?)--/g, "<s>$1</s>");
40
+
41
+ return result;
42
+ }
43
+
44
+ export function parseContentToHtml(content: string): string {
45
+ if (!content) return "";
46
+
47
+ const lines = content.split("\n");
48
+ const htmlParts: string[] = [];
49
+ let i = 0;
50
+
51
+ while (i < lines.length) {
52
+ const line = lines[i];
53
+
54
+ if (line.trim() === "---") {
55
+ htmlParts.push('<div class="skcontent-divider" contenteditable="false"><hr /></div>');
56
+ i++;
57
+ continue;
58
+ }
59
+
60
+ const headerMatch = line.match(/^##(.+?)##$/);
61
+ if (headerMatch) {
62
+ htmlParts.push(`<h2>${parseInlineFormats(headerMatch[1])}</h2>`);
63
+ i++;
64
+ continue;
65
+ }
66
+
67
+ if (line.match(/^\[list:bullet\]/)) {
68
+ const items: string[] = [];
69
+ while (i < lines.length) {
70
+ const bm = lines[i].match(/^\[list:bullet\](.*?)\[list\]$/);
71
+ if (!bm) break;
72
+ items.push(`<li>${parseInlineFormats(bm[1])}</li>`);
73
+ i++;
74
+ }
75
+ htmlParts.push(`<ul>${items.join("")}</ul>`);
76
+ continue;
77
+ }
78
+
79
+ if (line.match(/^\[list:number\]/)) {
80
+ const items: string[] = [];
81
+ while (i < lines.length) {
82
+ const nm = lines[i].match(/^\[list:number\](.*?)\[list\]$/);
83
+ if (!nm) break;
84
+ items.push(`<li>${parseInlineFormats(nm[1])}</li>`);
85
+ i++;
86
+ }
87
+ htmlParts.push(`<ol>${items.join("")}</ol>`);
88
+ continue;
89
+ }
90
+
91
+ const alignMatch = line.match(/^\[align:(left|center|right|justify)\](.*?)\[align\]$/);
92
+ if (alignMatch) {
93
+ const alignVal = alignMatch[1];
94
+ const alignStyle = alignVal === "left" ? "text-align:left" : alignVal === "center" ? "text-align:center" : alignVal === "right" ? "text-align:right" : "text-align:justify";
95
+ htmlParts.push(`<p style="${alignStyle}" class="text-${alignVal}">${parseInlineFormats(alignMatch[2])}</p>`);
96
+ i++;
97
+ continue;
98
+ }
99
+
100
+ if (line.trim() === "") {
101
+ htmlParts.push("<p><br></p>");
102
+ i++;
103
+ continue;
104
+ }
105
+
106
+ htmlParts.push(`<p>${parseInlineFormats(line)}</p>`);
107
+ i++;
108
+ }
109
+
110
+ return htmlParts.join("");
111
+ }
112
+
113
+ export function parseHtmlToContent(html: string): string {
114
+ if (!html) return "";
115
+
116
+ const parser = new DOMParser();
117
+ const doc = parser.parseFromString(`<div>${html}</div>`, "text/html");
118
+ const root = doc.body.firstElementChild;
119
+ if (!root) return "";
120
+
121
+ const lines: string[] = [];
122
+
123
+ for (let i = 0; i < root.childNodes.length; i++) {
124
+ const node = root.childNodes[i];
125
+
126
+ if (node.nodeType === Node.TEXT_NODE) {
127
+ const text = node.textContent?.trim();
128
+ if (text) lines.push(parseNodeToCustom(node));
129
+ continue;
130
+ }
131
+
132
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
133
+ const el = node as HTMLElement;
134
+ const tag = el.tagName.toLowerCase();
135
+
136
+ if (el.classList.contains("skcontent-divider") || tag === "hr") {
137
+ lines.push("---");
138
+ continue;
139
+ }
140
+
141
+ if (tag === "h2") {
142
+ lines.push(`##${parseChildrenToCustom(el)}##`);
143
+ continue;
144
+ }
145
+
146
+ if (tag === "ul") {
147
+ for (let j = 0; j < el.children.length; j++) {
148
+ const li = el.children[j];
149
+ lines.push(`[list:bullet]${parseChildrenToCustom(li as HTMLElement)}[list]`);
150
+ }
151
+ continue;
152
+ }
153
+
154
+ if (tag === "ol") {
155
+ for (let j = 0; j < el.children.length; j++) {
156
+ const li = el.children[j];
157
+ lines.push(`[list:number]${parseChildrenToCustom(li as HTMLElement)}[list]`);
158
+ }
159
+ continue;
160
+ }
161
+
162
+ if (tag === "p" || tag === "div") {
163
+ const align = el.style.textAlign || (el.classList.contains("text-center") ? "center" : el.classList.contains("text-right") ? "right" : el.classList.contains("text-justify") ? "justify" : "");
164
+ const content = parseChildrenToCustom(el);
165
+
166
+ if (!content || content === "\n" || el.innerHTML === "<br>" || el.innerHTML === "<br/>") {
167
+ lines.push("");
168
+ continue;
169
+ }
170
+
171
+ if (align && align !== "left" && align !== "start") {
172
+ lines.push(`[align:${align}]${content}[align]`);
173
+ } else {
174
+ lines.push(content);
175
+ }
176
+ continue;
177
+ }
178
+
179
+ const fallback = parseChildrenToCustom(el);
180
+ if (fallback) lines.push(fallback);
181
+ }
182
+
183
+ return lines.join("\n");
184
+ }
185
+
186
+ function parseChildrenToCustom(el: HTMLElement): string {
187
+ let result = "";
188
+ for (let i = 0; i < el.childNodes.length; i++) {
189
+ result += parseNodeToCustom(el.childNodes[i]);
190
+ }
191
+ return result;
192
+ }
193
+
194
+ function parseNodeToCustom(node: Node): string {
195
+ if (node.nodeType === Node.TEXT_NODE) {
196
+ return (node.textContent || "").replace(/\u200B/g, "");
197
+ }
198
+
199
+ if (node.nodeType !== Node.ELEMENT_NODE) return "";
200
+
201
+ const el = node as HTMLElement;
202
+ const tag = el.tagName.toLowerCase();
203
+ const inner = parseChildrenToCustom(el);
204
+
205
+ if (tag === "strong" || tag === "b") return `**${inner}**`;
206
+ if (tag === "em" || tag === "i") return `//${inner}//`;
207
+ if (tag === "u") return `__${inner}__`;
208
+ if (tag === "s" || tag === "strike" || tag === "del") return `--${inner}--`;
209
+
210
+ if (tag === "span" && el.dataset.color) {
211
+ return `[color:${el.dataset.color}]${inner}[color]`;
212
+ }
213
+
214
+ if (tag === "span" && el.dataset.size) {
215
+ return `[size:${el.dataset.size}]${inner}[size]`;
216
+ }
217
+
218
+ if (tag === "a") {
219
+ const href = el.getAttribute("href") || el.dataset.link || "";
220
+ return `[link:${href}]${inner}[link]`;
221
+ }
222
+
223
+ if (tag === "br") return "";
224
+ if (tag === "hr") return "---";
225
+
226
+ return inner;
227
+ }
228
+
229
+ export function ContentWrapperComponent({
230
+ content,
231
+ className,
232
+ }: ContentWrapperProps) {
233
+ const renderedHtml = useMemo(() => parseContentToHtml(content), [content]);
234
+
235
+ return (
236
+ <div
237
+ className={cn("skcontent-wrapper", className)}
238
+ dangerouslySetInnerHTML={{ __html: renderedHtml }}
239
+ />
240
+ );
241
+ }