@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.
- package/dist/input/InputDate.component.d.ts +1 -0
- package/dist/input/InputDate.component.js +47 -29
- package/dist/input/InputDatetime.component.js +2 -2
- package/dist/input/InputImage.component.d.ts +2 -0
- package/dist/input/InputImage.component.js +54 -18
- package/dist/input/InputTime.component.d.ts +5 -3
- package/dist/input/InputTime.component.js +109 -28
- package/dist/input/Select.component.d.ts +7 -2
- package/dist/input/Select.component.js +91 -38
- package/package.json +1 -1
- package/src/input/InputDate.component.tsx +95 -55
- package/src/input/InputDatetime.component.tsx +2 -0
- package/src/input/InputImage.component.tsx +71 -21
- package/src/input/InputTime.component.tsx +218 -58
- package/src/input/Select.component.tsx +111 -53
|
@@ -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
|
|
273
|
-
aspect
|
|
274
|
-
|
|
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.
|
|
318
|
-
const scaledH = img.
|
|
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
|
|
380
|
-
const pctx = preview.getContext("2d")!;
|
|
392
|
+
const scaleFactor = 1 / zoom;
|
|
381
393
|
|
|
382
|
-
|
|
383
|
-
|
|
394
|
+
const scaledW = img.naturalWidth * zoom;
|
|
395
|
+
const scaledH = img.naturalHeight * zoom;
|
|
384
396
|
|
|
385
|
-
const
|
|
386
|
-
const
|
|
397
|
+
const cropDisplayX = (scaledW - CROP_W) / 2 - pos.x;
|
|
398
|
+
const cropDisplayY = (scaledH - CROP_SIZE) / 2 - pos.y;
|
|
387
399
|
|
|
388
|
-
const
|
|
389
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
|
16
|
-
tip
|
|
17
|
-
leftIcon
|
|
18
|
-
rightIcon
|
|
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
|
|
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
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
246
|
-
|
|
301
|
+
emitChange(newH, newM, newS);
|
|
302
|
+
};
|
|
247
303
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
}, [])
|
|
322
|
+
return slots;
|
|
323
|
+
}, []);
|
|
258
324
|
|
|
259
325
|
return (
|
|
260
|
-
<div className="w-full
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
);
|