@skalfa/skalfa-component 1.0.52 → 1.0.54

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.
@@ -54,6 +54,7 @@ export const InputImageComponent: React.FC<InputImageProps> = ({
54
54
  const [preview, setPreview] = useState("");
55
55
  const [drag, setDrag] = useState(false);
56
56
  const [cropSrc, setCropSrc] = useState<string | null>(null);
57
+ const [cropFile, setCropFile] = useState<File | null>(null);
57
58
  const [openCrop, setOpenCrop] = useState(false);
58
59
 
59
60
  const inputHandler = useInputHandler(name, value, validations, register, true, unregister);
@@ -77,6 +78,7 @@ export const InputImageComponent: React.FC<InputImageProps> = ({
77
78
  }, [value]);
78
79
 
79
80
  const openCropper = (file: File) => {
81
+ setCropFile(file);
80
82
  const reader = new FileReader();
81
83
  reader.onload = () => {
82
84
  setCropSrc(reader.result as string);
@@ -113,11 +115,15 @@ export const InputImageComponent: React.FC<InputImageProps> = ({
113
115
 
114
116
  const onCropCancel = () => {
115
117
  setOpenCrop(false);
118
+ setCropFile(null);
119
+ setCropSrc(null);
116
120
  inputRef.current!.value = "";
117
121
  };
118
122
 
119
123
  const remove = () => {
120
124
  setPreview("");
125
+ setCropFile(null);
126
+ setCropSrc(null);
121
127
  onChange?.(null);
122
128
  inputRef.current && (inputRef.current.value = "");
123
129
  };
@@ -243,6 +249,8 @@ export const InputImageComponent: React.FC<InputImageProps> = ({
243
249
  <CanvasCropper
244
250
  src={cropSrc}
245
251
  aspect={eval(currentAspect.replace(":", "/"))}
252
+ fileName={cropFile?.name}
253
+ fileType={cropFile?.type}
246
254
  onDone={onCropDone}
247
255
  />
248
256
  </div>
@@ -255,6 +263,8 @@ export const InputImageComponent: React.FC<InputImageProps> = ({
255
263
  <CanvasCropper
256
264
  src={cropSrc}
257
265
  aspect={eval(currentAspect.replace(":", "/"))}
266
+ fileName={cropFile?.name}
267
+ fileType={cropFile?.type}
258
268
  onDone={onCropDone}
259
269
  />
260
270
  </div>
@@ -269,18 +279,21 @@ export const InputImageComponent: React.FC<InputImageProps> = ({
269
279
 
270
280
 
271
281
  interface CropperProps {
272
- src : string;
273
- aspect : number;
274
- onDone ?: (file: File) => void;
282
+ src : string;
283
+ aspect : number;
284
+ fileName ?: string;
285
+ fileType ?: string;
286
+ onDone ?: (file: File) => void;
275
287
  }
276
288
 
277
289
  export const CanvasCropper: React.FC<CropperProps> = ({
278
290
  src,
279
291
  aspect,
292
+ fileName,
293
+ fileType,
280
294
  onDone,
281
295
  }) => {
282
296
  const canvasRef = useRef<HTMLCanvasElement>(null);
283
- const previewRef = useRef<HTMLCanvasElement>(null);
284
297
  const pinchDistRef = useRef(0);
285
298
  const pinchStartZoomRef = useRef(1);
286
299
 
@@ -314,8 +327,8 @@ export const CanvasCropper: React.FC<CropperProps> = ({
314
327
 
315
328
  ctx.clearRect(0, 0, canvas.width, canvas.height);
316
329
 
317
- const scaledW = img.width * zoom;
318
- const scaledH = img.height * zoom;
330
+ const scaledW = img.naturalWidth * zoom;
331
+ const scaledH = img.naturalHeight * zoom;
319
332
 
320
333
  const x = pos.x - scaledW / 2 + CROP_W / 2;
321
334
  const y = pos.y - scaledH / 2 + CROP_SIZE / 2;
@@ -376,24 +389,63 @@ export const CanvasCropper: React.FC<CropperProps> = ({
376
389
  const performCrop = () => {
377
390
  if (!img) return;
378
391
 
379
- const preview = previewRef.current!;
380
- const pctx = preview.getContext("2d")!;
392
+ const scaleFactor = 1 / zoom;
381
393
 
382
- preview.width = CROP_W;
383
- preview.height = CROP_SIZE;
394
+ const scaledW = img.naturalWidth * zoom;
395
+ const scaledH = img.naturalHeight * zoom;
384
396
 
385
- const scaledW = img.width * zoom;
386
- const scaledH = img.height * zoom;
397
+ const cropDisplayX = (scaledW - CROP_W) / 2 - pos.x;
398
+ const cropDisplayY = (scaledH - CROP_SIZE) / 2 - pos.y;
387
399
 
388
- const x = pos.x - scaledW / 2 + CROP_W / 2;
389
- const y = pos.y - scaledH / 2 + CROP_SIZE / 2;
400
+ const sourceX = cropDisplayX * scaleFactor;
401
+ const sourceY = cropDisplayY * scaleFactor;
402
+ const sourceW = CROP_W * scaleFactor;
403
+ const sourceH = CROP_SIZE * scaleFactor;
390
404
 
391
- pctx.drawImage(img, x, y, scaledW, scaledH);
405
+ const sx = Math.max(0, Math.min(img.naturalWidth - 1, sourceX));
406
+ const sy = Math.max(0, Math.min(img.naturalHeight - 1, sourceY));
407
+ const sw = Math.min(sourceW, img.naturalWidth - sx);
408
+ const sh = Math.min(sourceH, img.naturalHeight - sy);
392
409
 
393
- preview.toBlob((blob) => {
394
- const file = new File([blob!], "cropped.png", { type: "image/png" });
395
- onDone?.(file);
396
- });
410
+ let targetW = Math.round(sw);
411
+ let targetH = Math.round(sh);
412
+
413
+ const MAX_DIMENSION = 2560;
414
+ if (targetW > MAX_DIMENSION || targetH > MAX_DIMENSION) {
415
+ if (targetW >= targetH) {
416
+ targetH = Math.round((targetH * MAX_DIMENSION) / targetW);
417
+ targetW = MAX_DIMENSION;
418
+ } else {
419
+ targetW = Math.round((targetW * MAX_DIMENSION) / targetH);
420
+ targetH = MAX_DIMENSION;
421
+ }
422
+ }
423
+
424
+ const outputCanvas = document.createElement("canvas");
425
+ outputCanvas.width = targetW;
426
+ outputCanvas.height = targetH;
427
+
428
+ const octx = outputCanvas.getContext("2d");
429
+ if (!octx) return;
430
+
431
+ octx.imageSmoothingEnabled = true;
432
+ octx.imageSmoothingQuality = "high";
433
+
434
+ octx.drawImage(img, sx, sy, sw, sh, 0, 0, targetW, targetH);
435
+
436
+ const type = fileType || "image/jpeg";
437
+ const name = fileName || (type === "image/png" ? "cropped.png" : "cropped.jpg");
438
+ const quality = type === "image/png" ? undefined : 0.92;
439
+
440
+ outputCanvas.toBlob(
441
+ (blob) => {
442
+ if (!blob) return;
443
+ const file = new File([blob], name, { type, lastModified: Date.now() });
444
+ onDone?.(file);
445
+ },
446
+ type,
447
+ quality
448
+ );
397
449
  };
398
450
 
399
451
 
@@ -557,8 +609,6 @@ export const CanvasCropper: React.FC<CropperProps> = ({
557
609
  block
558
610
  />
559
611
  </div>
560
-
561
- <canvas ref={previewRef} className="hidden" />
562
612
  </div>
563
613
  );
564
614
  };
@@ -1,6 +1,6 @@
1
1
  "use client"
2
2
 
3
- import { FC, InputHTMLAttributes, ReactNode, useEffect, useMemo, useState } from "react";
3
+ import { FC, InputHTMLAttributes, ReactNode, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { Icon } from "@skalfa/skalfa-icon";
5
5
  import { cn, pcn, useInputHandler, useInputRandomId, useResponsive, useValidation, validation, ValidationRules } from "@utils";
6
6
  import { BottomSheetComponent } from "../modal/BottomSheet.component";
@@ -12,21 +12,22 @@ import { OutsideClickComponent } from "../wrap/OutsideClick.component";
12
12
  type CT = "label" | "tip" | "error" | "input"| "icon";
13
13
 
14
14
  export interface InputTimeProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "onChange"> {
15
- label ?: string;
16
- tip ?: string | ReactNode;
17
- leftIcon ?: any;
18
- rightIcon ?: any;
15
+ label ?: string;
16
+ tip ?: string | ReactNode;
17
+ leftIcon ?: any;
18
+ rightIcon ?: any;
19
19
 
20
20
  value ?: string;
21
21
  invalid ?: string;
22
22
  validations ?: ValidationRules;
23
+ showSeconds ?: boolean;
23
24
 
24
- onChange ?: (value: string) => any;
25
+ onChange ?: (value: string) => any;
25
26
  register ?: (name: string, validations?: ValidationRules) => void;
26
27
  unregister ?: (name: string) => void;
27
28
 
28
29
  /** Use custom class with: "label::", "tip::", "error::", "icon::". */
29
- className ?: string;
30
+ className ?: string;
30
31
  }
31
32
 
32
33
 
@@ -40,6 +41,7 @@ export function InputTimeComponent({
40
41
  value,
41
42
  invalid,
42
43
  validations,
44
+ showSeconds,
43
45
 
44
46
  register,
45
47
  unregister,
@@ -145,8 +147,11 @@ export function InputTimeComponent({
145
147
  {!isSm && inputHandler.focus && (
146
148
  <div className="input-time-picker-popover">
147
149
  <InputTimePickerComponent
150
+ value={inputHandler.value}
151
+ showSeconds={showSeconds}
148
152
  onChange={(time) => {
149
153
  inputHandler.setValue(time);
154
+ inputHandler.setIdle(false);
150
155
  onChange?.(time);
151
156
  }}
152
157
  />
@@ -178,8 +183,11 @@ export function InputTimeComponent({
178
183
  >
179
184
  <div className="p-4">
180
185
  <InputTimePickerComponent
186
+ value={inputHandler.value}
187
+ showSeconds={showSeconds}
181
188
  onChange={(time) => {
182
189
  inputHandler.setValue(time);
190
+ inputHandler.setIdle(false);
183
191
  onChange?.(time);
184
192
  }}
185
193
  />
@@ -192,7 +200,9 @@ export function InputTimeComponent({
192
200
 
193
201
 
194
202
 
195
- interface InputTimePickerProps {
203
+ export interface InputTimePickerProps {
204
+ value ?: string;
205
+ showSeconds ?: boolean;
196
206
  onChange ?: (time: string) => void;
197
207
  rightElement ?: ReactNode;
198
208
  };
@@ -201,79 +211,229 @@ interface InputTimePickerProps {
201
211
 
202
212
 
203
213
  export const InputTimePickerComponent: FC<InputTimePickerProps> = ({
214
+ value,
215
+ showSeconds = false,
204
216
  onChange,
205
217
  rightElement,
206
218
  }) => {
207
- const [hour, setHour] = useState(0);
208
- const [minute, setMinute] = useState(0);
209
- const [second, setSecond] = useState(0);
219
+ const parsed = useMemo(() => {
220
+ if (!value || typeof value !== "string") {
221
+ return { h: 0, m: 0, s: 0, hasValue: false };
222
+ }
223
+ const parts = value.split(":").map((p) => parseInt(p, 10));
224
+ return {
225
+ h: isNaN(parts[0]) ? 0 : Math.min(23, Math.max(0, parts[0])),
226
+ m: isNaN(parts[1]) ? 0 : Math.min(59, Math.max(0, parts[1])),
227
+ s: isNaN(parts[2]) ? 0 : Math.min(59, Math.max(0, parts[2])),
228
+ hasValue: true,
229
+ };
230
+ }, [value]);
231
+
232
+ const [hour, setHour] = useState(parsed.h);
233
+ const [minute, setMinute] = useState(parsed.m);
234
+ const [second, setSecond] = useState(parsed.s);
235
+
236
+ const shouldShowSeconds = showSeconds || (typeof value === "string" && value.split(":").length >= 3 && value.split(":")[2] !== "");
237
+
238
+ useEffect(() => {
239
+ if (parsed.hasValue) {
240
+ setHour(parsed.h);
241
+ setMinute(parsed.m);
242
+ setSecond(parsed.s);
243
+ }
244
+ }, [parsed]);
210
245
 
211
246
  const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, "0"));
212
247
  const minutes = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, "0"));
213
248
  const seconds = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, "0"));
214
249
 
215
- const handleSelect = (type: "h" | "m" | "s", val: string) => {
216
- if (type === "h") setHour(Number(val));
217
- if (type === "m") setMinute(Number(val));
218
- if (type === "s") setSecond(Number(val));
250
+ const presetContainerRef = useRef<HTMLDivElement | null>(null);
251
+ const activePresetRef = useRef<HTMLDivElement | null>(null);
252
+
253
+ const hourContainerRef = useRef<HTMLDivElement | null>(null);
254
+ const activeHourRef = useRef<HTMLDivElement | null>(null);
255
+
256
+ const minuteContainerRef = useRef<HTMLDivElement | null>(null);
257
+ const activeMinuteRef = useRef<HTMLDivElement | null>(null);
258
+
259
+ const secondContainerRef = useRef<HTMLDivElement | null>(null);
260
+ const activeSecondRef = useRef<HTMLDivElement | null>(null);
261
+
262
+ const scrollToActive = (container: HTMLDivElement | null, active: HTMLDivElement | null) => {
263
+ if (container && active) {
264
+ container.scrollTop = active.offsetTop - container.offsetTop - (container.clientHeight / 2) + (active.clientHeight / 2);
265
+ }
219
266
  };
220
267
 
221
268
  useEffect(() => {
222
- const formatted = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:${String(second).padStart(2, "0")}`;
269
+ scrollToActive(hourContainerRef.current, activeHourRef.current);
270
+ scrollToActive(minuteContainerRef.current, activeMinuteRef.current);
271
+ if (shouldShowSeconds) {
272
+ scrollToActive(secondContainerRef.current, activeSecondRef.current);
273
+ }
274
+ scrollToActive(presetContainerRef.current, activePresetRef.current);
275
+ }, []);
276
+
277
+ const emitChange = (newH: number, newM: number, newS: number) => {
278
+ const hh = String(newH).padStart(2, "0");
279
+ const mm = String(newM).padStart(2, "0");
280
+ const ss = String(newS).padStart(2, "0");
281
+ const formatted = shouldShowSeconds ? `${hh}:${mm}:${ss}` : `${hh}:${mm}`;
223
282
  onChange?.(formatted);
224
- }, [hour, minute, second]);
225
-
226
- const renderColumn = (items: string[], type: "h" | "m" | "s", activeValue: number) => (
227
- <div className="flex-1 overflow-y-auto text-center input-scroll">
228
- {items.map((item) => {
229
- const active = Number(item) === activeValue;
283
+ };
230
284
 
231
- return (
232
- <div
233
- key={item}
234
- onClick={() => handleSelect(type, item)}
235
- className={cn(
236
- "p-2 cursor-pointer rounded text-sm",
237
- active ? "bg-primary text-background font-semibold" : "hover:bg-light-primary"
238
- )}
239
- >{item}</div>
240
- );
241
- })}
242
- </div>
243
- );
285
+ const handleSelect = (type: "h" | "m" | "s", val: number) => {
286
+ let newH = hour;
287
+ let newM = minute;
288
+ let newS = second;
289
+
290
+ if (type === "h") {
291
+ newH = val;
292
+ setHour(val);
293
+ } else if (type === "m") {
294
+ newM = val;
295
+ setMinute(val);
296
+ } else if (type === "s") {
297
+ newS = val;
298
+ setSecond(val);
299
+ }
244
300
 
245
- const timeSlots = useMemo(() => {
246
- const newTimeSlots = [];
301
+ emitChange(newH, newM, newS);
302
+ };
247
303
 
248
- for (let i = 0; i <= 24 * 60; i += 30) {
249
- const hours = Math.floor(i / 60);
250
- const minutes = i % 60;
251
- const label = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
304
+ const handlePresetSelect = (presetStr: string) => {
305
+ const parts = presetStr.split(":").map(Number);
306
+ const newH = parts[0] ?? 0;
307
+ const newM = parts[1] ?? 0;
308
+ const newS = 0;
309
+ setHour(newH);
310
+ setMinute(newM);
311
+ setSecond(newS);
312
+ emitChange(newH, newM, newS);
313
+ };
252
314
 
253
- newTimeSlots.push(label);
315
+ const timeSlots = useMemo(() => {
316
+ const slots: string[] = [];
317
+ for (let i = 0; i < 24 * 60; i += 30) {
318
+ const h = Math.floor(i / 60);
319
+ const m = i % 60;
320
+ slots.push(`${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`);
254
321
  }
255
-
256
- return newTimeSlots;
257
- }, [])
322
+ return slots;
323
+ }, []);
258
324
 
259
325
  return (
260
- <div className="w-full max-h-[260] flex gap-2">
261
- <div className="w-1/3 overflow-y-auto bg-background rounded-[6px] input-scroll">
262
- {timeSlots.map((time) => (
263
- <div
264
- key={time}
265
- className="p-2 text-sm rounded cursor-pointer hover:bg-light-primary text-center"
266
- onClick={() => onChange?.(time)}
267
- >{time}</div>
268
- ))}
326
+ <div className="w-full flex gap-2 h-[260px] select-none text-xs">
327
+ {/* 1. Presets Column */}
328
+ <div className="w-20 shrink-0 h-full flex flex-col border-r border-stroke pr-1.5">
329
+ <div className="text-[11px] font-semibold text-light-foreground text-center py-1 mb-1 border-b border-stroke">
330
+ Preset
331
+ </div>
332
+ <div className="flex-1 overflow-y-auto input-scroll flex flex-col gap-0.5" ref={presetContainerRef}>
333
+ {timeSlots.map((time) => {
334
+ const isPresetActive = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}` === time;
335
+ return (
336
+ <div
337
+ key={time}
338
+ ref={isPresetActive ? activePresetRef : null}
339
+ className={cn(
340
+ "py-1.5 px-1 text-center font-medium rounded-md cursor-pointer transition-colors",
341
+ isPresetActive
342
+ ? "bg-primary text-white font-semibold"
343
+ : "hover:bg-light-primary text-foreground"
344
+ )}
345
+ onClick={() => handlePresetSelect(time)}
346
+ >
347
+ {time}
348
+ </div>
349
+ );
350
+ })}
351
+ </div>
269
352
  </div>
270
353
 
271
- <div className="w-2/3 flex gap-2">
272
- {renderColumn(hours, "h", hour)}
273
- {renderColumn(minutes, "m", minute)}
274
- {renderColumn(seconds, "s", second)}
354
+ {/* 2. Hour Column */}
355
+ <div className="flex-1 h-full flex flex-col">
356
+ <div className="text-[11px] font-semibold text-light-foreground text-center py-1 mb-1 border-b border-stroke">
357
+ Jam
358
+ </div>
359
+ <div className="flex-1 overflow-y-auto input-scroll flex flex-col gap-0.5" ref={hourContainerRef}>
360
+ {hours.map((item) => {
361
+ const active = Number(item) === hour;
362
+ return (
363
+ <div
364
+ key={item}
365
+ ref={active ? activeHourRef : null}
366
+ onClick={() => handleSelect("h", Number(item))}
367
+ className={cn(
368
+ "py-1.5 text-center font-medium rounded-md cursor-pointer transition-colors",
369
+ active
370
+ ? "bg-primary text-white font-semibold"
371
+ : "hover:bg-light-primary text-foreground"
372
+ )}
373
+ >
374
+ {item}
375
+ </div>
376
+ );
377
+ })}
378
+ </div>
379
+ </div>
380
+
381
+ {/* 3. Minute Column */}
382
+ <div className="flex-1 h-full flex flex-col">
383
+ <div className="text-[11px] font-semibold text-light-foreground text-center py-1 mb-1 border-b border-stroke">
384
+ Menit
385
+ </div>
386
+ <div className="flex-1 overflow-y-auto input-scroll flex flex-col gap-0.5" ref={minuteContainerRef}>
387
+ {minutes.map((item) => {
388
+ const active = Number(item) === minute;
389
+ return (
390
+ <div
391
+ key={item}
392
+ ref={active ? activeMinuteRef : null}
393
+ onClick={() => handleSelect("m", Number(item))}
394
+ className={cn(
395
+ "py-1.5 text-center font-medium rounded-md cursor-pointer transition-colors",
396
+ active
397
+ ? "bg-primary text-white font-semibold"
398
+ : "hover:bg-light-primary text-foreground"
399
+ )}
400
+ >
401
+ {item}
402
+ </div>
403
+ );
404
+ })}
405
+ </div>
275
406
  </div>
276
407
 
408
+ {/* 4. Second Column (Optional) */}
409
+ {shouldShowSeconds && (
410
+ <div className="flex-1 h-full flex flex-col">
411
+ <div className="text-[11px] font-semibold text-light-foreground text-center py-1 mb-1 border-b border-stroke">
412
+ Detik
413
+ </div>
414
+ <div className="flex-1 overflow-y-auto input-scroll flex flex-col gap-0.5" ref={secondContainerRef}>
415
+ {seconds.map((item) => {
416
+ const active = Number(item) === second;
417
+ return (
418
+ <div
419
+ key={item}
420
+ ref={active ? activeSecondRef : null}
421
+ onClick={() => handleSelect("s", Number(item))}
422
+ className={cn(
423
+ "py-1.5 text-center font-medium rounded-md cursor-pointer transition-colors",
424
+ active
425
+ ? "bg-primary text-white font-semibold"
426
+ : "hover:bg-light-primary text-foreground"
427
+ )}
428
+ >
429
+ {item}
430
+ </div>
431
+ );
432
+ })}
433
+ </div>
434
+ </div>
435
+ )}
436
+
277
437
  {rightElement && <div>{rightElement}</div>}
278
438
  </div>
279
439
  );