cynx-ui 1.2.48 → 1.3.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.
- package/components/TimePicker.jsx +1070 -0
- package/index.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { Clock, ChevronDown, ChevronUp, Check, X, Globe, List, Sliders } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
const STYLES = `
|
|
6
|
+
@keyframes cynxTimePickerPopIn {
|
|
7
|
+
0% {
|
|
8
|
+
opacity: 0;
|
|
9
|
+
transform: translateY(-8px) scale(0.96);
|
|
10
|
+
}
|
|
11
|
+
100% {
|
|
12
|
+
opacity: 1;
|
|
13
|
+
transform: translateY(0) scale(1);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@keyframes cynxTimePickerPopOut {
|
|
18
|
+
0% {
|
|
19
|
+
opacity: 1;
|
|
20
|
+
transform: translateY(0) scale(1);
|
|
21
|
+
}
|
|
22
|
+
100% {
|
|
23
|
+
opacity: 0;
|
|
24
|
+
transform: translateY(-8px) scale(0.96);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.cynx-timepicker-drop {
|
|
29
|
+
animation: cynxTimePickerPopIn 0.18s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
30
|
+
will-change: opacity, transform;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.cynx-timepicker-drop.closing {
|
|
34
|
+
animation: cynxTimePickerPopOut 0.14s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.cynx-timepicker-cell {
|
|
38
|
+
transition: background-color 0.12s ease, color 0.12s ease;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.cynx-timepicker-cell:hover {
|
|
42
|
+
background-color: var(--grey-1, var(--surface-hover));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.cynx-timepicker-stepper-btn {
|
|
46
|
+
display: flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
justify-content: center;
|
|
49
|
+
width: 100%;
|
|
50
|
+
height: 28px;
|
|
51
|
+
border-radius: var(--rads, 6px);
|
|
52
|
+
border: 1px solid var(--border-light, var(--border));
|
|
53
|
+
background: var(--grey-1, var(--surface-hover));
|
|
54
|
+
color: var(--fg, var(--t1));
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.cynx-timepicker-stepper-btn:hover {
|
|
60
|
+
background: var(--surface, var(--bg));
|
|
61
|
+
border-color: var(--accent, var(--blue));
|
|
62
|
+
color: var(--accent, var(--blue));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.cynx-timepicker-stepper-btn:active {
|
|
66
|
+
background: var(--accent-bg, rgba(37,99,235,0.08));
|
|
67
|
+
}
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
const STYLE_ID = 'cynx-timepicker-styles';
|
|
71
|
+
|
|
72
|
+
function ensureStyles() {
|
|
73
|
+
if (typeof document === 'undefined') return;
|
|
74
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
75
|
+
const tag = document.createElement('style');
|
|
76
|
+
tag.id = STYLE_ID;
|
|
77
|
+
tag.textContent = STYLES;
|
|
78
|
+
document.head.appendChild(tag);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const COMMON_TIMEZONES = [
|
|
82
|
+
{ value: 'system', label: 'Системное время (Local)' },
|
|
83
|
+
{ value: 'Asia/Tashkent', label: 'Ташкент (UTC+5)' },
|
|
84
|
+
{ value: 'America/New_York', label: 'Нью-Йорк (UTC-5)' },
|
|
85
|
+
{ value: 'Europe/London', label: 'Лондон (UTC+0)' },
|
|
86
|
+
{ value: 'Asia/Tokyo', label: 'Токио (UTC+9)' },
|
|
87
|
+
{ value: 'UTC', label: 'UTC' },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
function pad(n) {
|
|
91
|
+
return String(n).padStart(2, '0');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function to12h(h24) {
|
|
95
|
+
const h = parseInt(h24, 10);
|
|
96
|
+
if (isNaN(h)) return { h12: '12', ampm: 'AM' };
|
|
97
|
+
const ampm = h >= 12 ? 'PM' : 'AM';
|
|
98
|
+
let h12 = h % 12;
|
|
99
|
+
if (h12 === 0) h12 = 12;
|
|
100
|
+
return { h12: pad(h12), ampm };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function to24h(h12, ampm) {
|
|
104
|
+
let h = parseInt(h12, 10);
|
|
105
|
+
if (isNaN(h)) return '00';
|
|
106
|
+
if (ampm === 'PM' && h < 12) h += 12;
|
|
107
|
+
if (ampm === 'AM' && h === 12) h = 0;
|
|
108
|
+
return pad(h);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseTime(val, is12h, showSeconds) {
|
|
112
|
+
if (!val || typeof val !== 'string') return { h24: '00', h12: '12', m: '00', s: '00', ampm: 'AM' };
|
|
113
|
+
|
|
114
|
+
let str = val.trim();
|
|
115
|
+
let ampm = 'AM';
|
|
116
|
+
if (/pm/i.test(str)) { ampm = 'PM'; str = str.replace(/pm/i, '').trim(); }
|
|
117
|
+
else if (/am/i.test(str)) { ampm = 'AM'; str = str.replace(/am/i, '').trim(); }
|
|
118
|
+
|
|
119
|
+
let p0 = 0, p1 = 0, p2 = 0;
|
|
120
|
+
|
|
121
|
+
if (str.includes(':')) {
|
|
122
|
+
const parts = str.split(':').map(p => p.trim());
|
|
123
|
+
p0 = parseInt(parts[0], 10) || 0;
|
|
124
|
+
p1 = parseInt(parts[1], 10) || 0;
|
|
125
|
+
p2 = parseInt(parts[2], 10) || 0;
|
|
126
|
+
} else if (/^\d{3,6}$/.test(str)) {
|
|
127
|
+
p0 = parseInt(str.slice(0, 2), 10) || 0;
|
|
128
|
+
p1 = parseInt(str.slice(2, 4), 10) || 0;
|
|
129
|
+
p2 = parseInt(str.slice(4, 6), 10) || 0;
|
|
130
|
+
} else {
|
|
131
|
+
p0 = parseInt(str, 10) || 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let h24 = '00';
|
|
135
|
+
let h12 = '12';
|
|
136
|
+
|
|
137
|
+
if (is12h) {
|
|
138
|
+
if (p0 > 12) {
|
|
139
|
+
h24 = pad(Math.min(23, Math.max(0, p0)));
|
|
140
|
+
const conv = to12h(h24);
|
|
141
|
+
h12 = conv.h12;
|
|
142
|
+
ampm = conv.ampm;
|
|
143
|
+
} else {
|
|
144
|
+
let hNum = Math.min(12, Math.max(1, p0));
|
|
145
|
+
h12 = pad(hNum);
|
|
146
|
+
h24 = to24h(h12, ampm);
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
let hNum = Math.min(23, Math.max(0, p0));
|
|
150
|
+
h24 = pad(hNum);
|
|
151
|
+
const conv = to12h(h24);
|
|
152
|
+
h12 = conv.h12;
|
|
153
|
+
ampm = conv.ampm;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const m = pad(Math.min(59, Math.max(0, p1)));
|
|
157
|
+
const s = pad(Math.min(59, Math.max(0, p2)));
|
|
158
|
+
|
|
159
|
+
return { h24, h12, m, s, ampm };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function TimePicker({
|
|
163
|
+
value = '',
|
|
164
|
+
onChange,
|
|
165
|
+
variant = 'list', // 'stepper' (+/- buttons) | 'list' (full 00..59 scrollable list)
|
|
166
|
+
showVariantToggle = false, // Default false: clean list mode by default!
|
|
167
|
+
format = '24h', // '24h' | '12h'
|
|
168
|
+
showSeconds = false,
|
|
169
|
+
showIcon = false, // Default false: clock icon hidden by default, set showIcon={true} to display
|
|
170
|
+
hideIcon = false, // Legacy prop alias for showIcon = false
|
|
171
|
+
showFormatToggle = false, // Minimal default: false
|
|
172
|
+
showPresets = false, // Minimal default: false
|
|
173
|
+
showTimezoneSelect = false, // Minimal default: false
|
|
174
|
+
showPlaceholder = false, // Minimal default: false
|
|
175
|
+
timezone = 'Asia/Tashkent',
|
|
176
|
+
label,
|
|
177
|
+
placeholder,
|
|
178
|
+
size = 'sm', // 'xs' | 'sm' | 'md'
|
|
179
|
+
disabled = false,
|
|
180
|
+
readOnly = false,
|
|
181
|
+
style = {},
|
|
182
|
+
className = '',
|
|
183
|
+
}) {
|
|
184
|
+
const displayClockIcon = showIcon && !hideIcon;
|
|
185
|
+
const [open, setOpen] = useState(false);
|
|
186
|
+
const [rendered, setRendered] = useState(false);
|
|
187
|
+
const [closing, setClosing] = useState(false);
|
|
188
|
+
|
|
189
|
+
const [activeVariant, setActiveVariant] = useState(variant);
|
|
190
|
+
const [mode, setMode] = useState(format);
|
|
191
|
+
const [tz, setTz] = useState(timezone);
|
|
192
|
+
const [showTzPicker, setShowTzPicker] = useState(false);
|
|
193
|
+
|
|
194
|
+
const btnRef = useRef(null);
|
|
195
|
+
const dropRef = useRef(null);
|
|
196
|
+
const isTypingRef = useRef(false);
|
|
197
|
+
|
|
198
|
+
const [pos, setPos] = useState({ top: 0, left: 0, width: 230 });
|
|
199
|
+
|
|
200
|
+
ensureStyles();
|
|
201
|
+
|
|
202
|
+
useEffect(() => { setActiveVariant(variant); }, [variant]);
|
|
203
|
+
useEffect(() => { setMode(format); }, [format]);
|
|
204
|
+
useEffect(() => { setTz(timezone); }, [timezone]);
|
|
205
|
+
|
|
206
|
+
const openPicker = useCallback(() => {
|
|
207
|
+
if (disabled || readOnly) return;
|
|
208
|
+
setRendered(true);
|
|
209
|
+
setClosing(false);
|
|
210
|
+
setOpen(true);
|
|
211
|
+
}, [disabled, readOnly]);
|
|
212
|
+
|
|
213
|
+
const closePicker = useCallback(() => {
|
|
214
|
+
if (!open) return;
|
|
215
|
+
setClosing(true);
|
|
216
|
+
setOpen(false);
|
|
217
|
+
setShowTzPicker(false);
|
|
218
|
+
setTimeout(() => {
|
|
219
|
+
setRendered(false);
|
|
220
|
+
setClosing(false);
|
|
221
|
+
}, 140);
|
|
222
|
+
}, [open]);
|
|
223
|
+
|
|
224
|
+
const togglePicker = useCallback(() => {
|
|
225
|
+
if (open) closePicker();
|
|
226
|
+
else openPicker();
|
|
227
|
+
}, [open, closePicker, openPicker]);
|
|
228
|
+
|
|
229
|
+
const formatOutput = useCallback((parsedObj, useMode = mode) => {
|
|
230
|
+
const { h24, h12, m, s, ampm } = parsedObj;
|
|
231
|
+
if (useMode === '12h') {
|
|
232
|
+
return showSeconds ? `${h12}:${m}:${s} ${ampm}` : `${h12}:${m} ${ampm}`;
|
|
233
|
+
}
|
|
234
|
+
return showSeconds ? `${h24}:${m}:${s}` : `${h24}:${m}`;
|
|
235
|
+
}, [mode, showSeconds]);
|
|
236
|
+
|
|
237
|
+
const parsed = parseTime(value, mode === '12h', showSeconds);
|
|
238
|
+
const [inputValue, setInputValue] = useState(() => value ? formatOutput(parsed) : '');
|
|
239
|
+
|
|
240
|
+
// Keep inputValue in sync with props ONLY when user is NOT actively typing!
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
if (!isTypingRef.current) {
|
|
243
|
+
if (value) {
|
|
244
|
+
const p = parseTime(value, mode === '12h', showSeconds);
|
|
245
|
+
setInputValue(formatOutput(p));
|
|
246
|
+
} else {
|
|
247
|
+
setInputValue('');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}, [value, mode, showSeconds, formatOutput]);
|
|
251
|
+
|
|
252
|
+
const calcPos = useCallback(() => {
|
|
253
|
+
if (!btnRef.current) return;
|
|
254
|
+
const r = btnRef.current.getBoundingClientRect();
|
|
255
|
+
const dropW = mode === '12h' ? 290 : 230;
|
|
256
|
+
const dropH = dropRef.current ? dropRef.current.offsetHeight : 230;
|
|
257
|
+
|
|
258
|
+
let left = r.left;
|
|
259
|
+
if (left + dropW > window.innerWidth - 12) {
|
|
260
|
+
left = Math.max(12, r.right - dropW);
|
|
261
|
+
}
|
|
262
|
+
if (left < 12) left = 12;
|
|
263
|
+
|
|
264
|
+
let top = r.bottom + 4;
|
|
265
|
+
if (top + dropH > window.innerHeight - 12 && r.top - 4 - dropH > 12) {
|
|
266
|
+
top = r.top - 4 - dropH;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
setPos({ top, left, width: Math.max(r.width, dropW) });
|
|
270
|
+
}, [mode]);
|
|
271
|
+
|
|
272
|
+
useLayoutEffect(() => {
|
|
273
|
+
if (!rendered) return;
|
|
274
|
+
calcPos();
|
|
275
|
+
}, [rendered, calcPos]);
|
|
276
|
+
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
if (!rendered || closing) return;
|
|
279
|
+
function onDown(e) {
|
|
280
|
+
const inBtn = btnRef.current && btnRef.current.contains(e.target);
|
|
281
|
+
const inDrop = dropRef.current && dropRef.current.contains(e.target);
|
|
282
|
+
if (!inBtn && !inDrop) {
|
|
283
|
+
closePicker();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const timer = setTimeout(() => {
|
|
287
|
+
document.addEventListener('mousedown', onDown, true);
|
|
288
|
+
}, 0);
|
|
289
|
+
window.addEventListener('scroll', calcPos, true);
|
|
290
|
+
window.addEventListener('resize', calcPos);
|
|
291
|
+
return () => {
|
|
292
|
+
clearTimeout(timer);
|
|
293
|
+
document.removeEventListener('mousedown', onDown, true);
|
|
294
|
+
window.removeEventListener('scroll', calcPos, true);
|
|
295
|
+
window.removeEventListener('resize', calcPos);
|
|
296
|
+
};
|
|
297
|
+
}, [rendered, closing, calcPos, closePicker]);
|
|
298
|
+
|
|
299
|
+
const emitChange = (parsedObj, useMode = mode) => {
|
|
300
|
+
const formatted = formatOutput(parsedObj, useMode);
|
|
301
|
+
setInputValue(formatted);
|
|
302
|
+
if (onChange) {
|
|
303
|
+
onChange(formatted, {
|
|
304
|
+
hours: parseInt(parsedObj.h24, 10),
|
|
305
|
+
minutes: parseInt(parsedObj.m, 10),
|
|
306
|
+
seconds: parseInt(parsedObj.s, 10),
|
|
307
|
+
ampm: parsedObj.ampm,
|
|
308
|
+
format: useMode,
|
|
309
|
+
timezone: tz,
|
|
310
|
+
variant: activeVariant,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const handleKeyDown = (e) => {
|
|
316
|
+
if (
|
|
317
|
+
['Backspace', 'Delete', 'Tab', 'Escape', 'Enter', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key) ||
|
|
318
|
+
e.ctrlKey || e.metaKey || e.altKey
|
|
319
|
+
) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const allowed = mode === '12h' ? /^[0-9:\sAPMapm]$/ : /^[0-9:]$/;
|
|
323
|
+
if (!allowed.test(e.key)) {
|
|
324
|
+
e.preventDefault();
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const handleInputChange = (e) => {
|
|
329
|
+
let raw = e.target.value;
|
|
330
|
+
isTypingRef.current = true;
|
|
331
|
+
|
|
332
|
+
// 1. Strict filter: allow ONLY digits and colon (and AM/PM in 12h)
|
|
333
|
+
if (mode === '12h') {
|
|
334
|
+
raw = raw.replace(/[^0-9:\sAPMapm]/g, '');
|
|
335
|
+
} else {
|
|
336
|
+
raw = raw.replace(/[^0-9:]/g, '');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 2. Single-digit hour shortcut (e.g. typing "3" -> "03:")
|
|
340
|
+
if (mode === '24h' && /^[3-9]$/.test(raw)) {
|
|
341
|
+
raw = '0' + raw + ':';
|
|
342
|
+
} else if (mode === '12h' && /^[2-9]$/.test(raw) && !/pm|am/i.test(raw)) {
|
|
343
|
+
raw = '0' + raw + ':';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 3. Auto-append 1st colon after 2 hour digits + cap hour max (23 in 24h, 12 in 12h)
|
|
347
|
+
if (/^\d{2}$/.test(raw) && !inputValue.endsWith(':')) {
|
|
348
|
+
const hNum = parseInt(raw, 10);
|
|
349
|
+
if (mode === '24h' && hNum > 23) raw = '23:';
|
|
350
|
+
else if (mode === '12h' && hNum > 12) raw = '12:';
|
|
351
|
+
else raw = raw + ':';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 4. Minutes tens digit validation: first digit of minute (after 1st colon) CANNOT be > 5!
|
|
355
|
+
if (/^\d{2}:[6-9]/.test(raw)) {
|
|
356
|
+
raw = raw.replace(/^(\d{2}:)[6-9]/, '$15');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 5. Auto-append 2nd colon after 2 minute digits if showSeconds is true (e.g. "16:16" -> "16:16:")
|
|
360
|
+
if (showSeconds && /^\d{2}:\d{2}$/.test(raw) && !inputValue.endsWith(':')) {
|
|
361
|
+
const mNum = parseInt(raw.slice(3, 5), 10);
|
|
362
|
+
if (mNum > 59) raw = raw.slice(0, 3) + '59:';
|
|
363
|
+
else raw = raw + ':';
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 6. Seconds tens digit validation (if showSeconds): first digit of second CANNOT be > 5!
|
|
367
|
+
if (showSeconds && /^\d{2}:\d{2}:[6-9]/.test(raw)) {
|
|
368
|
+
raw = raw.replace(/^(\d{2}:\d{2}:)[6-9]/, '$15');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 7. Prevent 3rd minute digit when showSeconds is FALSE! (e.g. "16:166" -> "16:16")
|
|
372
|
+
if (!showSeconds && /^\d{2}:\d{3,}/.test(raw)) {
|
|
373
|
+
raw = raw.slice(0, 5);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 8. Prevent 3rd second digit when showSeconds is TRUE! (e.g. "16:16:666" -> "16:16:66")
|
|
377
|
+
if (showSeconds && /^\d{2}:\d{2}:\d{3,}/.test(raw)) {
|
|
378
|
+
raw = raw.slice(0, 8);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 9. Strict Max length capping
|
|
382
|
+
const maxLen = mode === '12h' ? (showSeconds ? 11 : 8) : (showSeconds ? 8 : 5);
|
|
383
|
+
if (raw.length > maxLen) {
|
|
384
|
+
raw = raw.slice(0, maxLen);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
e.target.value = raw;
|
|
388
|
+
setInputValue(raw);
|
|
389
|
+
|
|
390
|
+
if (!raw) {
|
|
391
|
+
if (onChange) onChange('', { hours: null, minutes: null, seconds: null, ampm: null, format: mode, timezone: tz, variant: activeVariant });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Emit change if raw matches a complete valid time pattern
|
|
396
|
+
if (/^\d{1,2}:\d{1,2}(:\d{1,2})?(\s*[ap]m)?$/i.test(raw) || /^\d{4}$/.test(raw)) {
|
|
397
|
+
const p = parseTime(raw, mode === '12h', showSeconds);
|
|
398
|
+
const formatted = formatOutput(p);
|
|
399
|
+
if (onChange) {
|
|
400
|
+
onChange(formatted, {
|
|
401
|
+
hours: parseInt(p.h24, 10),
|
|
402
|
+
minutes: parseInt(p.m, 10),
|
|
403
|
+
seconds: parseInt(p.s, 10),
|
|
404
|
+
ampm: p.ampm,
|
|
405
|
+
format: mode,
|
|
406
|
+
timezone: tz,
|
|
407
|
+
variant: activeVariant,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const handleInputBlur = () => {
|
|
414
|
+
isTypingRef.current = false;
|
|
415
|
+
if (!inputValue) return;
|
|
416
|
+
const p = parseTime(inputValue, mode === '12h', showSeconds);
|
|
417
|
+
emitChange(p);
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
// Stepper Up/Down (+/-) handlers
|
|
421
|
+
const stepHour = (delta) => {
|
|
422
|
+
isTypingRef.current = false;
|
|
423
|
+
let curH = mode === '12h' ? parseInt(parsed.h12, 10) : parseInt(parsed.h24, 10);
|
|
424
|
+
if (isNaN(curH)) curH = mode === '12h' ? 12 : 0;
|
|
425
|
+
|
|
426
|
+
if (mode === '12h') {
|
|
427
|
+
let nextH12 = curH + delta;
|
|
428
|
+
let nextAmPm = parsed.ampm;
|
|
429
|
+
if (nextH12 > 12) { nextH12 = 1; }
|
|
430
|
+
else if (nextH12 < 1) { nextH12 = 12; }
|
|
431
|
+
const h12 = pad(nextH12);
|
|
432
|
+
const h24 = to24h(h12, nextAmPm);
|
|
433
|
+
emitChange({ ...parsed, h12, h24, ampm: nextAmPm });
|
|
434
|
+
} else {
|
|
435
|
+
let nextH24 = (curH + delta + 24) % 24;
|
|
436
|
+
const h24 = pad(nextH24);
|
|
437
|
+
const { h12, ampm } = to12h(h24);
|
|
438
|
+
emitChange({ ...parsed, h24, h12, ampm });
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const stepMinute = (delta) => {
|
|
443
|
+
isTypingRef.current = false;
|
|
444
|
+
let curM = parseInt(parsed.m, 10);
|
|
445
|
+
if (isNaN(curM)) curM = 0;
|
|
446
|
+
let nextM = (curM + delta + 60) % 60;
|
|
447
|
+
const m = pad(nextM);
|
|
448
|
+
emitChange({ ...parsed, m });
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const stepSecond = (delta) => {
|
|
452
|
+
isTypingRef.current = false;
|
|
453
|
+
let curS = parseInt(parsed.s, 10);
|
|
454
|
+
if (isNaN(curS)) curS = 0;
|
|
455
|
+
let nextS = (curS + delta + 60) % 60;
|
|
456
|
+
const s = pad(nextS);
|
|
457
|
+
emitChange({ ...parsed, s });
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// List direct selection handlers
|
|
461
|
+
const selectH24 = (hNum) => {
|
|
462
|
+
isTypingRef.current = false;
|
|
463
|
+
const h24 = pad(hNum);
|
|
464
|
+
const { h12, ampm } = to12h(h24);
|
|
465
|
+
emitChange({ ...parsed, h24, h12, ampm });
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const selectH12 = (hNum12) => {
|
|
469
|
+
isTypingRef.current = false;
|
|
470
|
+
const h12 = pad(hNum12);
|
|
471
|
+
const h24 = to24h(h12, parsed.ampm);
|
|
472
|
+
emitChange({ ...parsed, h24, h12 });
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const selectMin = (mNum) => {
|
|
476
|
+
isTypingRef.current = false;
|
|
477
|
+
const m = pad(mNum);
|
|
478
|
+
emitChange({ ...parsed, m });
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const selectSec = (sNum) => {
|
|
482
|
+
isTypingRef.current = false;
|
|
483
|
+
const s = pad(sNum);
|
|
484
|
+
emitChange({ ...parsed, s });
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const toggleAmPm = (targetAmPm) => {
|
|
488
|
+
isTypingRef.current = false;
|
|
489
|
+
const ampm = targetAmPm;
|
|
490
|
+
const h24 = to24h(parsed.h12, ampm);
|
|
491
|
+
emitChange({ ...parsed, ampm, h24 });
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const switchMode = (newMode) => {
|
|
495
|
+
isTypingRef.current = false;
|
|
496
|
+
setMode(newMode);
|
|
497
|
+
emitChange(parsed, newMode);
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const clearTime = (e) => {
|
|
501
|
+
e?.stopPropagation();
|
|
502
|
+
isTypingRef.current = false;
|
|
503
|
+
setInputValue('');
|
|
504
|
+
if (onChange) onChange('', { hours: null, minutes: null, seconds: null, ampm: null, format: mode, timezone: tz, variant: activeVariant });
|
|
505
|
+
closePicker();
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const defaultPlaceholder = mode === '12h' && showPlaceholder
|
|
509
|
+
? (showSeconds ? 'hh:mm:ss AM' : 'hh:mm AM')
|
|
510
|
+
: (showSeconds ? 'HH:MM:SS' : 'HH:MM');
|
|
511
|
+
const ph = placeholder || defaultPlaceholder;
|
|
512
|
+
|
|
513
|
+
const hours24List = Array.from({ length: 24 }, (_, i) => i);
|
|
514
|
+
const hours12List = Array.from({ length: 12 }, (_, i) => i + 1);
|
|
515
|
+
const fullMinutesList = Array.from({ length: 60 }, (_, i) => i);
|
|
516
|
+
|
|
517
|
+
const presets24 = ['00:00', '07:00', '08:00', '09:00', '12:00', '18:00', '23:00'];
|
|
518
|
+
const presets12 = ['07:00 AM', '09:00 AM', '12:00 PM', '03:00 PM', '06:00 PM', '11:00 PM'];
|
|
519
|
+
|
|
520
|
+
const inputHeight = size === 'sm' ? '32px' : '38px';
|
|
521
|
+
const fontSize = size === 'sm' ? '.78rem' : '.85rem';
|
|
522
|
+
|
|
523
|
+
return (
|
|
524
|
+
<div
|
|
525
|
+
className={`cynx-timepicker-wrap ${className}`}
|
|
526
|
+
style={{ display: 'inline-flex', flexDirection: 'column', position: 'relative', width: style?.width || 'auto', boxSizing: 'border-box', minWidth: 0, ...style }}
|
|
527
|
+
>
|
|
528
|
+
{label && (
|
|
529
|
+
<label
|
|
530
|
+
style={{ fontSize: 11, fontWeight: 600, color: 'var(--t2, var(--muted))', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 4 }}
|
|
531
|
+
>
|
|
532
|
+
{label}
|
|
533
|
+
</label>
|
|
534
|
+
)}
|
|
535
|
+
|
|
536
|
+
{/* Input Trigger Field */}
|
|
537
|
+
<div ref={btnRef} style={{ position: 'relative', display: 'flex', alignItems: 'center', width: '100%', boxSizing: 'border-box', minWidth: 0 }}>
|
|
538
|
+
{displayClockIcon && (
|
|
539
|
+
<button
|
|
540
|
+
type="button"
|
|
541
|
+
onClick={togglePicker}
|
|
542
|
+
disabled={disabled || readOnly}
|
|
543
|
+
title="Выбрать время"
|
|
544
|
+
style={{
|
|
545
|
+
position: 'absolute',
|
|
546
|
+
left: 10,
|
|
547
|
+
background: 'none',
|
|
548
|
+
border: 'none',
|
|
549
|
+
cursor: (disabled || readOnly) ? 'default' : 'pointer',
|
|
550
|
+
padding: 0,
|
|
551
|
+
display: 'flex',
|
|
552
|
+
alignItems: 'center',
|
|
553
|
+
justifyContent: 'center',
|
|
554
|
+
color: open ? 'var(--accent, var(--blue))' : 'var(--muted, var(--t2))',
|
|
555
|
+
zIndex: 1,
|
|
556
|
+
transition: 'color .15s ease',
|
|
557
|
+
}}
|
|
558
|
+
>
|
|
559
|
+
<Clock size={size === 'sm' ? 14 : 16} />
|
|
560
|
+
</button>
|
|
561
|
+
)}
|
|
562
|
+
|
|
563
|
+
<input
|
|
564
|
+
type="text"
|
|
565
|
+
value={inputValue}
|
|
566
|
+
onChange={handleInputChange}
|
|
567
|
+
onKeyDown={handleKeyDown}
|
|
568
|
+
onBlur={handleInputBlur}
|
|
569
|
+
onFocus={openPicker}
|
|
570
|
+
maxLength={mode === '12h' ? (showSeconds ? 11 : 8) : (showSeconds ? 8 : 5)}
|
|
571
|
+
placeholder={ph}
|
|
572
|
+
disabled={disabled}
|
|
573
|
+
readOnly={readOnly}
|
|
574
|
+
style={{
|
|
575
|
+
display: 'flex',
|
|
576
|
+
width: '100%',
|
|
577
|
+
minWidth: 0,
|
|
578
|
+
maxWidth: '100%',
|
|
579
|
+
boxSizing: 'border-box',
|
|
580
|
+
height: inputHeight,
|
|
581
|
+
paddingLeft: displayClockIcon ? 28 : 10,
|
|
582
|
+
paddingRight: 26,
|
|
583
|
+
border: `1px solid ${open ? 'var(--accent, var(--blue))' : 'var(--border-light, var(--border))'}`,
|
|
584
|
+
borderRadius: 'var(--rads, 6px)',
|
|
585
|
+
fontSize: fontSize,
|
|
586
|
+
background: disabled ? 'var(--grey-1, var(--surface-hover))' : 'var(--bg, var(--surface))',
|
|
587
|
+
color: 'var(--fg, var(--t1, var(--primary-text)))',
|
|
588
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
|
589
|
+
letterSpacing: '0.03em',
|
|
590
|
+
fontWeight: 600,
|
|
591
|
+
fontVariantNumeric: 'tabular-nums',
|
|
592
|
+
textAlign: 'left',
|
|
593
|
+
outline: 'none',
|
|
594
|
+
boxShadow: open ? 'var(--pd, rgba(37, 99, 235, 0.12))' : 'var(--shadow, none)',
|
|
595
|
+
transition: 'all .15s cubic-bezier(.16,1,.3,1)',
|
|
596
|
+
}}
|
|
597
|
+
/>
|
|
598
|
+
|
|
599
|
+
<div style={{ position: 'absolute', right: 8, display: 'flex', alignItems: 'center' }}>
|
|
600
|
+
{inputValue && !disabled && !readOnly ? (
|
|
601
|
+
<button
|
|
602
|
+
type="button"
|
|
603
|
+
onClick={clearTime}
|
|
604
|
+
title="Очистить"
|
|
605
|
+
style={{
|
|
606
|
+
background: 'none',
|
|
607
|
+
border: 'none',
|
|
608
|
+
cursor: 'pointer',
|
|
609
|
+
color: 'var(--muted, var(--t2))',
|
|
610
|
+
padding: 2,
|
|
611
|
+
display: 'flex',
|
|
612
|
+
alignItems: 'center',
|
|
613
|
+
justifyContent: 'center',
|
|
614
|
+
transition: 'color .14s ease',
|
|
615
|
+
}}
|
|
616
|
+
onMouseEnter={e => e.currentTarget.style.color = 'var(--danger)'}
|
|
617
|
+
onMouseLeave={e => e.currentTarget.style.color = 'var(--muted, var(--t2))'}
|
|
618
|
+
>
|
|
619
|
+
<X size={13} />
|
|
620
|
+
</button>
|
|
621
|
+
) : (
|
|
622
|
+
<ChevronDown
|
|
623
|
+
size={13}
|
|
624
|
+
style={{
|
|
625
|
+
color: open ? 'var(--accent, var(--blue))' : 'var(--muted, var(--t2))',
|
|
626
|
+
transform: open ? 'rotate(180deg)' : 'none',
|
|
627
|
+
transition: 'transform .2s ease, color .15s ease',
|
|
628
|
+
pointerEvents: 'none',
|
|
629
|
+
}}
|
|
630
|
+
/>
|
|
631
|
+
)}
|
|
632
|
+
</div>
|
|
633
|
+
</div>
|
|
634
|
+
|
|
635
|
+
{/* PORTAL DROPDOWN POPUP */}
|
|
636
|
+
{rendered && createPortal(
|
|
637
|
+
<div
|
|
638
|
+
ref={dropRef}
|
|
639
|
+
className={`cynx-timepicker-drop ${closing ? 'closing' : ''}`}
|
|
640
|
+
style={{
|
|
641
|
+
position: 'fixed',
|
|
642
|
+
top: pos.top,
|
|
643
|
+
left: pos.left,
|
|
644
|
+
zIndex: 999999,
|
|
645
|
+
background: 'var(--surface, var(--bg))',
|
|
646
|
+
border: '1px solid var(--border-light, var(--border))',
|
|
647
|
+
borderRadius: 'var(--rad, 10px)',
|
|
648
|
+
boxShadow: 'var(--shadow-lg, var(--shadow))',
|
|
649
|
+
padding: 10,
|
|
650
|
+
width: mode === '12h' ? 290 : 240,
|
|
651
|
+
maxWidth: 'calc(100vw - 24px)',
|
|
652
|
+
boxSizing: 'border-box',
|
|
653
|
+
fontSize: '.78rem',
|
|
654
|
+
userSelect: 'none',
|
|
655
|
+
fontFamily: 'var(--sans, inherit)',
|
|
656
|
+
}}
|
|
657
|
+
>
|
|
658
|
+
{/* Header Bar: Variant Switcher */}
|
|
659
|
+
{showVariantToggle && (
|
|
660
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8, paddingBottom: 6, borderBottom: '1px solid var(--border-light, var(--border))' }}>
|
|
661
|
+
<span style={{ fontSize: '.68rem', fontWeight: 700, color: 'var(--fg, var(--t1))', textTransform: 'uppercase', letterSpacing: '.05em' }}>
|
|
662
|
+
Режим
|
|
663
|
+
</span>
|
|
664
|
+
|
|
665
|
+
<div style={{ display: 'inline-flex', background: 'var(--grey-1, var(--surface-hover))', padding: 2, borderRadius: 6, border: '1px solid var(--border-light, var(--border))' }}>
|
|
666
|
+
<button
|
|
667
|
+
type="button"
|
|
668
|
+
onClick={() => setActiveVariant('stepper')}
|
|
669
|
+
className="cynx-timepicker-cell"
|
|
670
|
+
style={{
|
|
671
|
+
padding: '2px 8px',
|
|
672
|
+
borderRadius: 4,
|
|
673
|
+
border: 'none',
|
|
674
|
+
fontSize: '.68rem',
|
|
675
|
+
fontWeight: activeVariant === 'stepper' ? 700 : 500,
|
|
676
|
+
background: activeVariant === 'stepper' ? 'var(--surface, var(--bg))' : 'transparent',
|
|
677
|
+
color: activeVariant === 'stepper' ? 'var(--accent, var(--blue))' : 'var(--muted, var(--t2))',
|
|
678
|
+
boxShadow: activeVariant === 'stepper' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
|
679
|
+
cursor: 'pointer',
|
|
680
|
+
display: 'flex',
|
|
681
|
+
alignItems: 'center',
|
|
682
|
+
gap: 3,
|
|
683
|
+
}}
|
|
684
|
+
>
|
|
685
|
+
<Sliders size={11} /> Спиннер (+/-)
|
|
686
|
+
</button>
|
|
687
|
+
|
|
688
|
+
<button
|
|
689
|
+
type="button"
|
|
690
|
+
onClick={() => setActiveVariant('list')}
|
|
691
|
+
className="cynx-timepicker-cell"
|
|
692
|
+
style={{
|
|
693
|
+
padding: '2px 8px',
|
|
694
|
+
borderRadius: 4,
|
|
695
|
+
border: 'none',
|
|
696
|
+
fontSize: '.68rem',
|
|
697
|
+
fontWeight: activeVariant === 'list' ? 700 : 500,
|
|
698
|
+
background: activeVariant === 'list' ? 'var(--surface, var(--bg))' : 'transparent',
|
|
699
|
+
color: activeVariant === 'list' ? 'var(--accent, var(--blue))' : 'var(--muted, var(--t2))',
|
|
700
|
+
boxShadow: activeVariant === 'list' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
|
701
|
+
cursor: 'pointer',
|
|
702
|
+
display: 'flex',
|
|
703
|
+
alignItems: 'center',
|
|
704
|
+
gap: 3,
|
|
705
|
+
}}
|
|
706
|
+
>
|
|
707
|
+
<List size={11} /> Список (00-59)
|
|
708
|
+
</button>
|
|
709
|
+
</div>
|
|
710
|
+
</div>
|
|
711
|
+
)}
|
|
712
|
+
|
|
713
|
+
{/* Optional Format Switcher (24h / 12h) */}
|
|
714
|
+
{showFormatToggle && (
|
|
715
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8, paddingBottom: 6, borderBottom: '1px solid var(--border-light, var(--border))' }}>
|
|
716
|
+
<span style={{ fontSize: '.68rem', fontWeight: 700, color: 'var(--fg, var(--t1))', textTransform: 'uppercase', letterSpacing: '.05em' }}>
|
|
717
|
+
Формат
|
|
718
|
+
</span>
|
|
719
|
+
|
|
720
|
+
<div style={{ display: 'inline-flex', background: 'var(--grey-1, var(--surface-hover))', padding: 2, borderRadius: 6, border: '1px solid var(--border-light, var(--border))' }}>
|
|
721
|
+
<button
|
|
722
|
+
type="button"
|
|
723
|
+
onClick={() => switchMode('24h')}
|
|
724
|
+
className="cynx-timepicker-cell"
|
|
725
|
+
style={{
|
|
726
|
+
padding: '2px 8px',
|
|
727
|
+
borderRadius: 4,
|
|
728
|
+
border: 'none',
|
|
729
|
+
fontSize: '.68rem',
|
|
730
|
+
fontWeight: mode === '24h' ? 700 : 500,
|
|
731
|
+
background: mode === '24h' ? 'var(--surface, var(--bg))' : 'transparent',
|
|
732
|
+
color: mode === '24h' ? 'var(--accent, var(--blue))' : 'var(--muted, var(--t2))',
|
|
733
|
+
boxShadow: mode === '24h' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
|
734
|
+
cursor: 'pointer',
|
|
735
|
+
}}
|
|
736
|
+
>
|
|
737
|
+
24h
|
|
738
|
+
</button>
|
|
739
|
+
<button
|
|
740
|
+
type="button"
|
|
741
|
+
onClick={() => switchMode('12h')}
|
|
742
|
+
className="cynx-timepicker-cell"
|
|
743
|
+
style={{
|
|
744
|
+
padding: '2px 8px',
|
|
745
|
+
borderRadius: 4,
|
|
746
|
+
border: 'none',
|
|
747
|
+
fontSize: '.68rem',
|
|
748
|
+
fontWeight: mode === '12h' ? 700 : 500,
|
|
749
|
+
background: mode === '12h' ? 'var(--surface, var(--bg))' : 'transparent',
|
|
750
|
+
color: mode === '12h' ? 'var(--accent, var(--blue))' : 'var(--muted, var(--t2))',
|
|
751
|
+
boxShadow: mode === '12h' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
|
752
|
+
cursor: 'pointer',
|
|
753
|
+
}}
|
|
754
|
+
>
|
|
755
|
+
12h AM/PM
|
|
756
|
+
</button>
|
|
757
|
+
</div>
|
|
758
|
+
</div>
|
|
759
|
+
)}
|
|
760
|
+
|
|
761
|
+
{/* Optional Timezone Selector Header */}
|
|
762
|
+
{showTimezoneSelect && (
|
|
763
|
+
<div style={{ marginBottom: 8 }}>
|
|
764
|
+
<button
|
|
765
|
+
type="button"
|
|
766
|
+
onClick={() => setShowTzPicker(prev => !prev)}
|
|
767
|
+
className="cynx-timepicker-cell"
|
|
768
|
+
style={{
|
|
769
|
+
display: 'flex',
|
|
770
|
+
alignItems: 'center',
|
|
771
|
+
justifyContent: 'space-between',
|
|
772
|
+
width: '100%',
|
|
773
|
+
padding: '4px 8px',
|
|
774
|
+
borderRadius: 6,
|
|
775
|
+
border: '1px solid var(--border-light, var(--border))',
|
|
776
|
+
background: 'var(--grey-1, var(--surface-hover))',
|
|
777
|
+
fontSize: '.68rem',
|
|
778
|
+
color: 'var(--fg, var(--t1))',
|
|
779
|
+
cursor: 'pointer',
|
|
780
|
+
fontWeight: 500,
|
|
781
|
+
}}
|
|
782
|
+
>
|
|
783
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
784
|
+
<Globe size={12} color="var(--accent, var(--blue))" />
|
|
785
|
+
{COMMON_TIMEZONES.find(t => t.value === tz)?.label || tz}
|
|
786
|
+
</span>
|
|
787
|
+
<ChevronDown size={12} color="var(--muted, var(--t2))" />
|
|
788
|
+
</button>
|
|
789
|
+
|
|
790
|
+
{showTzPicker && (
|
|
791
|
+
<div style={{ marginTop: 4, background: 'var(--surface, var(--bg))', border: '1px solid var(--border-light, var(--border))', borderRadius: 6, boxShadow: 'var(--shadow)', padding: 4, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
792
|
+
{COMMON_TIMEZONES.map(t => (
|
|
793
|
+
<button
|
|
794
|
+
key={t.value}
|
|
795
|
+
type="button"
|
|
796
|
+
onClick={() => { setTz(t.value); setShowTzPicker(false); }}
|
|
797
|
+
className="cynx-timepicker-cell"
|
|
798
|
+
style={{
|
|
799
|
+
padding: '4px 6px',
|
|
800
|
+
borderRadius: 4,
|
|
801
|
+
border: 'none',
|
|
802
|
+
textAlign: 'left',
|
|
803
|
+
fontSize: '.68rem',
|
|
804
|
+
background: tz === t.value ? 'var(--accent, var(--blue))' : 'transparent',
|
|
805
|
+
color: tz === t.value ? '#ffffff' : 'var(--fg, var(--t1))',
|
|
806
|
+
cursor: 'pointer',
|
|
807
|
+
fontWeight: tz === t.value ? 600 : 400,
|
|
808
|
+
}}
|
|
809
|
+
>
|
|
810
|
+
{t.label}
|
|
811
|
+
</button>
|
|
812
|
+
))}
|
|
813
|
+
</div>
|
|
814
|
+
)}
|
|
815
|
+
</div>
|
|
816
|
+
)}
|
|
817
|
+
|
|
818
|
+
{/* Optional Quick Presets Bar */}
|
|
819
|
+
{showPresets && (
|
|
820
|
+
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 8, paddingBottom: 6, borderBottom: '1px solid var(--border-light, var(--border))' }}>
|
|
821
|
+
{(mode === '12h' ? presets12 : presets24).map(p => (
|
|
822
|
+
<button
|
|
823
|
+
key={p}
|
|
824
|
+
type="button"
|
|
825
|
+
onClick={() => {
|
|
826
|
+
const newP = parseTime(p, mode === '12h', showSeconds);
|
|
827
|
+
emitChange(newP);
|
|
828
|
+
}}
|
|
829
|
+
className="cynx-timepicker-cell"
|
|
830
|
+
style={{
|
|
831
|
+
padding: '3px 6px',
|
|
832
|
+
borderRadius: 4,
|
|
833
|
+
fontSize: '.68rem',
|
|
834
|
+
border: '1px solid var(--border-light, var(--border))',
|
|
835
|
+
background: inputValue === p ? 'var(--accent, var(--blue))' : 'var(--grey-1, var(--surface-hover))',
|
|
836
|
+
color: inputValue === p ? '#ffffff' : 'var(--fg, var(--t1))',
|
|
837
|
+
cursor: 'pointer',
|
|
838
|
+
fontWeight: 500,
|
|
839
|
+
}}
|
|
840
|
+
>
|
|
841
|
+
{p}
|
|
842
|
+
</button>
|
|
843
|
+
))}
|
|
844
|
+
</div>
|
|
845
|
+
)}
|
|
846
|
+
|
|
847
|
+
{/* AM / PM Segment Selector (Only for 12h mode) */}
|
|
848
|
+
{mode === '12h' && (
|
|
849
|
+
<div style={{ display: 'flex', gap: 6, marginBottom: 8, justifyContent: 'center' }}>
|
|
850
|
+
<button
|
|
851
|
+
type="button"
|
|
852
|
+
onClick={() => toggleAmPm('AM')}
|
|
853
|
+
className="cynx-timepicker-cell"
|
|
854
|
+
style={{
|
|
855
|
+
flex: 1,
|
|
856
|
+
padding: '4px 0',
|
|
857
|
+
borderRadius: 6,
|
|
858
|
+
border: '1px solid var(--border-light, var(--border))',
|
|
859
|
+
fontSize: '.75rem',
|
|
860
|
+
fontWeight: parsed.ampm === 'AM' ? 700 : 500,
|
|
861
|
+
background: parsed.ampm === 'AM' ? 'var(--accent, var(--blue))' : 'var(--grey-1, var(--surface-hover))',
|
|
862
|
+
color: parsed.ampm === 'AM' ? '#ffffff' : 'var(--fg, var(--t1))',
|
|
863
|
+
cursor: 'pointer',
|
|
864
|
+
textAlign: 'center',
|
|
865
|
+
}}
|
|
866
|
+
>
|
|
867
|
+
AM (Утро)
|
|
868
|
+
</button>
|
|
869
|
+
<button
|
|
870
|
+
type="button"
|
|
871
|
+
onClick={() => toggleAmPm('PM')}
|
|
872
|
+
className="cynx-timepicker-cell"
|
|
873
|
+
style={{
|
|
874
|
+
flex: 1,
|
|
875
|
+
padding: '4px 0',
|
|
876
|
+
borderRadius: 6,
|
|
877
|
+
border: '1px solid var(--border-light, var(--border))',
|
|
878
|
+
fontSize: '.75rem',
|
|
879
|
+
fontWeight: parsed.ampm === 'PM' ? 700 : 500,
|
|
880
|
+
background: parsed.ampm === 'PM' ? 'var(--accent, var(--blue))' : 'var(--grey-1, var(--surface-hover))',
|
|
881
|
+
color: parsed.ampm === 'PM' ? '#ffffff' : 'var(--fg, var(--t1))',
|
|
882
|
+
cursor: 'pointer',
|
|
883
|
+
textAlign: 'center',
|
|
884
|
+
}}
|
|
885
|
+
>
|
|
886
|
+
PM (Вечер)
|
|
887
|
+
</button>
|
|
888
|
+
</div>
|
|
889
|
+
)}
|
|
890
|
+
|
|
891
|
+
{/* VARIANT 1: STEPPER MODE (Up/Down +/- Buttons) */}
|
|
892
|
+
{activeVariant === 'stepper' ? (
|
|
893
|
+
<div style={{ display: 'grid', gridTemplateColumns: showSeconds ? '1fr 1fr 1fr' : '1fr 1fr', gap: 10, padding: '4px 0' }}>
|
|
894
|
+
{/* Hours Stepper */}
|
|
895
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
|
896
|
+
<div style={{ fontSize: '.65rem', fontWeight: 600, color: 'var(--muted, var(--t2))' }}>
|
|
897
|
+
{mode === '12h' ? 'Час (1-12)' : 'Час (0-23)'}
|
|
898
|
+
</div>
|
|
899
|
+
<button type="button" className="cynx-timepicker-stepper-btn" onClick={() => stepHour(1)} title="+1 Час">
|
|
900
|
+
<ChevronUp size={14} />
|
|
901
|
+
</button>
|
|
902
|
+
<div style={{ fontSize: '1.2rem', fontWeight: 700, fontVariantNumeric: 'tabular-nums', padding: '6px 0', color: 'var(--accent, var(--blue))' }}>
|
|
903
|
+
{mode === '12h' ? parsed.h12 : parsed.h24}
|
|
904
|
+
</div>
|
|
905
|
+
<button type="button" className="cynx-timepicker-stepper-btn" onClick={() => stepHour(-1)} title="-1 Час">
|
|
906
|
+
<ChevronDown size={14} />
|
|
907
|
+
</button>
|
|
908
|
+
</div>
|
|
909
|
+
|
|
910
|
+
{/* Minutes Stepper */}
|
|
911
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
|
912
|
+
<div style={{ fontSize: '.65rem', fontWeight: 600, color: 'var(--muted, var(--t2))' }}>Мин (0-59)</div>
|
|
913
|
+
<button type="button" className="cynx-timepicker-stepper-btn" onClick={() => stepMinute(1)} title="+1 Мин">
|
|
914
|
+
<ChevronUp size={14} />
|
|
915
|
+
</button>
|
|
916
|
+
<div style={{ fontSize: '1.2rem', fontWeight: 700, fontVariantNumeric: 'tabular-nums', padding: '6px 0', color: 'var(--accent, var(--blue))' }}>
|
|
917
|
+
{parsed.m}
|
|
918
|
+
</div>
|
|
919
|
+
<button type="button" className="cynx-timepicker-stepper-btn" onClick={() => stepMinute(-1)} title="-1 Мин">
|
|
920
|
+
<ChevronDown size={14} />
|
|
921
|
+
</button>
|
|
922
|
+
</div>
|
|
923
|
+
|
|
924
|
+
{/* Seconds Stepper */}
|
|
925
|
+
{showSeconds && (
|
|
926
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
|
927
|
+
<div style={{ fontSize: '.65rem', fontWeight: 600, color: 'var(--muted, var(--t2))' }}>Сек (0-59)</div>
|
|
928
|
+
<button type="button" className="cynx-timepicker-stepper-btn" onClick={() => stepSecond(1)} title="+1 Сек">
|
|
929
|
+
<ChevronUp size={14} />
|
|
930
|
+
</button>
|
|
931
|
+
<div style={{ fontSize: '1.2rem', fontWeight: 700, fontVariantNumeric: 'tabular-nums', padding: '6px 0', color: 'var(--accent, var(--blue))' }}>
|
|
932
|
+
{parsed.s}
|
|
933
|
+
</div>
|
|
934
|
+
<button type="button" className="cynx-timepicker-stepper-btn" onClick={() => stepSecond(-1)} title="-1 Сек">
|
|
935
|
+
<ChevronDown size={14} />
|
|
936
|
+
</button>
|
|
937
|
+
</div>
|
|
938
|
+
)}
|
|
939
|
+
</div>
|
|
940
|
+
) : (
|
|
941
|
+
/* VARIANT 2: LIST MODE (Full List 00..59) */
|
|
942
|
+
<div style={{ display: 'grid', gridTemplateColumns: showSeconds ? '1fr 1fr 1fr' : '1fr 1fr', gap: 6 }}>
|
|
943
|
+
{/* Hours Column */}
|
|
944
|
+
<div>
|
|
945
|
+
<div style={{ fontSize: '.65rem', fontWeight: 600, color: 'var(--muted, var(--t2))', marginBottom: 4, textAlign: 'center' }}>
|
|
946
|
+
{mode === '12h' ? 'Час (1-12)' : 'Час (0-23)'}
|
|
947
|
+
</div>
|
|
948
|
+
<div style={{ maxHeight: 150, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 2, paddingRight: 2 }}>
|
|
949
|
+
{(mode === '12h' ? hours12List : hours24List).map(hNum => {
|
|
950
|
+
const isSelected = mode === '12h' ? parsed.h12 === pad(hNum) : parsed.h24 === pad(hNum);
|
|
951
|
+
return (
|
|
952
|
+
<button
|
|
953
|
+
key={hNum}
|
|
954
|
+
type="button"
|
|
955
|
+
onClick={() => (mode === '12h' ? selectH12(hNum) : selectH24(hNum))}
|
|
956
|
+
className="cynx-timepicker-cell"
|
|
957
|
+
style={{
|
|
958
|
+
padding: '3px 6px',
|
|
959
|
+
borderRadius: 4,
|
|
960
|
+
border: 'none',
|
|
961
|
+
textAlign: 'center',
|
|
962
|
+
fontSize: '.75rem',
|
|
963
|
+
cursor: 'pointer',
|
|
964
|
+
fontWeight: isSelected ? 700 : 400,
|
|
965
|
+
background: isSelected ? 'var(--accent, var(--blue))' : 'transparent',
|
|
966
|
+
color: isSelected ? '#ffffff' : 'var(--fg, var(--t1))',
|
|
967
|
+
}}
|
|
968
|
+
>
|
|
969
|
+
{pad(hNum)}
|
|
970
|
+
</button>
|
|
971
|
+
);
|
|
972
|
+
})}
|
|
973
|
+
</div>
|
|
974
|
+
</div>
|
|
975
|
+
|
|
976
|
+
{/* Minutes Column: FULL LIST 00 to 59! */}
|
|
977
|
+
<div>
|
|
978
|
+
<div style={{ fontSize: '.65rem', fontWeight: 600, color: 'var(--muted, var(--t2))', marginBottom: 4, textAlign: 'center' }}>Мин (00-59)</div>
|
|
979
|
+
<div style={{ maxHeight: 150, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 2, paddingRight: 2 }}>
|
|
980
|
+
{fullMinutesList.map(mNum => {
|
|
981
|
+
const mStr = pad(mNum);
|
|
982
|
+
const isSelected = parsed.m === mStr;
|
|
983
|
+
return (
|
|
984
|
+
<button
|
|
985
|
+
key={mNum}
|
|
986
|
+
type="button"
|
|
987
|
+
onClick={() => selectMin(mNum)}
|
|
988
|
+
className="cynx-timepicker-cell"
|
|
989
|
+
style={{
|
|
990
|
+
padding: '3px 6px',
|
|
991
|
+
borderRadius: 4,
|
|
992
|
+
border: 'none',
|
|
993
|
+
textAlign: 'center',
|
|
994
|
+
fontSize: '.75rem',
|
|
995
|
+
cursor: 'pointer',
|
|
996
|
+
fontWeight: isSelected ? 700 : 400,
|
|
997
|
+
background: isSelected ? 'var(--accent, var(--blue))' : 'transparent',
|
|
998
|
+
color: isSelected ? '#ffffff' : 'var(--fg, var(--t1))',
|
|
999
|
+
}}
|
|
1000
|
+
>
|
|
1001
|
+
{mStr}
|
|
1002
|
+
</button>
|
|
1003
|
+
);
|
|
1004
|
+
})}
|
|
1005
|
+
</div>
|
|
1006
|
+
</div>
|
|
1007
|
+
|
|
1008
|
+
{/* Seconds Column (if showSeconds): FULL LIST 00 to 59! */}
|
|
1009
|
+
{showSeconds && (
|
|
1010
|
+
<div>
|
|
1011
|
+
<div style={{ fontSize: '.65rem', fontWeight: 600, color: 'var(--muted, var(--t2))', marginBottom: 4, textAlign: 'center' }}>Сек (00-59)</div>
|
|
1012
|
+
<div style={{ maxHeight: 150, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
1013
|
+
{fullMinutesList.map(sNum => {
|
|
1014
|
+
const sStr = pad(sNum);
|
|
1015
|
+
const isSelected = parsed.s === sStr;
|
|
1016
|
+
return (
|
|
1017
|
+
<button
|
|
1018
|
+
key={sNum}
|
|
1019
|
+
type="button"
|
|
1020
|
+
onClick={() => selectSec(sNum)}
|
|
1021
|
+
className="cynx-timepicker-cell"
|
|
1022
|
+
style={{
|
|
1023
|
+
padding: '3px 6px',
|
|
1024
|
+
borderRadius: 4,
|
|
1025
|
+
border: 'none',
|
|
1026
|
+
textAlign: 'center',
|
|
1027
|
+
fontSize: '.75rem',
|
|
1028
|
+
cursor: 'pointer',
|
|
1029
|
+
fontWeight: isSelected ? 700 : 400,
|
|
1030
|
+
background: isSelected ? 'var(--accent, var(--blue))' : 'transparent',
|
|
1031
|
+
color: isSelected ? '#ffffff' : 'var(--fg, var(--t1))',
|
|
1032
|
+
}}
|
|
1033
|
+
>
|
|
1034
|
+
{sStr}
|
|
1035
|
+
</button>
|
|
1036
|
+
);
|
|
1037
|
+
})}
|
|
1038
|
+
</div>
|
|
1039
|
+
</div>
|
|
1040
|
+
)}
|
|
1041
|
+
</div>
|
|
1042
|
+
)}
|
|
1043
|
+
|
|
1044
|
+
{/* Footer Actions */}
|
|
1045
|
+
<div style={{ marginTop: 8, paddingTop: 6, borderTop: '1px solid var(--border-light, var(--border))', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
1046
|
+
<button
|
|
1047
|
+
type="button"
|
|
1048
|
+
onClick={clearTime}
|
|
1049
|
+
className="cynx-timepicker-cell"
|
|
1050
|
+
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: '.7rem', fontWeight: 500 }}
|
|
1051
|
+
>
|
|
1052
|
+
Сбросить
|
|
1053
|
+
</button>
|
|
1054
|
+
<button
|
|
1055
|
+
type="button"
|
|
1056
|
+
onClick={closePicker}
|
|
1057
|
+
className="cynx-timepicker-cell"
|
|
1058
|
+
style={{ padding: '3px 10px', borderRadius: 4, border: 'none', background: 'var(--accent, var(--blue))', color: '#ffffff', fontSize: '.7rem', cursor: 'pointer', fontWeight: 600, display: 'flex', alignItems: 'center', gap: 4 }}
|
|
1059
|
+
>
|
|
1060
|
+
<Check size={12} /> Готово
|
|
1061
|
+
</button>
|
|
1062
|
+
</div>
|
|
1063
|
+
</div>,
|
|
1064
|
+
document.body
|
|
1065
|
+
)}
|
|
1066
|
+
</div>
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
export default TimePicker;
|
package/index.js
CHANGED
|
@@ -28,6 +28,7 @@ import { ConfirmRoot as _ConfirmRoot, confirm as _confirm } from './components/C
|
|
|
28
28
|
import { SearchableDropdown as _SearchableDropdown } from './components/SearchableDropdown.jsx';
|
|
29
29
|
import { Pagination as _Pagination } from './components/Pagination.jsx';
|
|
30
30
|
import { DatePicker as _DatePicker } from './components/DatePicker.jsx';
|
|
31
|
+
import { TimePicker as _TimePicker } from './components/TimePicker.jsx';
|
|
31
32
|
import { ActionsMenu as _ActionsMenu } from './components/ActionsMenu.jsx';
|
|
32
33
|
import { BrandingCard as _BrandingCard } from './components/BrandingCard.jsx';
|
|
33
34
|
import { Dropzone as _Dropzone } from './components/Dropzone.jsx';
|
|
@@ -72,6 +73,7 @@ export const confirm = _confirm;
|
|
|
72
73
|
export const SearchableDropdown = _SearchableDropdown;
|
|
73
74
|
export const Pagination = _Pagination;
|
|
74
75
|
export const DatePicker = _DatePicker;
|
|
76
|
+
export const TimePicker = _TimePicker;
|
|
75
77
|
export const ActionsMenu = _ActionsMenu;
|
|
76
78
|
export const BrandingCard = _BrandingCard;
|
|
77
79
|
export const Dropzone = _Dropzone;
|