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.
@@ -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 heightVal = size === 'xs' ? 24 : size === 'sm' ? 28 : 32;
162
- const paddingVal = size === 'xs' ? '0 6px' : size === 'sm' ? '0 8px' : '0 10px';
163
- const fontSizeVal = size === 'xs' ? '11px' : size === 'sm' ? '11.5px' : '13px';
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
- : (size === 'xs' ? '4px' : size === 'sm' ? '6px' : 'var(--rmd,var(--rads, 8px))');
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: size === 'xs' ? '2px 5px' : size === 'sm' ? '3px 6px' : '7px 10px',
314
- height: size === 'xs' ? 22 : size === 'sm' ? 26 : 32,
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: size === 'xs' ? '3px' : size === 'sm' ? '4px' : 'var(--rmd,var(--rads, 8px))',
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: size === 'xs' ? '10.5px' : size === 'sm' ? '11.5px' : '12.5px',
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: size === 'xs' ? 18 : size === 'sm' ? 22 : 28,
343
- padding: size === 'xs' ? '3px 5px' : size === 'sm' ? '4.5px 7px' : '8px 10px',
344
- borderRadius: size === 'xs' ? '3px' : size === 'sm' ? '4px' : 'var(--rmd,var(--rads, 8px))',
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: size === 'xs' ? '10.5px' : size === 'sm' ? '11.5px' : '12.5px',
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 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
- }
13
+ @keyframes cynxTimePickerFadeInDown {
14
+ from { opacity: 0; transform: translateY(-4px); }
15
+ to { opacity: 1; transform: translateY(0); }
15
16
  }
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
- }
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 = 'sm', // 'xs' | 'sm' | 'md'
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
- setRendered(true);
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 (!rendered) return;
275
- calcPos();
276
- }, [rendered, calcPos]);
261
+ if (open) {
262
+ calcPos();
263
+ requestAnimationFrame(calcPos);
264
+ }
265
+ }, [open, calcPos]);
277
266
 
278
267
  useEffect(() => {
279
- if (!rendered || closing) return;
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
- const timer = setTimeout(() => {
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
- }, [rendered, closing, calcPos, closePicker]);
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
- : (size === 'xs' ? '24px' : (size === 'sm' ? '32px' : '38px'));
524
- const fontSize = (size === 'xs' || (style?.height && parseInt(style.height) <= 26)) ? '.72rem' : (size === 'sm' ? '.78rem' : '.85rem');
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={size === 'sm' ? 14 : 16} />
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 ? (size === 'xs' ? 22 : 28) : (size === 'xs' ? 6 : 10),
585
- paddingRight: size === 'xs' ? 18 : 26,
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
- <button
609
- type="button"
610
- onClick={clearTime}
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
- transition: 'color .14s ease',
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={13} />
627
- </button>
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
- {rendered && createPortal(
634
+ {open && createPortal(
644
635
  <div
645
636
  ref={dropRef}
646
- className={`cynx-timepicker-drop ${closing ? 'closing' : ''}`}
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 */}
@@ -1,4 +1,4 @@
1
- import { useMemo, useState, useRef } from 'react';
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 = 'Uptime',
11
+ title,
18
12
  subtitle,
19
- successValues = [],
20
- failedValues = [],
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
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
82
- <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
83
- {showDot && (
84
- <span style={{
85
- width: 8, height: 8, borderRadius: '50%',
86
- background: dotColor || greenColor, flexShrink: 0,
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
- </div>
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
- {bars.map((b, i) => {
103
- const total = b.success + b.failed;
104
- const hasData = total > 0;
105
- const successPct = hasData ? (b.success / total) * 100 : 0;
106
- const failedPct = hasData ? (b.failed / total) * 100 : 0;
107
-
108
- return (
109
- <div
110
- key={i}
111
- onMouseEnter={(e) => handleMouseEnter(e, i)}
112
- onMouseLeave={() => setHovered(null)}
113
- style={{
114
- flex: 1,
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: 'translate(-50%, -100%)',
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: 100,
112
+ zIndex: 1000,
154
113
  whiteSpace: 'nowrap',
155
114
  }}>
156
- <div style={{ fontSize: '.65rem', color: 'var(--muted, #797b8d)', fontWeight: 500, marginBottom: 4 }}>
157
- {formatDate(dates[hovered])}
158
- </div>
159
- <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
160
- {bars[hovered].success > 0 && (
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 || '90 days ago'}</span>
184
- <span style={{ fontSize: '.78rem', fontWeight: 700, color: greenColor, fontFamily: 'var(--mono, ui-monospace, monospace)' }}>
185
- {uptimePercent}%
186
- </span>
187
- <span style={{ fontSize: '.58rem', color: 'var(--muted, #797b8d)', fontWeight: 500 }}>{rightLabel || 'Today'}</span>
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
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cynx-ui",
3
- "version": "1.3.21",
3
+ "version": "1.3.23",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.js"