cynx-ui 1.3.21 → 1.3.23
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/Select.jsx +20 -12
- package/components/TimePicker.jsx +87 -95
- package/components/UptimeBar.jsx +76 -141
- package/package.json +1 -1
package/components/Select.jsx
CHANGED
|
@@ -2,6 +2,13 @@ import { useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback } fr
|
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import { ChevronDown, Check, X } from 'lucide-react';
|
|
4
4
|
|
|
5
|
+
const SIZES = {
|
|
6
|
+
xs: { height: 24, fontSize: 11, paddingH: 6, searchHeight: 22, searchFont: 10.5, itemMinHeight: 18, itemPadding: '3px 5px' },
|
|
7
|
+
sm: { height: 28, fontSize: 11.5, paddingH: 8, searchHeight: 26, searchFont: 11.5, itemMinHeight: 22, itemPadding: '4.5px 7px' },
|
|
8
|
+
md: { height: 32, fontSize: 13, paddingH: 10, searchHeight: 32, searchFont: 12.5, itemMinHeight: 28, itemPadding: '8px 10px' },
|
|
9
|
+
lg: { height: 36, fontSize: 14, paddingH: 14, searchHeight: 34, searchFont: 13, itemMinHeight: 32, itemPadding: '9px 12px' },
|
|
10
|
+
};
|
|
11
|
+
|
|
5
12
|
const KEYFRAMES = `
|
|
6
13
|
@keyframes selFadeInDown{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}
|
|
7
14
|
@keyframes selFadeInUp{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
|
|
@@ -158,9 +165,10 @@ export default function Select({
|
|
|
158
165
|
|
|
159
166
|
const selectedValues = multi ? (Array.isArray(value) ? value : []) : (value !== undefined && value !== null ? [value] : []);
|
|
160
167
|
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
const
|
|
168
|
+
const s = SIZES[size] || SIZES.md;
|
|
169
|
+
const heightVal = s.height;
|
|
170
|
+
const paddingVal = `0 ${s.paddingH}px`;
|
|
171
|
+
const fontSizeVal = `${s.fontSize}px`;
|
|
164
172
|
|
|
165
173
|
let radiusVal = rounded
|
|
166
174
|
? (typeof rounded === 'string'
|
|
@@ -168,7 +176,7 @@ export default function Select({
|
|
|
168
176
|
: typeof rounded === 'number'
|
|
169
177
|
? `${rounded}px`
|
|
170
178
|
: '6px')
|
|
171
|
-
: (
|
|
179
|
+
: (s.height <= 26 ? '4px' : s.height <= 28 ? '6px' : 'var(--rmd,var(--rads, 8px))');
|
|
172
180
|
|
|
173
181
|
return (
|
|
174
182
|
<div
|
|
@@ -310,13 +318,13 @@ export default function Select({
|
|
|
310
318
|
style={{
|
|
311
319
|
width: '100%',
|
|
312
320
|
boxSizing: 'border-box',
|
|
313
|
-
padding:
|
|
314
|
-
height:
|
|
321
|
+
padding: s.height <= 26 ? '2px 5px' : s.height <= 28 ? '3px 6px' : '7px 10px',
|
|
322
|
+
height: s.searchHeight,
|
|
315
323
|
background: 'var(--sf2,var(--grey-1, #f5f5f8))',
|
|
316
324
|
border: '1.5px solid var(--bd2,var(--border, #e2e8f0))',
|
|
317
|
-
borderRadius:
|
|
325
|
+
borderRadius: s.height <= 26 ? '3px' : s.height <= 28 ? '4px' : 'var(--rmd,var(--rads, 8px))',
|
|
318
326
|
fontFamily: 'var(--f,var(--sans, inherit))',
|
|
319
|
-
fontSize:
|
|
327
|
+
fontSize: `${s.searchFont}px`,
|
|
320
328
|
lineHeight: 1,
|
|
321
329
|
color: 'var(--t,var(--primary-text, #0f172a))',
|
|
322
330
|
outline: 'none',
|
|
@@ -339,13 +347,13 @@ export default function Select({
|
|
|
339
347
|
display: 'flex',
|
|
340
348
|
alignItems: 'center',
|
|
341
349
|
gap: 6,
|
|
342
|
-
minHeight:
|
|
343
|
-
padding:
|
|
344
|
-
borderRadius:
|
|
350
|
+
minHeight: s.itemMinHeight,
|
|
351
|
+
padding: s.itemPadding,
|
|
352
|
+
borderRadius: s.height <= 26 ? '3px' : s.height <= 28 ? '4px' : 'var(--rmd,var(--rads, 8px))',
|
|
345
353
|
cursor: opt.disabled ? 'not-allowed' : 'pointer',
|
|
346
354
|
color: isSelected(opt.value) ? 'var(--accent, #f99e2c)' : 'var(--t,var(--primary-text, #0f172a))',
|
|
347
355
|
background: isSelected(opt.value) ? 'var(--pd,var(--accent-bg, rgba(249,158,44,.1)))' : undefined,
|
|
348
|
-
fontSize:
|
|
356
|
+
fontSize: `${s.searchFont}px`,
|
|
349
357
|
fontWeight: 400,
|
|
350
358
|
transition: 'background .1s',
|
|
351
359
|
userSelect: 'none',
|
|
@@ -2,38 +2,27 @@ import { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react
|
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import { Clock, ChevronDown, ChevronUp, Check, X, Globe, List, Sliders } from 'lucide-react';
|
|
4
4
|
|
|
5
|
+
const SIZES = {
|
|
6
|
+
xs: { height: 24, fontSize: 11, paddingH: 6, iconPadLeft: 22, noIconPadLeft: 6, clockSize: 12 },
|
|
7
|
+
sm: { height: 28, fontSize: 11.5, paddingH: 8, iconPadLeft: 26, noIconPadLeft: 8, clockSize: 13 },
|
|
8
|
+
md: { height: 32, fontSize: 13, paddingH: 10, iconPadLeft: 28, noIconPadLeft: 10, clockSize: 14 },
|
|
9
|
+
lg: { height: 36, fontSize: 14, paddingH: 14, iconPadLeft: 32, noIconPadLeft: 12, clockSize: 16 },
|
|
10
|
+
};
|
|
11
|
+
|
|
5
12
|
const STYLES = `
|
|
6
|
-
@keyframes
|
|
7
|
-
0
|
|
8
|
-
|
|
9
|
-
transform: translateY(-8px) scale(0.96);
|
|
10
|
-
}
|
|
11
|
-
100% {
|
|
12
|
-
opacity: 1;
|
|
13
|
-
transform: translateY(0) scale(1);
|
|
14
|
-
}
|
|
13
|
+
@keyframes cynxTimePickerFadeInDown {
|
|
14
|
+
from { opacity: 0; transform: translateY(-4px); }
|
|
15
|
+
to { opacity: 1; transform: translateY(0); }
|
|
15
16
|
}
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
}
|
|
17
|
+
@keyframes cynxTimePickerFadeInUp {
|
|
18
|
+
from { opacity: 0; transform: translateY(4px); }
|
|
19
|
+
to { opacity: 1; transform: translateY(0); }
|
|
26
20
|
}
|
|
27
21
|
|
|
28
22
|
.cynx-timepicker-drop {
|
|
29
|
-
animation: cynxTimePickerPopIn 0.18s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
30
23
|
will-change: opacity, transform;
|
|
31
24
|
}
|
|
32
25
|
|
|
33
|
-
.cynx-timepicker-drop.closing {
|
|
34
|
-
animation: cynxTimePickerPopOut 0.14s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
26
|
.cynx-timepicker-cell {
|
|
38
27
|
transition: background-color 0.12s ease, color 0.12s ease;
|
|
39
28
|
}
|
|
@@ -176,7 +165,7 @@ export function TimePicker({
|
|
|
176
165
|
timezone = 'Asia/Tashkent',
|
|
177
166
|
label,
|
|
178
167
|
placeholder,
|
|
179
|
-
size = '
|
|
168
|
+
size = 'md', // 'xs' | 'sm' | 'md' | 'lg'
|
|
180
169
|
disabled = false,
|
|
181
170
|
readOnly = false,
|
|
182
171
|
style = {},
|
|
@@ -184,8 +173,6 @@ export function TimePicker({
|
|
|
184
173
|
}) {
|
|
185
174
|
const displayClockIcon = showIcon && !hideIcon;
|
|
186
175
|
const [open, setOpen] = useState(false);
|
|
187
|
-
const [rendered, setRendered] = useState(false);
|
|
188
|
-
const [closing, setClosing] = useState(false);
|
|
189
176
|
|
|
190
177
|
const [activeVariant, setActiveVariant] = useState(variant);
|
|
191
178
|
const [mode, setMode] = useState(format);
|
|
@@ -196,7 +183,7 @@ export function TimePicker({
|
|
|
196
183
|
const dropRef = useRef(null);
|
|
197
184
|
const isTypingRef = useRef(false);
|
|
198
185
|
|
|
199
|
-
const [pos, setPos] = useState({ top: 0, left: 0, width: 230 });
|
|
186
|
+
const [pos, setPos] = useState({ top: 0, left: 0, width: 230, openUp: false });
|
|
200
187
|
|
|
201
188
|
ensureStyles();
|
|
202
189
|
|
|
@@ -204,22 +191,42 @@ export function TimePicker({
|
|
|
204
191
|
useEffect(() => { setMode(format); }, [format]);
|
|
205
192
|
useEffect(() => { setTz(timezone); }, [timezone]);
|
|
206
193
|
|
|
194
|
+
const calcPos = useCallback(() => {
|
|
195
|
+
if (!btnRef.current) return;
|
|
196
|
+
const r = btnRef.current.getBoundingClientRect();
|
|
197
|
+
const dropW = mode === '12h' ? 290 : 230;
|
|
198
|
+
const dropEl = dropRef.current;
|
|
199
|
+
const dropH = dropEl ? dropEl.offsetHeight : 230;
|
|
200
|
+
const spaceBelow = window.innerHeight - r.bottom;
|
|
201
|
+
const spaceAbove = r.top;
|
|
202
|
+
const openUp = spaceBelow < Math.min(dropH, 230) + 12 && spaceAbove > spaceBelow;
|
|
203
|
+
|
|
204
|
+
let top;
|
|
205
|
+
if (openUp) {
|
|
206
|
+
top = Math.max(8, r.top - (dropEl ? dropEl.offsetHeight : dropH) - 4);
|
|
207
|
+
} else {
|
|
208
|
+
top = Math.min(window.innerHeight - 40, r.bottom + 4);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let left = r.left;
|
|
212
|
+
if (left + dropW > window.innerWidth - 8) {
|
|
213
|
+
left = Math.max(8, window.innerWidth - dropW - 8);
|
|
214
|
+
}
|
|
215
|
+
if (left < 8) left = 8;
|
|
216
|
+
|
|
217
|
+
setPos({ top, left, width: Math.max(r.width, dropW), openUp });
|
|
218
|
+
}, [mode]);
|
|
219
|
+
|
|
207
220
|
const openPicker = useCallback(() => {
|
|
208
221
|
if (disabled || readOnly) return;
|
|
209
|
-
|
|
210
|
-
setClosing(false);
|
|
222
|
+
calcPos();
|
|
211
223
|
setOpen(true);
|
|
212
|
-
}, [disabled, readOnly]);
|
|
224
|
+
}, [disabled, readOnly, calcPos]);
|
|
213
225
|
|
|
214
226
|
const closePicker = useCallback(() => {
|
|
215
227
|
if (!open) return;
|
|
216
|
-
setClosing(true);
|
|
217
228
|
setOpen(false);
|
|
218
229
|
setShowTzPicker(false);
|
|
219
|
-
setTimeout(() => {
|
|
220
|
-
setRendered(false);
|
|
221
|
-
setClosing(false);
|
|
222
|
-
}, 140);
|
|
223
230
|
}, [open]);
|
|
224
231
|
|
|
225
232
|
const togglePicker = useCallback(() => {
|
|
@@ -250,33 +257,15 @@ export function TimePicker({
|
|
|
250
257
|
}
|
|
251
258
|
}, [value, mode, showSeconds, formatOutput]);
|
|
252
259
|
|
|
253
|
-
const calcPos = useCallback(() => {
|
|
254
|
-
if (!btnRef.current) return;
|
|
255
|
-
const r = btnRef.current.getBoundingClientRect();
|
|
256
|
-
const dropW = mode === '12h' ? 290 : 230;
|
|
257
|
-
const dropH = dropRef.current ? dropRef.current.offsetHeight : 230;
|
|
258
|
-
|
|
259
|
-
let left = r.left;
|
|
260
|
-
if (left + dropW > window.innerWidth - 12) {
|
|
261
|
-
left = Math.max(12, r.right - dropW);
|
|
262
|
-
}
|
|
263
|
-
if (left < 12) left = 12;
|
|
264
|
-
|
|
265
|
-
let top = r.bottom + 4;
|
|
266
|
-
if (top + dropH > window.innerHeight - 12 && r.top - 4 - dropH > 12) {
|
|
267
|
-
top = r.top - 4 - dropH;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
setPos({ top, left, width: Math.max(r.width, dropW) });
|
|
271
|
-
}, [mode]);
|
|
272
|
-
|
|
273
260
|
useLayoutEffect(() => {
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
|
|
261
|
+
if (open) {
|
|
262
|
+
calcPos();
|
|
263
|
+
requestAnimationFrame(calcPos);
|
|
264
|
+
}
|
|
265
|
+
}, [open, calcPos]);
|
|
277
266
|
|
|
278
267
|
useEffect(() => {
|
|
279
|
-
if (!
|
|
268
|
+
if (!open) return;
|
|
280
269
|
function onDown(e) {
|
|
281
270
|
const inBtn = btnRef.current && btnRef.current.contains(e.target);
|
|
282
271
|
const inDrop = dropRef.current && dropRef.current.contains(e.target);
|
|
@@ -284,18 +273,15 @@ export function TimePicker({
|
|
|
284
273
|
closePicker();
|
|
285
274
|
}
|
|
286
275
|
}
|
|
287
|
-
|
|
288
|
-
document.addEventListener('mousedown', onDown, true);
|
|
289
|
-
}, 0);
|
|
276
|
+
document.addEventListener('mousedown', onDown, true);
|
|
290
277
|
window.addEventListener('scroll', calcPos, true);
|
|
291
278
|
window.addEventListener('resize', calcPos);
|
|
292
279
|
return () => {
|
|
293
|
-
clearTimeout(timer);
|
|
294
280
|
document.removeEventListener('mousedown', onDown, true);
|
|
295
281
|
window.removeEventListener('scroll', calcPos, true);
|
|
296
282
|
window.removeEventListener('resize', calcPos);
|
|
297
283
|
};
|
|
298
|
-
}, [
|
|
284
|
+
}, [open, closePicker, calcPos]);
|
|
299
285
|
|
|
300
286
|
const emitChange = (parsedObj, useMode = mode) => {
|
|
301
287
|
const formatted = formatOutput(parsedObj, useMode);
|
|
@@ -518,10 +504,11 @@ export function TimePicker({
|
|
|
518
504
|
const fullMinutesList = Array.from({ length: 60 }, (_, i) => i);
|
|
519
505
|
|
|
520
506
|
|
|
507
|
+
const s = SIZES[size] || SIZES.md;
|
|
521
508
|
const inputHeight = style?.height
|
|
522
509
|
? (typeof style.height === 'number' ? `${style.height}px` : style.height)
|
|
523
|
-
:
|
|
524
|
-
const fontSize =
|
|
510
|
+
: `${s.height}px`;
|
|
511
|
+
const fontSize = `${s.fontSize}px`;
|
|
525
512
|
|
|
526
513
|
return (
|
|
527
514
|
<div
|
|
@@ -559,7 +546,7 @@ export function TimePicker({
|
|
|
559
546
|
transition: 'color .15s ease',
|
|
560
547
|
}}
|
|
561
548
|
>
|
|
562
|
-
<Clock size={
|
|
549
|
+
<Clock size={s.clockSize} />
|
|
563
550
|
</button>
|
|
564
551
|
)}
|
|
565
552
|
|
|
@@ -581,8 +568,8 @@ export function TimePicker({
|
|
|
581
568
|
maxWidth: '100%',
|
|
582
569
|
boxSizing: 'border-box',
|
|
583
570
|
height: inputHeight,
|
|
584
|
-
paddingLeft: displayClockIcon ?
|
|
585
|
-
paddingRight:
|
|
571
|
+
paddingLeft: displayClockIcon ? s.iconPadLeft : s.noIconPadLeft,
|
|
572
|
+
paddingRight: s.height <= 26 ? 18 : 26,
|
|
586
573
|
border: `1px solid ${open ? 'var(--accent, #f99e2c)' : 'var(--bd, var(--border, #d7d8e0))'}`,
|
|
587
574
|
borderRadius: 'var(--rads, 8px)',
|
|
588
575
|
fontSize: fontSize,
|
|
@@ -603,47 +590,51 @@ export function TimePicker({
|
|
|
603
590
|
}}
|
|
604
591
|
/>
|
|
605
592
|
|
|
606
|
-
<div style={{ position: 'absolute', right: 8, display: 'flex', alignItems: 'center' }}>
|
|
607
|
-
{inputValue && !disabled && !readOnly
|
|
608
|
-
<
|
|
609
|
-
|
|
610
|
-
|
|
593
|
+
<div style={{ position: 'absolute', right: 8, display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
594
|
+
{inputValue && !disabled && !readOnly && (
|
|
595
|
+
<span
|
|
596
|
+
onClick={(e) => {
|
|
597
|
+
e.stopPropagation();
|
|
598
|
+
clearTime();
|
|
599
|
+
}}
|
|
611
600
|
title="Очистить"
|
|
612
601
|
style={{
|
|
613
|
-
background: 'none',
|
|
614
|
-
border: 'none',
|
|
615
|
-
cursor: 'pointer',
|
|
616
|
-
color: 'var(--t2, var(--muted, #9b9daa))',
|
|
617
|
-
padding: 2,
|
|
618
602
|
display: 'flex',
|
|
619
603
|
alignItems: 'center',
|
|
620
604
|
justifyContent: 'center',
|
|
621
|
-
|
|
605
|
+
cursor: 'pointer',
|
|
606
|
+
color: 'var(--t2, var(--muted, #9b9daa))',
|
|
607
|
+
padding: '0 2px',
|
|
608
|
+
flexShrink: 0,
|
|
622
609
|
}}
|
|
623
610
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--danger, #f64747)'}
|
|
624
611
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--t2, var(--muted, #9b9daa))'}
|
|
625
612
|
>
|
|
626
|
-
<X size={
|
|
627
|
-
</
|
|
628
|
-
) : (
|
|
629
|
-
<ChevronDown
|
|
630
|
-
size={13}
|
|
631
|
-
style={{
|
|
632
|
-
color: open ? 'var(--accent, #f99e2c)' : 'var(--t2, var(--muted, #9b9daa))',
|
|
633
|
-
transform: open ? 'rotate(180deg)' : 'none',
|
|
634
|
-
transition: 'transform .2s ease, color .15s ease',
|
|
635
|
-
pointerEvents: 'none',
|
|
636
|
-
}}
|
|
637
|
-
/>
|
|
613
|
+
<X size={12} />
|
|
614
|
+
</span>
|
|
638
615
|
)}
|
|
616
|
+
<ChevronDown
|
|
617
|
+
size={12}
|
|
618
|
+
style={{
|
|
619
|
+
flexShrink: 0,
|
|
620
|
+
color: open ? 'var(--accent, #f99e2c)' : 'var(--t2, var(--muted, #9b9daa))',
|
|
621
|
+
transition: 'transform .2s ease',
|
|
622
|
+
transform: open ? 'rotate(180deg)' : undefined,
|
|
623
|
+
cursor: 'pointer',
|
|
624
|
+
}}
|
|
625
|
+
onClick={(e) => {
|
|
626
|
+
e.stopPropagation();
|
|
627
|
+
togglePicker();
|
|
628
|
+
}}
|
|
629
|
+
/>
|
|
639
630
|
</div>
|
|
640
631
|
</div>
|
|
641
632
|
|
|
642
633
|
{/* PORTAL DROPDOWN POPUP */}
|
|
643
|
-
{
|
|
634
|
+
{open && createPortal(
|
|
644
635
|
<div
|
|
645
636
|
ref={dropRef}
|
|
646
|
-
className=
|
|
637
|
+
className="cynx-timepicker-drop"
|
|
647
638
|
style={{
|
|
648
639
|
position: 'fixed',
|
|
649
640
|
top: pos.top,
|
|
@@ -660,6 +651,7 @@ export function TimePicker({
|
|
|
660
651
|
fontSize: '.78rem',
|
|
661
652
|
userSelect: 'none',
|
|
662
653
|
fontFamily: 'var(--sans, inherit)',
|
|
654
|
+
animation: `${pos.openUp ? 'cynxTimePickerFadeInUp' : 'cynxTimePickerFadeInDown'} .15s ease`,
|
|
663
655
|
}}
|
|
664
656
|
>
|
|
665
657
|
{/* Header Bar: Variant Switcher */}
|
package/components/UptimeBar.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState, useRef } from 'react';
|
|
2
2
|
|
|
3
3
|
const card = {
|
|
4
4
|
background: 'var(--surface,#fff)',
|
|
@@ -7,64 +7,26 @@ const card = {
|
|
|
7
7
|
boxShadow: 'var(--shadow, 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04))',
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
-
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
11
|
-
|
|
12
|
-
function formatDate(date) {
|
|
13
|
-
return `${MONTHS[date.getMonth()]} ${date.getDate()}`;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
10
|
export default function UptimeBar({
|
|
17
|
-
title
|
|
11
|
+
title,
|
|
18
12
|
subtitle,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
totalSlots = 90,
|
|
22
|
-
greenColor = 'var(--success, #27ae60)',
|
|
23
|
-
redColor = 'var(--danger, #f64747)',
|
|
24
|
-
emptyColor = 'var(--grey-1, #f5f5f8)',
|
|
25
|
-
barHeight = 28,
|
|
26
|
-
barRadius = 3,
|
|
13
|
+
items = [],
|
|
14
|
+
value,
|
|
27
15
|
leftLabel,
|
|
28
16
|
rightLabel,
|
|
17
|
+
legend,
|
|
29
18
|
showDot = true,
|
|
30
|
-
dotColor,
|
|
19
|
+
dotColor = 'var(--success, #27ae60)',
|
|
20
|
+
barHeight = 28,
|
|
21
|
+
barRadius = 3,
|
|
22
|
+
emptyColor = 'var(--grey-1, #f5f5f8)',
|
|
31
23
|
style,
|
|
24
|
+
renderTooltip,
|
|
32
25
|
}) {
|
|
33
26
|
const [hovered, setHovered] = useState(null);
|
|
34
27
|
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });
|
|
35
28
|
const containerRef = useRef(null);
|
|
36
29
|
|
|
37
|
-
const bars = useMemo(() => {
|
|
38
|
-
const len = Math.max(successValues.length, failedValues.length, totalSlots);
|
|
39
|
-
const result = [];
|
|
40
|
-
const stepS = successValues.length / len;
|
|
41
|
-
const stepF = failedValues.length / len;
|
|
42
|
-
for (let i = 0; i < len; i++) {
|
|
43
|
-
const si = Math.min(Math.floor(i * stepS), successValues.length - 1);
|
|
44
|
-
const fi = Math.min(Math.floor(i * stepF), failedValues.length - 1);
|
|
45
|
-
const s = successValues[si] || 0;
|
|
46
|
-
const f = failedValues[fi] || 0;
|
|
47
|
-
result.push({ success: s, failed: f });
|
|
48
|
-
}
|
|
49
|
-
return result;
|
|
50
|
-
}, [successValues, failedValues, totalSlots]);
|
|
51
|
-
|
|
52
|
-
const dates = useMemo(() => {
|
|
53
|
-
const result = [];
|
|
54
|
-
const now = new Date();
|
|
55
|
-
for (let i = 0; i < bars.length; i++) {
|
|
56
|
-
const d = new Date(now);
|
|
57
|
-
d.setDate(d.getDate() - (bars.length - 1 - i));
|
|
58
|
-
result.push(d);
|
|
59
|
-
}
|
|
60
|
-
return result;
|
|
61
|
-
}, [bars.length]);
|
|
62
|
-
|
|
63
|
-
const totalSuccess = bars.reduce((a, b) => a + b.success, 0);
|
|
64
|
-
const totalFailed = bars.reduce((a, b) => a + b.failed, 0);
|
|
65
|
-
const totalAll = totalSuccess + totalFailed;
|
|
66
|
-
const uptimePercent = totalAll > 0 ? Math.round((totalSuccess / totalAll) * 100) : 100;
|
|
67
|
-
|
|
68
30
|
function handleMouseEnter(e, idx) {
|
|
69
31
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
70
32
|
const containerRect = containerRef.current.getBoundingClientRect();
|
|
@@ -75,126 +37,99 @@ export default function UptimeBar({
|
|
|
75
37
|
setHovered(idx);
|
|
76
38
|
}
|
|
77
39
|
|
|
40
|
+
// Smart tooltip shift so edge items are never clipped
|
|
41
|
+
const tooltipShift = hovered !== null
|
|
42
|
+
? (hovered > items.length - 8 ? '-92%' : hovered < 8 ? '-8%' : '-50%')
|
|
43
|
+
: '-50%';
|
|
44
|
+
|
|
78
45
|
return (
|
|
79
46
|
<div ref={containerRef} style={{ ...card, padding: '14px 18px 14px', position: 'relative', ...style }}>
|
|
80
47
|
{/* Header */}
|
|
81
|
-
|
|
82
|
-
<div style={{ display: 'flex', alignItems: 'center',
|
|
83
|
-
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
48
|
+
{(title || subtitle || (Array.isArray(legend) && legend.length > 0)) && (
|
|
49
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
|
50
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
51
|
+
{showDot && (
|
|
52
|
+
<span style={{
|
|
53
|
+
width: 8, height: 8, borderRadius: '50%',
|
|
54
|
+
background: dotColor, flexShrink: 0,
|
|
55
|
+
}} />
|
|
56
|
+
)}
|
|
57
|
+
{title && (
|
|
58
|
+
<span style={{ fontSize: '.78rem', fontWeight: 700, color: 'var(--primary-text, #0f172a)', letterSpacing: '-.01em' }}>
|
|
59
|
+
{title}
|
|
60
|
+
</span>
|
|
61
|
+
)}
|
|
62
|
+
{subtitle && (
|
|
63
|
+
<span style={{ fontSize: '.65rem', color: 'var(--muted, #797b8d)', fontWeight: 500 }}>
|
|
64
|
+
{subtitle}
|
|
65
|
+
</span>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
{/* Legend */}
|
|
69
|
+
{Array.isArray(legend) && legend.length > 0 && (
|
|
70
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
71
|
+
{legend.map((item, idx) => (
|
|
72
|
+
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
73
|
+
<span style={{ width: 7, height: 7, borderRadius: 2, background: item.color, flexShrink: 0 }} />
|
|
74
|
+
<span style={{ fontSize: '.55rem', color: 'var(--muted, #797b8d)', fontWeight: 500 }}>{item.label}</span>
|
|
75
|
+
</div>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
88
78
|
)}
|
|
89
|
-
<span style={{ fontSize: '.78rem', fontWeight: 700, color: 'var(--primary-text, #0f172a)', letterSpacing: '-.01em' }}>{title}</span>
|
|
90
|
-
{subtitle && <span style={{ fontSize: '.65rem', color: 'var(--muted, #797b8d)', fontWeight: 500 }}>{subtitle}</span>}
|
|
91
|
-
</div>
|
|
92
|
-
{/* Legend */}
|
|
93
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
94
|
-
<LegendDot color={greenColor} label="Success" />
|
|
95
|
-
<LegendDot color={redColor} label="Failed" />
|
|
96
|
-
<LegendDot color={emptyColor} label="None" />
|
|
97
79
|
</div>
|
|
98
|
-
|
|
80
|
+
)}
|
|
99
81
|
|
|
100
|
-
{/* Bars */}
|
|
82
|
+
{/* Bars: purely renders precomputed items */}
|
|
101
83
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: barHeight, marginBottom: 6 }}>
|
|
102
|
-
{
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
height: '100%',
|
|
116
|
-
borderRadius: barRadius,
|
|
117
|
-
overflow: 'hidden',
|
|
118
|
-
display: 'flex',
|
|
119
|
-
flexDirection: 'column',
|
|
120
|
-
background: hasData ? 'transparent' : emptyColor,
|
|
121
|
-
}}
|
|
122
|
-
>
|
|
123
|
-
{hasData && (
|
|
124
|
-
<>
|
|
125
|
-
{/* Failed on bottom */}
|
|
126
|
-
{b.failed > 0 && (
|
|
127
|
-
<div style={{ height: `${failedPct}%`, background: redColor, marginTop: 'auto' }} />
|
|
128
|
-
)}
|
|
129
|
-
{/* Success on top */}
|
|
130
|
-
{b.success > 0 && (
|
|
131
|
-
<div style={{ height: `${successPct}%`, background: greenColor }} />
|
|
132
|
-
)}
|
|
133
|
-
</>
|
|
134
|
-
)}
|
|
135
|
-
</div>
|
|
136
|
-
);
|
|
137
|
-
})}
|
|
84
|
+
{items.map((item, i) => (
|
|
85
|
+
<div
|
|
86
|
+
key={item.id || i}
|
|
87
|
+
onMouseEnter={(e) => handleMouseEnter(e, i)}
|
|
88
|
+
onMouseLeave={() => setHovered(null)}
|
|
89
|
+
style={{
|
|
90
|
+
flex: 1,
|
|
91
|
+
height: '100%',
|
|
92
|
+
borderRadius: barRadius,
|
|
93
|
+
background: item.color || emptyColor,
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
))}
|
|
138
97
|
</div>
|
|
139
98
|
|
|
140
|
-
{/* Tooltip */}
|
|
141
|
-
{hovered !== null && (
|
|
99
|
+
{/* Tooltip: renders caller content only */}
|
|
100
|
+
{hovered !== null && items[hovered] && (renderTooltip || items[hovered].tooltip) && (
|
|
142
101
|
<div style={{
|
|
143
102
|
position: 'absolute',
|
|
144
103
|
left: tooltipPos.x,
|
|
145
104
|
top: tooltipPos.y,
|
|
146
|
-
transform:
|
|
105
|
+
transform: `translate(${tooltipShift}, -100%)`,
|
|
147
106
|
background: 'var(--surface,#fff)',
|
|
148
107
|
border: '1px solid var(--border, #e2e8f0)',
|
|
149
108
|
borderRadius: 'var(--rads, 8px)',
|
|
150
109
|
boxShadow: 'var(--smd, 0 4px 12px rgba(0,0,0,.12))',
|
|
151
110
|
padding: '8px 12px',
|
|
152
111
|
pointerEvents: 'none',
|
|
153
|
-
zIndex:
|
|
112
|
+
zIndex: 1000,
|
|
154
113
|
whiteSpace: 'nowrap',
|
|
155
114
|
}}>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
<div style={{ fontSize: '.7rem', fontWeight: 700, color: greenColor }}>
|
|
162
|
-
{bars[hovered].success} success login{bars[hovered].success !== 1 ? 's' : ''}
|
|
163
|
-
{bars[hovered].failed > 0 && <span style={{ fontWeight: 500, color: 'var(--muted, #797b8d)' }}> ({Math.round((bars[hovered].success / (bars[hovered].success + bars[hovered].failed)) * 100)}%)</span>}
|
|
164
|
-
</div>
|
|
165
|
-
)}
|
|
166
|
-
{bars[hovered].failed > 0 && (
|
|
167
|
-
<div style={{ fontSize: '.7rem', fontWeight: 700, color: redColor }}>
|
|
168
|
-
{bars[hovered].failed} failed login{bars[hovered].failed !== 1 ? 's' : ''}
|
|
169
|
-
{bars[hovered].success > 0 && <span style={{ fontWeight: 500, color: 'var(--muted, #797b8d)' }}> ({Math.round((bars[hovered].failed / (bars[hovered].success + bars[hovered].failed)) * 100)}%)</span>}
|
|
170
|
-
</div>
|
|
171
|
-
)}
|
|
172
|
-
{bars[hovered].success === 0 && bars[hovered].failed === 0 && (
|
|
173
|
-
<div style={{ fontSize: '.7rem', fontWeight: 500, color: 'var(--muted, #797b8d)' }}>
|
|
174
|
-
No logins
|
|
175
|
-
</div>
|
|
176
|
-
)}
|
|
177
|
-
</div>
|
|
115
|
+
{renderTooltip
|
|
116
|
+
? renderTooltip(items[hovered], hovered)
|
|
117
|
+
: typeof items[hovered].tooltip === 'function'
|
|
118
|
+
? items[hovered].tooltip(items[hovered], hovered)
|
|
119
|
+
: items[hovered].tooltip}
|
|
178
120
|
</div>
|
|
179
121
|
)}
|
|
180
122
|
|
|
181
123
|
{/* Footer */}
|
|
182
124
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
183
|
-
<span style={{ fontSize: '.58rem', color: 'var(--muted, #797b8d)', fontWeight: 500 }}>{leftLabel
|
|
184
|
-
|
|
185
|
-
{
|
|
186
|
-
|
|
187
|
-
|
|
125
|
+
<span style={{ fontSize: '.58rem', color: 'var(--muted, #797b8d)', fontWeight: 500 }}>{leftLabel}</span>
|
|
126
|
+
{value && (
|
|
127
|
+
<span style={{ fontSize: '.78rem', fontWeight: 700, color: dotColor, fontFamily: 'var(--mono, ui-monospace, monospace)' }}>
|
|
128
|
+
{value}
|
|
129
|
+
</span>
|
|
130
|
+
)}
|
|
131
|
+
<span style={{ fontSize: '.58rem', color: 'var(--muted, #797b8d)', fontWeight: 500 }}>{rightLabel}</span>
|
|
188
132
|
</div>
|
|
189
133
|
</div>
|
|
190
134
|
);
|
|
191
135
|
}
|
|
192
|
-
|
|
193
|
-
function LegendDot({ color, label }) {
|
|
194
|
-
return (
|
|
195
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
196
|
-
<span style={{ width: 7, height: 7, borderRadius: 2, background: color, flexShrink: 0 }} />
|
|
197
|
-
<span style={{ fontSize: '.55rem', color: 'var(--muted, #797b8d)', fontWeight: 500 }}>{label}</span>
|
|
198
|
-
</div>
|
|
199
|
-
);
|
|
200
|
-
}
|