cynx-ui 1.2.6 → 1.2.8

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.
@@ -1,109 +1,109 @@
1
- import { useEffect, useRef } from 'react';
2
-
3
- const SIZES = {
4
- xs: { height: 4, fontSize: '.68rem' },
5
- sm: { height: 5, fontSize: '.75rem' },
6
- md: { height: 6, fontSize: '.82rem' },
7
- lg: { height: 8, fontSize: '.9rem' },
8
- xl: { height: 10, fontSize: '1rem' },
9
- };
10
-
11
- export default function LineProgress({
12
- value = 0,
13
- size = 'md',
14
- customSize,
15
- color = 'var(--blue)',
16
- trackColor = '#e0e0e4',
17
- label = '',
18
- showPercent = true,
19
- animated = true,
20
- rounded = true,
21
- indeterminate = false,
22
- speed = 1,
23
- height,
24
- gap = 5,
25
- labelGap = 2,
26
- className = '',
27
- style = {},
28
- }) {
29
- const base = SIZES[size] || SIZES.md;
30
- const barHeight = height ?? (customSize || base.height);
31
- const fontSize = customSize ? `${Math.max(11, customSize * 1.6)}px` : base.fontSize;
32
- const pct = Math.min(100, Math.max(0, value));
33
- const radius = rounded ? barHeight / 2 : 0;
34
-
35
- const barRef = useRef(null);
36
-
37
- // Indeterminate animation
38
- useEffect(() => {
39
- if (!indeterminate || !barRef.current) return;
40
- let pos = -40, direction = 1, raf;
41
- let lastTime = performance.now();
42
-
43
- function tick(now) {
44
- const dt = (now - lastTime) / 16.67;
45
- lastTime = now;
46
- pos += direction * 1.2 * speed * dt;
47
- if (pos > 100) { pos = 100; direction = -1; }
48
- if (pos < -40) { pos = -40; direction = 1; }
49
- if (barRef.current) {
50
- barRef.current.style.left = pos + '%';
51
- barRef.current.style.width = '40%';
52
- }
53
- raf = requestAnimationFrame(tick);
54
- }
55
- raf = requestAnimationFrame(tick);
56
- return () => cancelAnimationFrame(raf);
57
- }, [indeterminate, speed]);
58
-
59
- const transitionStyle = animated ? 'flex .5s cubic-bezier(0.4,0,0.2,1)' : 'none';
60
-
61
- return (
62
- <div className={className} style={{ display: 'inline-flex', flexDirection: 'column', gap: labelGap, width: '100%', ...style }}>
63
- {(label || showPercent) && (
64
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
65
- {label && <span style={{ fontSize, fontWeight: 600, color: 'var(--primary-text, #1a1a2e)' }}>{label}</span>}
66
- {showPercent && !indeterminate && (
67
- <span style={{ fontSize, fontWeight: 600, color: 'var(--muted, #9ca3af)', fontVariantNumeric: 'tabular-nums', marginLeft: label ? 0 : 'auto' }}>
68
- {Math.round(pct)}%
69
- </span>
70
- )}
71
- </div>
72
- )}
73
- <div style={{
74
- height: barHeight, borderRadius: radius,
75
- background: 'transparent', position: 'relative',
76
- display: 'flex', gap: gap, overflow: 'hidden',
77
- }}>
78
- {indeterminate ? (
79
- <>
80
- <div style={{ flex: 1, borderRadius: radius, background: trackColor }} />
81
- <div
82
- ref={barRef}
83
- style={{
84
- position: 'absolute', top: 0, height: '100%',
85
- width: '40%', borderRadius: radius,
86
- background: color, opacity: 0.85,
87
- }}
88
- />
89
- </>
90
- ) : (
91
- <>
92
- <div ref={barRef} style={{
93
- flex: pct > 0 ? pct : 0, minWidth: pct > 0 ? barHeight : 0,
94
- borderRadius: radius, background: color,
95
- transition: transitionStyle,
96
- }} />
97
- {pct < 100 && (
98
- <div style={{
99
- flex: 100 - pct,
100
- borderRadius: radius, background: trackColor,
101
- transition: transitionStyle,
102
- }} />
103
- )}
104
- </>
105
- )}
106
- </div>
107
- </div>
108
- );
109
- }
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ const SIZES = {
4
+ xs: { height: 4, fontSize: '.68rem' },
5
+ sm: { height: 5, fontSize: '.75rem' },
6
+ md: { height: 6, fontSize: '.82rem' },
7
+ lg: { height: 8, fontSize: '.9rem' },
8
+ xl: { height: 10, fontSize: '1rem' },
9
+ };
10
+
11
+ export default function LineProgress({
12
+ value = 0,
13
+ size = 'md',
14
+ customSize,
15
+ color = 'var(--blue)',
16
+ trackColor = '#e0e0e4',
17
+ label = '',
18
+ showPercent = true,
19
+ animated = true,
20
+ rounded = true,
21
+ indeterminate = false,
22
+ speed = 1,
23
+ height,
24
+ gap = 5,
25
+ labelGap = 2,
26
+ className = '',
27
+ style = {},
28
+ }) {
29
+ const base = SIZES[size] || SIZES.md;
30
+ const barHeight = height ?? (customSize || base.height);
31
+ const fontSize = customSize ? `${Math.max(11, customSize * 1.6)}px` : base.fontSize;
32
+ const pct = Math.min(100, Math.max(0, value));
33
+ const radius = rounded ? barHeight / 2 : 0;
34
+
35
+ const barRef = useRef(null);
36
+
37
+ // Indeterminate animation
38
+ useEffect(() => {
39
+ if (!indeterminate || !barRef.current) return;
40
+ let pos = -40, direction = 1, raf;
41
+ let lastTime = performance.now();
42
+
43
+ function tick(now) {
44
+ const dt = (now - lastTime) / 16.67;
45
+ lastTime = now;
46
+ pos += direction * 1.2 * speed * dt;
47
+ if (pos > 100) { pos = 100; direction = -1; }
48
+ if (pos < -40) { pos = -40; direction = 1; }
49
+ if (barRef.current) {
50
+ barRef.current.style.left = pos + '%';
51
+ barRef.current.style.width = '40%';
52
+ }
53
+ raf = requestAnimationFrame(tick);
54
+ }
55
+ raf = requestAnimationFrame(tick);
56
+ return () => cancelAnimationFrame(raf);
57
+ }, [indeterminate, speed]);
58
+
59
+ const transitionStyle = animated ? 'flex .5s cubic-bezier(0.4,0,0.2,1)' : 'none';
60
+
61
+ return (
62
+ <div className={className} style={{ display: 'inline-flex', flexDirection: 'column', gap: labelGap, width: '100%', ...style }}>
63
+ {(label || showPercent) && (
64
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
65
+ {label && <span style={{ fontSize, fontWeight: 600, color: 'var(--primary-text, #1a1a2e)' }}>{label}</span>}
66
+ {showPercent && !indeterminate && (
67
+ <span style={{ fontSize, fontWeight: 600, color: 'var(--muted, #9ca3af)', fontVariantNumeric: 'tabular-nums', marginLeft: label ? 0 : 'auto' }}>
68
+ {Math.round(pct)}%
69
+ </span>
70
+ )}
71
+ </div>
72
+ )}
73
+ <div style={{
74
+ height: barHeight, borderRadius: radius,
75
+ background: 'transparent', position: 'relative',
76
+ display: 'flex', gap: gap, overflow: 'hidden',
77
+ }}>
78
+ {indeterminate ? (
79
+ <>
80
+ <div style={{ flex: 1, borderRadius: radius, background: trackColor }} />
81
+ <div
82
+ ref={barRef}
83
+ style={{
84
+ position: 'absolute', top: 0, height: '100%',
85
+ width: '40%', borderRadius: radius,
86
+ background: color, opacity: 0.85,
87
+ }}
88
+ />
89
+ </>
90
+ ) : (
91
+ <>
92
+ <div ref={barRef} style={{
93
+ flex: pct > 0 ? pct : 0, minWidth: pct > 0 ? barHeight : 0,
94
+ borderRadius: radius, background: color,
95
+ transition: transitionStyle,
96
+ }} />
97
+ {pct < 100 && (
98
+ <div style={{
99
+ flex: 100 - pct,
100
+ borderRadius: radius, background: trackColor,
101
+ transition: transitionStyle,
102
+ }} />
103
+ )}
104
+ </>
105
+ )}
106
+ </div>
107
+ </div>
108
+ );
109
+ }
@@ -2,10 +2,100 @@ import { useState, useEffect, useRef } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import Button from './Button';
4
4
 
5
+ const STYLES = `
6
+ /* ── Modal ─────────────────────────────────────────────────── */
7
+ .modal-backdrop {
8
+ position: fixed; inset: 0;
9
+ background: rgba(20,22,41,.45);
10
+ display: flex; align-items: center; justify-content: center;
11
+ z-index: 8000; padding: 20px;
12
+ }
13
+ .modal {
14
+ background: var(--surface); border: 1px solid var(--border);
15
+ border-radius: 14px; width: 100%; max-width: 480px;
16
+ box-shadow: var(--shadow-lg);
17
+ overflow: visible;
18
+ }
19
+ .modal-hdr {
20
+ display: flex; align-items: center; justify-content: space-between;
21
+ padding: 15px 20px; border-bottom: 1px solid var(--border-light);
22
+ border-radius: 14px 14px 0 0;
23
+ }
24
+ .modal-title { font-size: .88rem; font-weight: 700; letter-spacing: -.01em; }
25
+ .modal-body { padding: 20px; overflow: visible; }
26
+ .modal-footer {
27
+ display: flex; justify-content: flex-end; gap: 8px;
28
+ padding: 13px 20px; border-top: 1px solid var(--border-light);
29
+ background: var(--grey-1); border-radius: 0 0 14px 14px;
30
+ }
31
+ .modal-card {
32
+ background: var(--surface); border: 1px solid var(--border);
33
+ border-radius: 14px; width: 100%; max-width: 480px;
34
+ box-shadow: var(--shadow-lg); overflow: visible;
35
+ display: flex; flex-direction: column;
36
+ }
37
+ .modal-close {
38
+ background: none; border: none; cursor: pointer; padding: 4px; border-radius: 6px;
39
+ color: var(--muted); display: flex; align-items: center; transition: all .14s;
40
+ }
41
+ .modal-close:hover { color: var(--primary-text); background: var(--grey-1); }
42
+ .confirm-icon {
43
+ width: 48px; height: 48px; border-radius: 50%; margin: 0 auto 12px;
44
+ display: flex; align-items: center; justify-content: center;
45
+ }
46
+ .confirm-icon.danger { background: rgba(246,71,71,.1); }
47
+ .confirm-icon.warning { background: rgba(249,158,44,.1); }
48
+ .confirm-icon.info { background: rgba(39,130,228,.1); }
49
+
50
+ /* Modal scale */
51
+ @keyframes modalIn {
52
+ from { opacity: 0; transform: scale(.97); }
53
+ to { opacity: 1; transform: scale(1); }
54
+ }
55
+ .modal { animation: modalIn .16s ease; }
56
+
57
+ /* ── Modal open animation ──────────────────────────────────── */
58
+ @keyframes modalPop {
59
+ from { opacity: 0; transform: scale(.96) translateY(-8px); }
60
+ to { opacity: 1; transform: scale(1) translateY(0); }
61
+ }
62
+ @keyframes backdropFade {
63
+ from { opacity: 0; }
64
+ to { opacity: 1; }
65
+ }
66
+
67
+ /* ── Modal close animations ────────────────────────────────── */
68
+ @keyframes modalClose {
69
+ from { opacity: 1; transform: scale(1) translateY(0); }
70
+ to { opacity: 0; transform: scale(.96) translateY(-8px); }
71
+ }
72
+ @keyframes backdropClose {
73
+ from { opacity: 1; }
74
+ to { opacity: 0; }
75
+ }
76
+
77
+ /* ── cynx-ui Modal keyframes ────────────────────────────────── */
78
+ @keyframes modalFadeIn { from { opacity:0 } to { opacity:1 } }
79
+ @keyframes modalFadeOut { from { opacity:1 } to { opacity:0 } }
80
+ @keyframes modalCardIn { from { opacity:0; transform:scale(.96) translateY(-8px) } to { opacity:1; transform:scale(1) translateY(0) } }
81
+ @keyframes modalCardOut { from { opacity:1; transform:scale(1) translateY(0) } to { opacity:0; transform:scale(.96) translateY(-8px) } }
82
+ `;
83
+
84
+ let injected = false;
85
+ function ensureStyles() {
86
+ if (injected || typeof document === 'undefined') return;
87
+ const tag = document.createElement('style');
88
+ tag.textContent = STYLES;
89
+ document.head.appendChild(tag);
90
+ injected = true;
91
+ }
92
+
5
93
  export function Modal({ open, onClose, onBeforeClose, title, children, footer, maxWidth = 520, bodyStyle }) {
6
94
  const [closing, setClosing] = useState(false);
7
95
  const mounted = useRef(open);
8
96
 
97
+ ensureStyles();
98
+
9
99
  useEffect(() => {
10
100
  if (open) {
11
101
  mounted.current = true;
@@ -41,20 +131,38 @@ export function Modal({ open, onClose, onBeforeClose, title, children, footer, m
41
131
  return createPortal(
42
132
  <div
43
133
  className="modal-backdrop"
44
- style={{ animation: `${closing ? 'modalFadeOut' : 'modalFadeIn'} .26s ease forwards` }}
134
+ style={{
135
+ position: 'fixed', inset: 0,
136
+ background: 'rgba(20,22,41,.45)',
137
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
138
+ zIndex: 8000, padding: 20,
139
+ animation: `${closing ? 'modalFadeOut' : 'modalFadeIn'} .26s ease forwards`,
140
+ }}
45
141
  onMouseDown={e => { if (e.target === e.currentTarget) close(); }}
46
142
  >
47
143
  <div
48
144
  className="modal-card"
49
145
  style={{
50
- maxWidth,
146
+ background: 'var(--surface)', border: '1px solid var(--border)',
147
+ borderRadius: 14, width: '100%', maxWidth,
148
+ boxShadow: '0 20px 60px rgba(0,0,0,.25)',
149
+ overflow: 'visible', display: 'flex', flexDirection: 'column',
51
150
  animation: `${closing ? 'modalCardOut' : 'modalCardIn'} .26s cubic-bezier(.4,0,.2,1) forwards`,
52
151
  }}
53
152
  onMouseDown={e => e.stopPropagation()}
54
153
  >
55
- <div className="modal-hdr">
56
- <span className="modal-title">{title}</span>
57
- <button className="modal-close" onClick={close}>
154
+ <div className="modal-hdr" style={{
155
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
156
+ padding: '15px 20px', borderBottom: '1px solid var(--border-light)',
157
+ borderRadius: '14px 14px 0 0',
158
+ }}>
159
+ <span className="modal-title" style={{ fontSize: '.88rem', fontWeight: 700, letterSpacing: '-.01em' }}>{title}</span>
160
+ <button className="modal-close" onClick={close} style={{
161
+ background: 'none', border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6,
162
+ color: 'var(--muted)', display: 'flex', alignItems: 'center', transition: 'all .14s',
163
+ }}
164
+ onMouseEnter={e => { e.currentTarget.style.color = 'var(--primary-text)'; e.currentTarget.style.background = 'var(--grey-1)'; }}
165
+ onMouseLeave={e => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.background = 'none'; }}>
58
166
  <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor"
59
167
  strokeWidth="2" strokeLinecap="round">
60
168
  <line x1="1.5" y1="1.5" x2="11.5" y2="11.5"/>
@@ -62,8 +170,12 @@ export function Modal({ open, onClose, onBeforeClose, title, children, footer, m
62
170
  </svg>
63
171
  </button>
64
172
  </div>
65
- <div className="modal-body" style={{ overflowX: 'hidden', overflowY: 'auto', flex: 1, minHeight: 0, ...bodyStyle }}>{resolvedChildren}</div>
66
- {resolvedFooter && <div className="modal-footer">{resolvedFooter}</div>}
173
+ <div className="modal-body" style={{ padding: 20, overflowX: 'hidden', overflowY: 'auto', flex: 1, minHeight: 0, ...bodyStyle }}>{resolvedChildren}</div>
174
+ {resolvedFooter && <div className="modal-footer" style={{
175
+ display: 'flex', justifyContent: 'flex-end', gap: 8,
176
+ padding: '13px 20px', borderTop: '1px solid var(--border-light)',
177
+ background: 'var(--grey-1)', borderRadius: '0 0 14px 14px',
178
+ }}>{resolvedFooter}</div>}
67
179
  </div>
68
180
  </div>,
69
181
  document.body
@@ -89,7 +201,11 @@ export function Confirm({ open, onClose, onConfirm, title, message, variant = 'd
89
201
  </>
90
202
  }>
91
203
  <div style={{ textAlign: 'center', padding: '4px 0 8px' }}>
92
- <div className={`confirm-icon ${variant}`}>{icon}</div>
204
+ <div className={`confirm-icon ${variant}`} style={{
205
+ width: 48, height: 48, borderRadius: '50%', margin: '0 auto 12px',
206
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
207
+ background: variant === 'danger' ? 'rgba(246,71,71,.1)' : variant === 'warning' ? 'rgba(249,158,44,.1)' : 'rgba(39,130,228,.1)',
208
+ }}>{icon}</div>
93
209
  <div style={{ fontSize: '.9rem', fontWeight: 700, marginBottom: 8 }}>{title}</div>
94
210
  {message && <div style={{ fontSize: '.8rem', color: 'var(--muted)', lineHeight: 1.5 }}>{message}</div>}
95
211
  </div>
@@ -1,41 +1,41 @@
1
- import Button from './Button';
2
- import { ChevronLeft, ChevronRight } from 'lucide-react';
3
-
4
- export function Pagination({ page, pages, total, perPage, onChange }) {
5
- const totalPages = pages || (total && perPage ? Math.ceil(total / perPage) : 0);
6
- if (!totalPages || totalPages <= 1) return null;
7
-
8
- const items = [];
9
- for (let i = 1; i <= totalPages; i++) {
10
- if (i === 1 || i === totalPages || (i >= page - 1 && i <= page + 1)) items.push(i);
11
- else if (items[items.length - 1] !== '...') items.push('...');
12
- }
13
-
14
- const start = perPage ? (page - 1) * perPage + 1 : null;
15
- const end = perPage ? Math.min(page * perPage, total) : null;
16
-
17
- return (
18
- <div style={{
19
- display: 'flex', alignItems: 'center', justifyContent: 'space-between',
20
- padding: '10px 16px',
21
- borderTop: '1px solid var(--border-light)',
22
- background: 'var(--surface)',
23
- borderRadius: '0 0 var(--rad) var(--rad)',
24
- }}>
25
- {start !== null && (
26
- <div style={{ fontSize: '.72rem', color: 'var(--muted)', fontFamily: 'var(--mono)' }}>
27
- {start}–{end} of {total}
28
- </div>
29
- )}
30
- <div style={{ display: 'flex', gap: 4, alignItems: 'center', marginLeft: start !== null ? 'auto' : 0 }}>
31
- <Button variant="ghost" size="xs" icon={<ChevronLeft size={14} />} onClick={() => onChange(page - 1)} disabled={page <= 1} />
32
- {items.map((item, i) =>
33
- item === '...'
34
- ? <span key={`e${i}`} style={{ padding: '0 4px', color: 'var(--muted)', fontSize: '.78rem' }}>…</span>
35
- : <Button key={item} variant={item === page ? 'primary' : 'no-line'} size="xs" onClick={() => onChange(item)} style={{ minWidth: 28 }}>{item}</Button>
36
- )}
37
- <Button variant="ghost" size="xs" icon={<ChevronRight size={14} />} onClick={() => onChange(page + 1)} disabled={page >= totalPages} />
38
- </div>
39
- </div>
40
- );
41
- }
1
+ import Button from './Button';
2
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
3
+
4
+ export function Pagination({ page, pages, total, perPage, onChange }) {
5
+ const totalPages = pages || (total && perPage ? Math.ceil(total / perPage) : 0);
6
+ if (!totalPages || totalPages <= 1) return null;
7
+
8
+ const items = [];
9
+ for (let i = 1; i <= totalPages; i++) {
10
+ if (i === 1 || i === totalPages || (i >= page - 1 && i <= page + 1)) items.push(i);
11
+ else if (items[items.length - 1] !== '...') items.push('...');
12
+ }
13
+
14
+ const start = perPage ? (page - 1) * perPage + 1 : null;
15
+ const end = perPage ? Math.min(page * perPage, total) : null;
16
+
17
+ return (
18
+ <div style={{
19
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
20
+ padding: '10px 16px',
21
+ borderTop: '1px solid var(--border-light)',
22
+ background: 'var(--surface)',
23
+ borderRadius: '0 0 var(--rad) var(--rad)',
24
+ }}>
25
+ {start !== null && (
26
+ <div style={{ fontSize: '.72rem', color: 'var(--muted)', fontFamily: 'var(--mono)' }}>
27
+ {start}–{end} of {total}
28
+ </div>
29
+ )}
30
+ <div style={{ display: 'flex', gap: 4, alignItems: 'center', marginLeft: start !== null ? 'auto' : 0 }}>
31
+ <Button variant="ghost" size="xs" icon={<ChevronLeft size={14} />} onClick={() => onChange(page - 1)} disabled={page <= 1} />
32
+ {items.map((item, i) =>
33
+ item === '...'
34
+ ? <span key={`e${i}`} style={{ padding: '0 4px', color: 'var(--muted)', fontSize: '.78rem' }}>…</span>
35
+ : <Button key={item} variant={item === page ? 'primary' : 'no-line'} size="xs" onClick={() => onChange(item)} style={{ minWidth: 28 }}>{item}</Button>
36
+ )}
37
+ <Button variant="ghost" size="xs" icon={<ChevronRight size={14} />} onClick={() => onChange(page + 1)} disabled={page >= totalPages} />
38
+ </div>
39
+ </div>
40
+ );
41
+ }
@@ -1,58 +1,76 @@
1
- const STYLES = `
2
- .pdot{position:relative;display:inline-flex;align-items:center;justify-content:center;width:8px;height:8px;flex-shrink:0}
3
- .pdot-core{width:8px;height:8px;border-radius:50%;background:var(--grey-4,#797b8d)}
4
- .pdot-ring{position:absolute;inset:-3px;border-radius:50%;border:2px solid var(--grey-4,#797b8d);opacity:0;animation:pdot-pulse 1.1s ease-out infinite}
5
- .pdot-live .pdot-ring{animation-play-state:running}
6
- .pdot-paused .pdot-ring{animation-play-state:paused}
7
-
8
- .pdot-sm .pdot-core{width:6px;height:6px}
9
- .pdot-sm .pdot-ring{inset:-2px;border-width:1.5px}
10
- .pdot-lg .pdot-core{width:10px;height:10px}
11
- .pdot-lg .pdot-ring{inset:-4px}
12
-
13
- .pdot-ok .pdot-core{background:var(--success,#27ae60)}
14
- .pdot-ok .pdot-ring{border-color:var(--success,#27ae60)}
15
- .pdot-err .pdot-core{background:var(--danger,#f64747)}
16
- .pdot-err .pdot-ring{border-color:var(--danger,#f64747)}
17
- .pdot-warn .pdot-core{background:var(--accent,#f99e2c)}
18
- .pdot-warn .pdot-ring{border-color:var(--accent,#f99e2c)}
19
- .pdot-blue .pdot-core{background:var(--blue,#2782e4)}
20
- .pdot-blue .pdot-ring{border-color:var(--blue,#2782e4)}
21
- .pdot-purple .pdot-core{background:#8b5cf6}
22
- .pdot-purple .pdot-ring{border-color:#8b5cf6}
23
- .pdot-teal .pdot-core{background:#14b8a6}
24
- .pdot-teal .pdot-ring{border-color:#14b8a6}
25
- .pdot-pink .pdot-core{background:#ec4899}
26
- .pdot-pink .pdot-ring{border-color:#ec4899}
27
-
28
- .pdot-off .pdot-core{background:var(--t3,#9b9daa)}
29
- .pdot-off .pdot-ring{display:none}
30
-
31
- @keyframes pdot-pulse{0%{transform:scale(1);opacity:.7}100%{transform:scale(1.3);opacity:0}}
32
- `;
33
-
34
- let styleInjected = false;
35
-
36
- export default function PulseDot({
37
- variant = 'dim',
38
- size = 'md',
39
- pulse = true,
40
- className = '',
41
- style = {},
42
- }) {
43
- if (!styleInjected && typeof document !== 'undefined') {
44
- const tag = document.createElement('style');
45
- tag.textContent = STYLES;
46
- document.head.appendChild(tag);
47
- styleInjected = true;
48
- }
49
-
50
- const classes = `pdot pdot-${variant} pdot-${size}${pulse ? ' pdot-live' : ' pdot-paused'}${className ? ' ' + className : ''}`;
51
-
52
- return (
53
- <span className={classes} style={style}>
54
- <span className="pdot-core" />
55
- <span className="pdot-ring" />
56
- </span>
57
- );
58
- }
1
+ const VARIANTS = {
2
+ dim: { bg: 'var(--grey-4,#797b8d)', ring: 'var(--grey-4,#797b8d)' },
3
+ ok: { bg: 'var(--success,#27ae60)', ring: 'var(--success,#27ae60)' },
4
+ err: { bg: 'var(--danger,#f64747)', ring: 'var(--danger,#f64747)' },
5
+ warn: { bg: 'var(--accent,#f99e2c)', ring: 'var(--accent,#f99e2c)' },
6
+ blue: { bg: 'var(--blue,#2782e4)', ring: 'var(--blue,#2782e4)' },
7
+ purple: { bg: '#8b5cf6', ring: '#8b5cf6' },
8
+ teal: { bg: '#14b8a6', ring: '#14b8a6' },
9
+ pink: { bg: '#ec4899', ring: '#ec4899' },
10
+ off: { bg: 'var(--t3,#9b9daa)', ring: 'var(--t3,#9b9daa)' },
11
+ };
12
+
13
+ const SIZES = {
14
+ sm: { core: 6, ring: { inset: '-2px', borderWidth: '1.5px' } },
15
+ md: { core: 8, ring: { inset: '-3px', borderWidth: '2px' } },
16
+ lg: { core: 10, ring: { inset: '-4px', borderWidth: '2px' } },
17
+ };
18
+
19
+ const KEYFRAMES = `@keyframes pdot-pulse{0%{transform:scale(1);opacity:.7}100%{transform:scale(1.3);opacity:0}}`;
20
+ let styleInjected = false;
21
+
22
+ function ensureStyles() {
23
+ if (styleInjected || typeof document === 'undefined') return;
24
+ const tag = document.createElement('style');
25
+ tag.textContent = KEYFRAMES;
26
+ document.head.appendChild(tag);
27
+ styleInjected = true;
28
+ }
29
+
30
+ export default function PulseDot({
31
+ variant = 'dim',
32
+ size = 'md',
33
+ pulse = true,
34
+ className = '',
35
+ style = {},
36
+ }) {
37
+ ensureStyles();
38
+
39
+ const v = VARIANTS[variant] || VARIANTS.dim;
40
+ const s = SIZES[size] || SIZES.md;
41
+ const isOff = variant === 'off';
42
+
43
+ return (
44
+ <span
45
+ className={className}
46
+ style={{
47
+ position: 'relative',
48
+ display: 'inline-flex',
49
+ alignItems: 'center',
50
+ justifyContent: 'center',
51
+ width: s.core,
52
+ height: s.core,
53
+ flexShrink: 0,
54
+ ...style,
55
+ }}
56
+ >
57
+ <span style={{
58
+ width: s.core,
59
+ height: s.core,
60
+ borderRadius: '50%',
61
+ background: v.bg,
62
+ }} />
63
+ {!isOff && (
64
+ <span style={{
65
+ position: 'absolute',
66
+ inset: s.ring.inset,
67
+ borderRadius: '50%',
68
+ border: `${s.ring.borderWidth} solid ${v.ring}`,
69
+ opacity: 0,
70
+ animation: `pdot-pulse 1.1s ease-out infinite`,
71
+ animationPlayState: pulse ? 'running' : 'paused',
72
+ }} />
73
+ )}
74
+ </span>
75
+ );
76
+ }