cynx-lib 1.3.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.
- package/index.js +3 -0
- package/package.json +36 -0
- package/ui/components/ActionsMenu.jsx +158 -0
- package/ui/components/ActivityCard.jsx +184 -0
- package/ui/components/AuthOverlay.jsx +94 -0
- package/ui/components/Badge.jsx +186 -0
- package/ui/components/BrandingCard.jsx +146 -0
- package/ui/components/Button.jsx +148 -0
- package/ui/components/Card.jsx +132 -0
- package/ui/components/Checkbox.jsx +59 -0
- package/ui/components/ChipSelect.jsx +54 -0
- package/ui/components/CircularProgress.jsx +216 -0
- package/ui/components/Confirm.jsx +79 -0
- package/ui/components/DatePicker.jsx +605 -0
- package/ui/components/Divider.jsx +96 -0
- package/ui/components/Dropzone.jsx +203 -0
- package/ui/components/FilePicker.jsx +205 -0
- package/ui/components/HoverCard.jsx +81 -0
- package/ui/components/ImageUpload.jsx +147 -0
- package/ui/components/InfoBadge.jsx +204 -0
- package/ui/components/Input.jsx +229 -0
- package/ui/components/LineProgress.jsx +133 -0
- package/ui/components/LinearProgress.jsx +132 -0
- package/ui/components/Modal.jsx +212 -0
- package/ui/components/Pagination.jsx +52 -0
- package/ui/components/PulseDot.jsx +77 -0
- package/ui/components/Select.jsx +415 -0
- package/ui/components/Skeleton.jsx +118 -0
- package/ui/components/Spinner.jsx +108 -0
- package/ui/components/StatBarCard.jsx +25 -0
- package/ui/components/StatsBar.jsx +88 -0
- package/ui/components/StatsCard.jsx +171 -0
- package/ui/components/TabBar.jsx +404 -0
- package/ui/components/Tabs.jsx +29 -0
- package/ui/components/TimePicker.jsx +1069 -0
- package/ui/components/Toast.jsx +106 -0
- package/ui/components/Toggle.jsx +67 -0
- package/ui/components/UptimeBar.jsx +135 -0
- package/ui/components/XTable.jsx +44 -0
- package/ui/index.js +97 -0
- package/utils/index.js +11 -0
- package/utils/utils/oidc.js +209 -0
- package/utils/utils/realtime.js +1040 -0
- package/utils/utils/storage.js +393 -0
- package/utils/utils/tz.js +625 -0
- package/utils/utils/uid.js +252 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
// DatePicker — reusable date picker component
|
|
6
|
+
// Props:
|
|
7
|
+
// value – Date object or null
|
|
8
|
+
// onChange – called with Date object
|
|
9
|
+
// placeholder – string shown when no date selected
|
|
10
|
+
// badges – object { 'YYYY-MM-DD': { dot: true, color: 'var(--accent, #f99e2c)' } } for day badges
|
|
11
|
+
// minDate – Date object, days before this are disabled
|
|
12
|
+
// maxDate – Date object, days after this are disabled
|
|
13
|
+
// disabled – boolean
|
|
14
|
+
// style – container style overrides
|
|
15
|
+
|
|
16
|
+
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
|
17
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
18
|
+
|
|
19
|
+
function pad(n) { return String(n).padStart(2, '0'); }
|
|
20
|
+
function toKey(y, m, d) { return `${y}-${pad(m+1)}-${pad(d)}`; }
|
|
21
|
+
function toDate(val) {
|
|
22
|
+
if (!val) return null;
|
|
23
|
+
if (val instanceof Date) return isNaN(val.getTime()) ? null : val;
|
|
24
|
+
if (typeof val === 'string') {
|
|
25
|
+
const parts = val.split('-');
|
|
26
|
+
if (parts.length === 3) {
|
|
27
|
+
const y = parseInt(parts[0], 10);
|
|
28
|
+
const m = parseInt(parts[1], 10) - 1;
|
|
29
|
+
const d = parseInt(parts[2], 10);
|
|
30
|
+
if (!isNaN(y) && !isNaN(m) && !isNaN(d)) {
|
|
31
|
+
return new Date(y, m, d);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const d = new Date(val);
|
|
36
|
+
return isNaN(d.getTime()) ? null : d;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isSameDay(a, b) {
|
|
40
|
+
const da = toDate(a), db = toDate(b);
|
|
41
|
+
return da && db && da.getFullYear()===db.getFullYear() && da.getMonth()===db.getMonth() && da.getDate()===db.getDate();
|
|
42
|
+
}
|
|
43
|
+
function isToday(y, m, d) { const t = new Date(); return t.getFullYear()===y && t.getMonth()===m && t.getDate()===d; }
|
|
44
|
+
|
|
45
|
+
export function DatePicker({ value, onChange, label, placeholder = 'Select date', badges = {}, minDate, maxDate, disabled, showStepper = true, stepperPosition = 'around', style }) {
|
|
46
|
+
const [open, setOpen] = useState(false);
|
|
47
|
+
const [view, setView] = useState('day'); // 'day' | 'month' | 'year'
|
|
48
|
+
const [cursor, setCursor] = useState(() => toDate(value) || new Date());
|
|
49
|
+
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 });
|
|
50
|
+
const ref = useRef(null);
|
|
51
|
+
const btnRef = useRef(null);
|
|
52
|
+
const dropRef = useRef(null);
|
|
53
|
+
|
|
54
|
+
const changeDay = (delta) => {
|
|
55
|
+
if (disabled) return;
|
|
56
|
+
const current = toDate(value) || new Date();
|
|
57
|
+
const next = new Date(current.getFullYear(), current.getMonth(), current.getDate() + delta);
|
|
58
|
+
|
|
59
|
+
const minD = toDate(minDate);
|
|
60
|
+
const maxD = toDate(maxDate);
|
|
61
|
+
if (minD && next < new Date(new Date(minD).setHours(0, 0, 0, 0))) return;
|
|
62
|
+
if (maxD && next > new Date(new Date(maxD).setHours(23, 59, 59, 999))) return;
|
|
63
|
+
|
|
64
|
+
if (typeof value === 'string') {
|
|
65
|
+
const y = next.getFullYear();
|
|
66
|
+
const m = pad(next.getMonth() + 1);
|
|
67
|
+
const d = pad(next.getDate());
|
|
68
|
+
onChange?.(`${y}-${m}-${d}`);
|
|
69
|
+
} else {
|
|
70
|
+
onChange?.(next);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Close on outside click
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!open) return;
|
|
77
|
+
function close(e) {
|
|
78
|
+
if (ref.current?.contains(e.target)) return;
|
|
79
|
+
if (dropRef.current?.contains(e.target)) return;
|
|
80
|
+
setOpen(false); setView('day');
|
|
81
|
+
}
|
|
82
|
+
document.addEventListener('mousedown', close, true);
|
|
83
|
+
return () => document.removeEventListener('mousedown', close, true);
|
|
84
|
+
}, [open]);
|
|
85
|
+
|
|
86
|
+
const [stepperPosState, setStepperPosState] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('datepicker_stepper_position') : null);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
function onPosChanged() {
|
|
90
|
+
setStepperPosState(typeof window !== 'undefined' ? localStorage.getItem('datepicker_stepper_position') : null);
|
|
91
|
+
}
|
|
92
|
+
window.addEventListener('datepicker-pos-changed', onPosChanged);
|
|
93
|
+
return () => window.removeEventListener('datepicker-pos-changed', onPosChanged);
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
// Sync cursor when value changes externally
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const d = toDate(value);
|
|
99
|
+
if (d) setCursor(d);
|
|
100
|
+
}, [value]);
|
|
101
|
+
|
|
102
|
+
const calcPos = useCallback(() => {
|
|
103
|
+
if (!btnRef.current) return;
|
|
104
|
+
const r = btnRef.current.getBoundingClientRect();
|
|
105
|
+
const dropW = Math.max(r.width, 260);
|
|
106
|
+
const dropH = dropRef.current ? dropRef.current.offsetHeight : 280;
|
|
107
|
+
|
|
108
|
+
let left = r.left;
|
|
109
|
+
if (left + dropW > window.innerWidth - 12) {
|
|
110
|
+
left = Math.max(12, r.right - dropW);
|
|
111
|
+
}
|
|
112
|
+
if (left < 12) left = 12;
|
|
113
|
+
|
|
114
|
+
let top = r.bottom + 6;
|
|
115
|
+
if (top + dropH > window.innerHeight - 12 && r.top - 6 - dropH > 12) {
|
|
116
|
+
top = r.top - 6 - dropH;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
setPos({ top, left, width: dropW });
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
useLayoutEffect(() => {
|
|
123
|
+
if (!open) return;
|
|
124
|
+
calcPos();
|
|
125
|
+
window.addEventListener('scroll', calcPos, true);
|
|
126
|
+
window.addEventListener('resize', calcPos);
|
|
127
|
+
return () => {
|
|
128
|
+
window.removeEventListener('scroll', calcPos, true);
|
|
129
|
+
window.removeEventListener('resize', calcPos);
|
|
130
|
+
};
|
|
131
|
+
}, [open, calcPos]);
|
|
132
|
+
|
|
133
|
+
const toggleOpen = () => {
|
|
134
|
+
if (disabled) return;
|
|
135
|
+
if (!open) {
|
|
136
|
+
calcPos();
|
|
137
|
+
}
|
|
138
|
+
setOpen(o => !o);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
function pick(date) {
|
|
142
|
+
if (!date) return;
|
|
143
|
+
const minD = toDate(minDate);
|
|
144
|
+
const maxD = toDate(maxDate);
|
|
145
|
+
if (minD && date < new Date(new Date(minD).setHours(0, 0, 0, 0))) return;
|
|
146
|
+
if (maxD && date > new Date(new Date(maxD).setHours(23, 59, 59, 999))) return;
|
|
147
|
+
|
|
148
|
+
if (typeof value === 'string') {
|
|
149
|
+
const y = date.getFullYear();
|
|
150
|
+
const m = pad(date.getMonth() + 1);
|
|
151
|
+
const d = pad(date.getDate());
|
|
152
|
+
onChange?.(`${y}-${m}-${d}`);
|
|
153
|
+
} else {
|
|
154
|
+
onChange?.(date);
|
|
155
|
+
}
|
|
156
|
+
setOpen(false);
|
|
157
|
+
setView('day');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const isNavNextDisabled = () => {
|
|
161
|
+
const maxD = toDate(maxDate);
|
|
162
|
+
if (!maxD) return false;
|
|
163
|
+
const y = cursor.getFullYear(), m = cursor.getMonth();
|
|
164
|
+
if (view === 'day') {
|
|
165
|
+
const nextMonthStart = new Date(y, m + 1, 1);
|
|
166
|
+
return nextMonthStart > new Date(new Date(maxD).setHours(23, 59, 59, 999));
|
|
167
|
+
}
|
|
168
|
+
if (view === 'month') {
|
|
169
|
+
const nextYearStart = new Date(y + 1, 0, 1);
|
|
170
|
+
return nextYearStart > new Date(new Date(maxD).setHours(23, 59, 59, 999));
|
|
171
|
+
}
|
|
172
|
+
if (view === 'year') {
|
|
173
|
+
const base = Math.floor(y / 12) * 12;
|
|
174
|
+
const nextBlockStart = new Date(base + 12, 0, 1);
|
|
175
|
+
return nextBlockStart > new Date(new Date(maxD).setHours(23, 59, 59, 999));
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const isNavPrevDisabled = () => {
|
|
181
|
+
const minD = toDate(minDate);
|
|
182
|
+
if (!minD) return false;
|
|
183
|
+
const y = cursor.getFullYear(), m = cursor.getMonth();
|
|
184
|
+
if (view === 'day') {
|
|
185
|
+
const prevMonthEnd = new Date(y, m, 0, 23, 59, 59, 999);
|
|
186
|
+
return prevMonthEnd < new Date(new Date(minD).setHours(0, 0, 0, 0));
|
|
187
|
+
}
|
|
188
|
+
if (view === 'month') {
|
|
189
|
+
const prevYearEnd = new Date(y - 1, 11, 31, 23, 59, 59, 999);
|
|
190
|
+
return prevYearEnd < new Date(new Date(minD).setHours(0, 0, 0, 0));
|
|
191
|
+
}
|
|
192
|
+
if (view === 'year') {
|
|
193
|
+
const base = Math.floor(y / 12) * 12;
|
|
194
|
+
const prevBlockEnd = new Date(base - 1, 11, 31, 23, 59, 59, 999);
|
|
195
|
+
return prevBlockEnd < new Date(new Date(minD).setHours(0, 0, 0, 0));
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
function nav(dir) {
|
|
201
|
+
if (dir > 0 && isNavNextDisabled()) return;
|
|
202
|
+
if (dir < 0 && isNavPrevDisabled()) return;
|
|
203
|
+
const c = new Date(cursor);
|
|
204
|
+
if (view === 'day') { c.setMonth(c.getMonth() + dir); }
|
|
205
|
+
if (view === 'month') { c.setFullYear(c.getFullYear() + dir); }
|
|
206
|
+
if (view === 'year') { c.setFullYear(c.getFullYear() + dir * 12); }
|
|
207
|
+
setCursor(c);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function cycleView() {
|
|
211
|
+
setView(v => v === 'day' ? 'month' : v === 'month' ? 'year' : 'year');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const valDate = toDate(value);
|
|
215
|
+
const formatted = valDate
|
|
216
|
+
? `${MONTHS[valDate.getMonth()]} ${valDate.getDate()}, ${valDate.getFullYear()}`
|
|
217
|
+
: (typeof value === 'string' ? value : null);
|
|
218
|
+
|
|
219
|
+
// ── Day grid ─────────────────────────────────────────────────
|
|
220
|
+
function renderDays() {
|
|
221
|
+
const y = cursor.getFullYear(), m = cursor.getMonth();
|
|
222
|
+
const first = new Date(y, m, 1).getDay(); // 0=Sun
|
|
223
|
+
const offset = first === 0 ? 6 : first === 1 ? 7 : first - 1; // 0=Sun→6, 1=Mon→7(show prev week), else Mon-start
|
|
224
|
+
const daysInMonth = new Date(y, m + 1, 0).getDate();
|
|
225
|
+
const prevDays = new Date(y, m, 0).getDate();
|
|
226
|
+
|
|
227
|
+
const cells = [];
|
|
228
|
+
// Prev month filler
|
|
229
|
+
for (let i = offset - 1; i >= 0; i--)
|
|
230
|
+
cells.push({ day: prevDays - i, cur: false, next: false });
|
|
231
|
+
// Current month
|
|
232
|
+
for (let d = 1; d <= daysInMonth; d++)
|
|
233
|
+
cells.push({ day: d, cur: true, next: false });
|
|
234
|
+
// Next month filler — complete last row + always show at least one full next month week
|
|
235
|
+
const totalSoFar = cells.length;
|
|
236
|
+
let remaining = (7 - (totalSoFar % 7)) % 7;
|
|
237
|
+
if (remaining === 0) remaining = 7;
|
|
238
|
+
for (let d = 1; d <= remaining; d++)
|
|
239
|
+
cells.push({ day: d, cur: false, next: true });
|
|
240
|
+
|
|
241
|
+
const minD = toDate(minDate);
|
|
242
|
+
const maxD = toDate(maxDate);
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div>
|
|
246
|
+
{/* Weekday headers */}
|
|
247
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', marginBottom: 4 }}>
|
|
248
|
+
{DAYS.map(d => (
|
|
249
|
+
<div key={d} style={{ textAlign: 'center', fontSize: '.68rem', fontWeight: 700, color: 'var(--muted, #797b8d)', padding: '4px 0', fontFamily: 'var(--mono, ui-monospace, monospace)' }}>{d}</div>
|
|
250
|
+
))}
|
|
251
|
+
</div>
|
|
252
|
+
{/* Day cells */}
|
|
253
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 1 }}>
|
|
254
|
+
{cells.map((c, i) => {
|
|
255
|
+
if (!c.cur) {
|
|
256
|
+
const isPrev = !c.next;
|
|
257
|
+
const date = isPrev
|
|
258
|
+
? new Date(y, m - 1, c.day)
|
|
259
|
+
: new Date(y, m + 1, c.day);
|
|
260
|
+
const disMin = minD && date < new Date(new Date(minD).setHours(0,0,0,0));
|
|
261
|
+
const disMax = maxD && date > new Date(new Date(maxD).setHours(23,59,59,999));
|
|
262
|
+
const dis = disMin || disMax;
|
|
263
|
+
return (
|
|
264
|
+
<div
|
|
265
|
+
key={i}
|
|
266
|
+
onClick={() => { if (!dis) pick(date); }}
|
|
267
|
+
style={{
|
|
268
|
+
textAlign: 'center',
|
|
269
|
+
padding: '6px 2px',
|
|
270
|
+
fontSize: '.78rem',
|
|
271
|
+
color: dis ? 'var(--border, #e5e7eb)' : 'var(--muted, #797b8d)',
|
|
272
|
+
cursor: dis ? 'not-allowed' : 'pointer',
|
|
273
|
+
borderRadius: 'var(--radxs, 6px)',
|
|
274
|
+
transition: 'background .12s',
|
|
275
|
+
opacity: dis ? 0.35 : 0.7,
|
|
276
|
+
}}
|
|
277
|
+
onMouseEnter={e => { if (!dis) e.currentTarget.style.background = 'var(--grey-1, #f5f5f8)'; }}
|
|
278
|
+
onMouseLeave={e => { if (!dis) e.currentTarget.style.background = 'transparent'; }}
|
|
279
|
+
>
|
|
280
|
+
{c.day}
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
const date = new Date(y, m, c.day);
|
|
285
|
+
const key = toKey(y, m, c.day);
|
|
286
|
+
const badge = badges[key];
|
|
287
|
+
const sel = isSameDay(date, value);
|
|
288
|
+
const today = isToday(y, m, c.day);
|
|
289
|
+
const disMin = minD && date < new Date(new Date(minD).setHours(0,0,0,0));
|
|
290
|
+
const disMax = maxD && date > new Date(new Date(maxD).setHours(23,59,59,999));
|
|
291
|
+
const dis = disMin || disMax;
|
|
292
|
+
return (
|
|
293
|
+
<div key={i} onClick={() => !dis && pick(date)} style={{
|
|
294
|
+
position: 'relative', textAlign: 'center', padding: '6px 2px',
|
|
295
|
+
fontSize: '.8rem', fontWeight: sel ? 700 : today ? 600 : 400,
|
|
296
|
+
borderRadius: 'var(--radxs, 6px)', cursor: dis ? 'not-allowed' : 'pointer',
|
|
297
|
+
background: sel ? 'var(--accent, #f99e2c)' : 'transparent',
|
|
298
|
+
color: sel ? 'var(--def-btn-text,#fff)' : dis ? 'var(--border, #e2e8f0)' : today ? 'var(--accent, #f99e2c)' : 'var(--primary-text, #0f172a)',
|
|
299
|
+
border: today && !sel ? '1.5px solid var(--accent, #f99e2c)' : '1.5px solid transparent',
|
|
300
|
+
transition: 'background .12s, color .12s',
|
|
301
|
+
opacity: dis ? 0.35 : 1,
|
|
302
|
+
}}
|
|
303
|
+
onMouseEnter={e => { if (!sel && !dis) e.currentTarget.style.background = 'var(--grey-1, #f5f5f8)'; }}
|
|
304
|
+
onMouseLeave={e => { if (!sel && !dis) e.currentTarget.style.background = 'transparent'; }}>
|
|
305
|
+
{c.day}
|
|
306
|
+
{badge && (
|
|
307
|
+
<span style={{
|
|
308
|
+
position: 'absolute', bottom: 2, left: '50%', transform: 'translateX(-50%)',
|
|
309
|
+
width: 4, height: 4, borderRadius: '50%',
|
|
310
|
+
background: badge.color || 'var(--accent, #f99e2c)',
|
|
311
|
+
}} />
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
})}
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── Month grid ───────────────────────────────────────────────
|
|
322
|
+
function renderMonths() {
|
|
323
|
+
const y = cursor.getFullYear();
|
|
324
|
+
const minD = toDate(minDate);
|
|
325
|
+
const maxD = toDate(maxDate);
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, padding: '4px 0' }}>
|
|
329
|
+
{MONTHS.map((name, i) => {
|
|
330
|
+
const sel = value && value.getFullYear() === y && value.getMonth() === i;
|
|
331
|
+
const monthStart = new Date(y, i, 1);
|
|
332
|
+
const monthEnd = new Date(y, i + 1, 0, 23, 59, 59, 999);
|
|
333
|
+
const disMin = minD && monthEnd < new Date(new Date(minD).setHours(0, 0, 0, 0));
|
|
334
|
+
const disMax = maxD && monthStart > new Date(new Date(maxD).setHours(23, 59, 59, 999));
|
|
335
|
+
const dis = disMin || disMax;
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<div
|
|
339
|
+
key={i}
|
|
340
|
+
onClick={() => {
|
|
341
|
+
if (dis) return;
|
|
342
|
+
const c = new Date(cursor);
|
|
343
|
+
c.setMonth(i);
|
|
344
|
+
setCursor(c);
|
|
345
|
+
setView('day');
|
|
346
|
+
}}
|
|
347
|
+
style={{
|
|
348
|
+
textAlign: 'center',
|
|
349
|
+
padding: '10px 4px',
|
|
350
|
+
borderRadius: 'var(--radxs, 6px)',
|
|
351
|
+
fontSize: '.82rem',
|
|
352
|
+
fontWeight: 600,
|
|
353
|
+
cursor: dis ? 'not-allowed' : 'pointer',
|
|
354
|
+
opacity: dis ? 0.35 : 1,
|
|
355
|
+
background: sel ? 'var(--accent, #f99e2c)' : 'transparent',
|
|
356
|
+
color: sel ? 'var(--def-btn-text,#fff)' : dis ? 'var(--border, #e2e8f0)' : 'var(--primary-text, #0f172a)',
|
|
357
|
+
transition: 'background .12s',
|
|
358
|
+
}}
|
|
359
|
+
onMouseEnter={e => { if (!sel && !dis) e.currentTarget.style.background = 'var(--grey-1, #f5f5f8)'; }}
|
|
360
|
+
onMouseLeave={e => { if (!sel && !dis) e.currentTarget.style.background = 'transparent'; }}
|
|
361
|
+
>
|
|
362
|
+
{name}
|
|
363
|
+
</div>
|
|
364
|
+
);
|
|
365
|
+
})}
|
|
366
|
+
</div>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Year grid ────────────────────────────────────────────────
|
|
371
|
+
function renderYears() {
|
|
372
|
+
const base = Math.floor(cursor.getFullYear() / 12) * 12;
|
|
373
|
+
const years = Array.from({ length: 12 }, (_, i) => base + i);
|
|
374
|
+
const minD = toDate(minDate);
|
|
375
|
+
const maxD = toDate(maxDate);
|
|
376
|
+
|
|
377
|
+
return (
|
|
378
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, padding: '4px 0' }}>
|
|
379
|
+
{years.map(yr => {
|
|
380
|
+
const sel = value && value.getFullYear() === yr;
|
|
381
|
+
const yrStart = new Date(yr, 0, 1);
|
|
382
|
+
const yrEnd = new Date(yr, 11, 31, 23, 59, 59, 999);
|
|
383
|
+
const disMin = minD && yrEnd < new Date(new Date(minD).setHours(0, 0, 0, 0));
|
|
384
|
+
const disMax = maxD && yrStart > new Date(new Date(maxD).setHours(23, 59, 59, 999));
|
|
385
|
+
const dis = disMin || disMax;
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
<div
|
|
389
|
+
key={yr}
|
|
390
|
+
onClick={() => {
|
|
391
|
+
if (dis) return;
|
|
392
|
+
const c = new Date(cursor);
|
|
393
|
+
c.setFullYear(yr);
|
|
394
|
+
setCursor(c);
|
|
395
|
+
setView('month');
|
|
396
|
+
}}
|
|
397
|
+
style={{
|
|
398
|
+
textAlign: 'center',
|
|
399
|
+
padding: '10px 4px',
|
|
400
|
+
borderRadius: 'var(--radxs, 6px)',
|
|
401
|
+
fontSize: '.82rem',
|
|
402
|
+
fontWeight: 600,
|
|
403
|
+
cursor: dis ? 'not-allowed' : 'pointer',
|
|
404
|
+
opacity: dis ? 0.35 : 1,
|
|
405
|
+
background: sel ? 'var(--accent, #f99e2c)' : 'transparent',
|
|
406
|
+
color: sel ? 'var(--def-btn-text,#fff)' : dis ? 'var(--border, #e2e8f0)' : 'var(--primary-text, #0f172a)',
|
|
407
|
+
transition: 'background .12s',
|
|
408
|
+
}}
|
|
409
|
+
onMouseEnter={e => { if (!sel && !dis) e.currentTarget.style.background = 'var(--grey-1, #f5f5f8)'; }}
|
|
410
|
+
onMouseLeave={e => { if (!sel && !dis) e.currentTarget.style.background = 'transparent'; }}
|
|
411
|
+
>
|
|
412
|
+
{yr}
|
|
413
|
+
</div>
|
|
414
|
+
);
|
|
415
|
+
})}
|
|
416
|
+
</div>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const headerLabel = view === 'day'
|
|
421
|
+
? `${MONTHS[cursor.getMonth()]} ${cursor.getFullYear()}`
|
|
422
|
+
: view === 'month'
|
|
423
|
+
? `${cursor.getFullYear()}`
|
|
424
|
+
: `${Math.floor(cursor.getFullYear()/12)*12} – ${Math.floor(cursor.getFullYear()/12)*12+11}`;
|
|
425
|
+
|
|
426
|
+
const isNextDisabled = () => {
|
|
427
|
+
if (disabled) return true;
|
|
428
|
+
const maxD = toDate(maxDate);
|
|
429
|
+
if (!maxD) return false;
|
|
430
|
+
const current = toDate(value) || new Date();
|
|
431
|
+
const next = new Date(current.getFullYear(), current.getMonth(), current.getDate() + 1);
|
|
432
|
+
return next > new Date(new Date(maxD).setHours(23, 59, 59, 999));
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const isPrevDisabled = () => {
|
|
436
|
+
if (disabled) return true;
|
|
437
|
+
const minD = toDate(minDate);
|
|
438
|
+
if (!minD) return false;
|
|
439
|
+
const current = toDate(value) || new Date();
|
|
440
|
+
const prev = new Date(current.getFullYear(), current.getMonth(), current.getDate() - 1);
|
|
441
|
+
return prev < new Date(new Date(minD).setHours(0, 0, 0, 0));
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const renderPrevBtn = () => {
|
|
445
|
+
const dis = isPrevDisabled();
|
|
446
|
+
return (
|
|
447
|
+
<button
|
|
448
|
+
type="button"
|
|
449
|
+
disabled={dis}
|
|
450
|
+
onClick={(e) => { e.stopPropagation(); changeDay(-1); }}
|
|
451
|
+
style={{
|
|
452
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
453
|
+
width: 32, height: 32, borderRadius: 'var(--rads, 8px)',
|
|
454
|
+
border: '1px solid var(--border, #e2e8f0)', background: 'var(--surface, #ffffff)',
|
|
455
|
+
cursor: dis ? 'not-allowed' : 'pointer', opacity: dis ? 0.35 : 1,
|
|
456
|
+
color: 'var(--muted, #797b8d)', transition: 'all .15s', boxShadow: 'var(--shadow, 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06))',
|
|
457
|
+
flexShrink: 0
|
|
458
|
+
}}
|
|
459
|
+
title="Previous day (-1)"
|
|
460
|
+
onMouseEnter={e => { if (!dis) { e.currentTarget.style.borderColor = 'var(--accent, #f99e2c)'; e.currentTarget.style.color = 'var(--accent, #f99e2c)'; } }}
|
|
461
|
+
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border, #e2e8f0)'; e.currentTarget.style.color = 'var(--muted, #797b8d)'; }}
|
|
462
|
+
>
|
|
463
|
+
<ChevronLeft size={15} />
|
|
464
|
+
</button>
|
|
465
|
+
);
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const renderNextBtn = () => {
|
|
469
|
+
const dis = isNextDisabled();
|
|
470
|
+
return (
|
|
471
|
+
<button
|
|
472
|
+
type="button"
|
|
473
|
+
disabled={dis}
|
|
474
|
+
onClick={(e) => { e.stopPropagation(); changeDay(1); }}
|
|
475
|
+
style={{
|
|
476
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
477
|
+
width: 32, height: 32, borderRadius: 'var(--rads, 8px)',
|
|
478
|
+
border: '1px solid var(--border, #e2e8f0)', background: 'var(--surface, #ffffff)',
|
|
479
|
+
cursor: dis ? 'not-allowed' : 'pointer', opacity: dis ? 0.35 : 1,
|
|
480
|
+
color: 'var(--muted, #797b8d)', transition: 'all .15s', boxShadow: 'var(--shadow, 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06))',
|
|
481
|
+
flexShrink: 0
|
|
482
|
+
}}
|
|
483
|
+
title="Next day (+1)"
|
|
484
|
+
onMouseEnter={e => { if (!dis) { e.currentTarget.style.borderColor = 'var(--accent, #f99e2c)'; e.currentTarget.style.color = 'var(--accent, #f99e2c)'; } }}
|
|
485
|
+
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border, #e2e8f0)'; e.currentTarget.style.color = 'var(--muted, #797b8d)'; }}
|
|
486
|
+
>
|
|
487
|
+
<ChevronRight size={15} />
|
|
488
|
+
</button>
|
|
489
|
+
);
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const renderTriggerBtn = () => (
|
|
493
|
+
<button ref={btnRef} type="button" onClick={toggleOpen} style={{
|
|
494
|
+
display: 'flex', alignItems: 'center', gap: 7,
|
|
495
|
+
padding: '5px 10px', borderRadius: 'var(--rads, 8px)',
|
|
496
|
+
border: '1px solid var(--border, #e2e8f0)', background: 'var(--surface, #ffffff)',
|
|
497
|
+
cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.5 : 1,
|
|
498
|
+
fontSize: '.8rem', color: formatted ? 'var(--primary-text, #0f172a)' : 'var(--muted, #797b8d)',
|
|
499
|
+
fontFamily: 'var(--sans, inherit)', transition: 'border-color .15s',
|
|
500
|
+
whiteSpace: 'nowrap', minWidth: 140, height: 32, boxShadow: 'var(--shadow, 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06))',
|
|
501
|
+
}}
|
|
502
|
+
onMouseEnter={e => { if (!disabled) e.currentTarget.style.borderColor = 'var(--accent, #f99e2c)'; }}
|
|
503
|
+
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border, #e2e8f0)'; }}>
|
|
504
|
+
<Calendar size={13} style={{ color: 'var(--muted, #797b8d)', flexShrink: 0 }} />
|
|
505
|
+
<span style={{ flex: 1, textAlign: 'left' }}>{formatted || placeholder}</span>
|
|
506
|
+
{value && (
|
|
507
|
+
<span
|
|
508
|
+
onClick={e => { e.stopPropagation(); onChange?.(null); }}
|
|
509
|
+
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--muted, #797b8d)', cursor: 'pointer', flexShrink: 0, fontSize: 13, lineHeight: 1, padding: '0 2px' }}
|
|
510
|
+
onMouseEnter={e => { e.currentTarget.style.color = 'var(--danger, #f64747)'; }}
|
|
511
|
+
onMouseLeave={e => { e.currentTarget.style.color = 'var(--muted, #797b8d)'; }}>
|
|
512
|
+
✕
|
|
513
|
+
</span>
|
|
514
|
+
)}
|
|
515
|
+
</button>
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
const actualPos = stepperPosition || 'around';
|
|
519
|
+
const showStepperEffective = showStepper && actualPos !== 'off';
|
|
520
|
+
const posMode = actualPos === 'left' || actualPos === 'start' ? 'left'
|
|
521
|
+
: actualPos === 'right' || actualPos === 'end' ? 'right'
|
|
522
|
+
: 'around';
|
|
523
|
+
|
|
524
|
+
return (
|
|
525
|
+
<div style={{ display: 'inline-flex', flexDirection: 'column', position: 'relative', width: style?.width || 'auto', boxSizing: 'border-box', minWidth: 0, ...style }}>
|
|
526
|
+
{label && (
|
|
527
|
+
<label style={{ fontSize: 11, fontWeight: 600, color: 'var(--t2, var(--muted, #797b8d))', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 4 }}>
|
|
528
|
+
{label}
|
|
529
|
+
</label>
|
|
530
|
+
)}
|
|
531
|
+
<div ref={ref} style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 4, width: '100%' }}>
|
|
532
|
+
{showStepperEffective && posMode === 'around' && renderPrevBtn()}
|
|
533
|
+
{showStepperEffective && posMode === 'left' && renderPrevBtn()}
|
|
534
|
+
{showStepperEffective && posMode === 'left' && renderNextBtn()}
|
|
535
|
+
|
|
536
|
+
{renderTriggerBtn()}
|
|
537
|
+
|
|
538
|
+
{showStepperEffective && posMode === 'around' && renderNextBtn()}
|
|
539
|
+
{showStepperEffective && posMode === 'right' && renderPrevBtn()}
|
|
540
|
+
{showStepperEffective && posMode === 'right' && renderNextBtn()}
|
|
541
|
+
</div>
|
|
542
|
+
|
|
543
|
+
{/* Dropdown via portal */}
|
|
544
|
+
{open && createPortal(
|
|
545
|
+
<div ref={dropRef} style={{
|
|
546
|
+
position: 'fixed', top: pos.top, left: pos.left, width: pos.width, zIndex: 9000,
|
|
547
|
+
background: 'var(--surface, #ffffff)', border: '1px solid var(--border, #e2e8f0)',
|
|
548
|
+
borderRadius: 'var(--rad, 10px)', boxShadow: 'var(--shadow-lg, 0 10px 15px -3px rgba(0,0,0,.1), 0 4px 6px -4px rgba(0,0,0,.06))',
|
|
549
|
+
padding: '10px 12px',
|
|
550
|
+
animation: 'dp-in .12s ease',
|
|
551
|
+
}}>
|
|
552
|
+
{/* Calendar header */}
|
|
553
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
|
554
|
+
<button
|
|
555
|
+
type="button"
|
|
556
|
+
disabled={isNavPrevDisabled()}
|
|
557
|
+
onClick={() => nav(-1)}
|
|
558
|
+
style={{
|
|
559
|
+
background: 'none', border: 'none',
|
|
560
|
+
cursor: isNavPrevDisabled() ? 'not-allowed' : 'pointer',
|
|
561
|
+
opacity: isNavPrevDisabled() ? 0.35 : 1,
|
|
562
|
+
padding: 4, borderRadius: 'var(--radxs, 6px)',
|
|
563
|
+
color: 'var(--muted, #797b8d)', display: 'flex', alignItems: 'center'
|
|
564
|
+
}}
|
|
565
|
+
onMouseEnter={e => { if (!isNavPrevDisabled()) e.currentTarget.style.background = 'var(--grey-1, #f5f5f8)'; }}
|
|
566
|
+
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
|
567
|
+
>
|
|
568
|
+
<ChevronLeft size={15} />
|
|
569
|
+
</button>
|
|
570
|
+
<button type="button" onClick={cycleView} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '.82rem', fontWeight: 700, color: 'var(--primary-text, #0f172a)', padding: '4px 8px', borderRadius: 'var(--radxs, 6px)', fontFamily: 'var(--sans, inherit)' }}
|
|
571
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--grey-1, #f5f5f8)'}
|
|
572
|
+
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
|
573
|
+
{headerLabel}
|
|
574
|
+
</button>
|
|
575
|
+
<button
|
|
576
|
+
type="button"
|
|
577
|
+
disabled={isNavNextDisabled()}
|
|
578
|
+
onClick={() => nav(1)}
|
|
579
|
+
style={{
|
|
580
|
+
background: 'none', border: 'none',
|
|
581
|
+
cursor: isNavNextDisabled() ? 'not-allowed' : 'pointer',
|
|
582
|
+
opacity: isNavNextDisabled() ? 0.35 : 1,
|
|
583
|
+
padding: 4, borderRadius: 'var(--radxs, 6px)',
|
|
584
|
+
color: 'var(--muted, #797b8d)', display: 'flex', alignItems: 'center'
|
|
585
|
+
}}
|
|
586
|
+
onMouseEnter={e => { if (!isNavNextDisabled()) e.currentTarget.style.background = 'var(--grey-1, #f5f5f8)'; }}
|
|
587
|
+
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
|
588
|
+
>
|
|
589
|
+
<ChevronRight size={15} />
|
|
590
|
+
</button>
|
|
591
|
+
</div>
|
|
592
|
+
|
|
593
|
+
{/* Calendar body */}
|
|
594
|
+
{view === 'day' && renderDays()}
|
|
595
|
+
{view === 'month' && renderMonths()}
|
|
596
|
+
{view === 'year' && renderYears()}
|
|
597
|
+
|
|
598
|
+
</div>,
|
|
599
|
+
document.body
|
|
600
|
+
)}
|
|
601
|
+
|
|
602
|
+
<style>{`@keyframes dp-in { from { opacity:0; transform:translateY(-4px); } to { opacity:1; transform:none; } }`}</style>
|
|
603
|
+
</div>
|
|
604
|
+
);
|
|
605
|
+
}
|