cynx-ui 1.2.17 → 1.2.18

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,167 +1,167 @@
1
- import { useState, useRef, useEffect, useCallback } from 'react';
2
- import { createPortal } from 'react-dom';
3
- import { ChevronDown, Search, Check, X } from 'lucide-react';
4
-
5
- export default function MultiSelect({ value = [], onChange, options = [], placeholder = 'Select...' }) {
6
- const [open, setOpen] = useState(false);
7
- const [query, setQuery] = useState('');
8
- const [rect, setRect] = useState(null);
9
- const btnRef = useRef(null);
10
- const menuRef = useRef(null);
11
- const inputRef = useRef(null);
12
-
13
- const selected = value || [];
14
-
15
- const filtered = query
16
- ? options.filter(o => o.label.toLowerCase().includes(query.toLowerCase()))
17
- : options;
18
-
19
- const updateRect = useCallback(() => {
20
- if (btnRef.current) setRect(btnRef.current.getBoundingClientRect());
21
- }, []);
22
-
23
- useEffect(() => {
24
- if (!open) return;
25
- function close(e) {
26
- if (!btnRef.current?.contains(e.target) && !menuRef.current?.contains(e.target)) {
27
- setOpen(false);
28
- setQuery('');
29
- }
30
- }
31
- const timer = setTimeout(() => {
32
- document.addEventListener('mousedown', close, true);
33
- }, 0);
34
- return () => {
35
- clearTimeout(timer);
36
- document.removeEventListener('mousedown', close, true);
37
- };
38
- }, [open]);
39
-
40
- useEffect(() => {
41
- if (!open) return;
42
- window.addEventListener('scroll', updateRect, true);
43
- window.addEventListener('resize', updateRect);
44
- return () => {
45
- window.removeEventListener('scroll', updateRect, true);
46
- window.removeEventListener('resize', updateRect);
47
- };
48
- }, [open, updateRect]);
49
-
50
- useEffect(() => {
51
- if (open) { updateRect(); setTimeout(() => inputRef.current?.focus(), 50); }
52
- }, [open, updateRect]);
53
-
54
- function toggle(opt) {
55
- const isSelected = selected.some(s => s.value === opt.value);
56
- if (isSelected) {
57
- onChange(selected.filter(s => s.value !== opt.value));
58
- } else {
59
- onChange([...selected, opt]);
60
- }
61
- }
62
-
63
- function remove(val) {
64
- onChange(selected.filter(s => s.value !== val));
65
- }
66
-
67
- function toggleOpen() {
68
- updateRect();
69
- setOpen(o => !o);
70
- if (open) setQuery('');
71
- }
72
-
73
- const menu = open && rect && createPortal(
74
- <div
75
- ref={menuRef}
76
- style={{
77
- position: 'fixed',
78
- top: rect.bottom + 4,
79
- left: rect.left,
80
- width: rect.width,
81
- zIndex: 999999,
82
- background: 'var(--surface)',
83
- border: '1px solid var(--border)',
84
- borderRadius: 'var(--rads)',
85
- boxShadow: 'var(--shadow-lg)',
86
- overflow: 'hidden',
87
- animation: 'dropdownOpen .12s ease',
88
- }}>
89
- <div style={{ padding: '6px 8px', borderBottom: '1px solid var(--border-light)', display: 'flex', alignItems: 'center', gap: 6 }}>
90
- <Search size={12} style={{ color: 'var(--muted)', flexShrink: 0 }} />
91
- <input
92
- ref={inputRef}
93
- value={query}
94
- onChange={e => setQuery(e.target.value)}
95
- placeholder="Search..."
96
- style={{ border: 'none', background: 'transparent', outline: 'none', fontSize: '.78rem', color: 'var(--primary-text)', width: '100%', fontFamily: 'var(--sans)' }}
97
- />
98
- </div>
99
- <div style={{ maxHeight: 200, overflowY: 'auto', scrollbarWidth: 'thin' }}>
100
- {filtered.length === 0 ? (
101
- <div style={{ padding: '10px 12px', fontSize: '.78rem', color: 'var(--muted)', textAlign: 'center' }}>No results</div>
102
- ) : filtered.map(o => {
103
- const isSelected = selected.some(s => s.value === o.value);
104
- return (
105
- <div key={o.value}
106
- onMouseDown={e => e.stopPropagation()}
107
- onClick={() => toggle(o)} style={{
108
- display: 'flex', alignItems: 'center', justifyContent: 'space-between',
109
- padding: '8px 12px', fontSize: '.8rem', cursor: 'pointer',
110
- background: isSelected ? 'var(--accent-bg)' : 'transparent',
111
- color: isSelected ? 'var(--accent)' : 'var(--primary-text)',
112
- fontWeight: isSelected ? 500 : 400,
113
- transition: 'background .1s',
114
- }}
115
- onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--hover-bg)'; }}
116
- onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent'; }}>
117
- <span>{o.label}</span>
118
- {isSelected && <Check size={12} />}
119
- </div>
120
- );
121
- })}
122
- </div>
123
- </div>,
124
- document.body
125
- );
126
-
127
- return (
128
- <div ref={btnRef} style={{ position: 'relative' }}>
129
- <button type="button" onClick={toggleOpen} style={{
130
- display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 6,
131
- width: '100%', padding: '6px 10px', minHeight: 34,
132
- border: `1px solid ${open ? 'var(--accent)' : 'var(--border)'}`,
133
- borderRadius: 'var(--rads)',
134
- background: 'var(--surface)',
135
- boxShadow: open ? '0 0 0 3px rgba(249,158,44,.12)' : 'var(--shadow)',
136
- cursor: 'pointer', fontSize: '.8rem', fontFamily: 'var(--sans)',
137
- transition: 'border-color .15s, box-shadow .15s',
138
- }}
139
- onMouseEnter={e => { if (!open) e.currentTarget.style.borderColor = 'var(--accent)'; }}
140
- onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border)'; }}>
141
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, flex: 1, minWidth: 0 }}>
142
- {selected.length === 0 ? (
143
- <span style={{ color: 'var(--muted)', fontSize: '.8rem' }}>{placeholder}</span>
144
- ) : selected.map(s => (
145
- <span key={s.value} style={{
146
- display: 'inline-flex', alignItems: 'center', gap: 4,
147
- padding: '2px 6px', borderRadius: 'var(--radxs)',
148
- background: 'var(--accent-bg)', color: 'var(--accent)',
149
- fontSize: '.72rem', fontWeight: 500,
150
- border: '1px solid var(--accent-border)',
151
- }}>
152
- {s.label}
153
- <span
154
- role="button"
155
- onClick={e => { e.stopPropagation(); remove(s.value); }}
156
- style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', lineHeight: 1 }}>
157
- <X size={10} />
158
- </span>
159
- </span>
160
- ))}
161
- </div>
162
- <ChevronDown size={13} style={{ color: 'var(--muted)', flexShrink: 0, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s' }} />
163
- </button>
164
- {menu}
165
- </div>
166
- );
167
- }
1
+ import { useState, useRef, useEffect, useCallback } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { ChevronDown, Search, Check, X } from 'lucide-react';
4
+
5
+ export default function MultiSelect({ value = [], onChange, options = [], placeholder = 'Select...' }) {
6
+ const [open, setOpen] = useState(false);
7
+ const [query, setQuery] = useState('');
8
+ const [rect, setRect] = useState(null);
9
+ const btnRef = useRef(null);
10
+ const menuRef = useRef(null);
11
+ const inputRef = useRef(null);
12
+
13
+ const selected = value || [];
14
+
15
+ const filtered = query
16
+ ? options.filter(o => o.label.toLowerCase().includes(query.toLowerCase()))
17
+ : options;
18
+
19
+ const updateRect = useCallback(() => {
20
+ if (btnRef.current) setRect(btnRef.current.getBoundingClientRect());
21
+ }, []);
22
+
23
+ useEffect(() => {
24
+ if (!open) return;
25
+ function close(e) {
26
+ if (!btnRef.current?.contains(e.target) && !menuRef.current?.contains(e.target)) {
27
+ setOpen(false);
28
+ setQuery('');
29
+ }
30
+ }
31
+ const timer = setTimeout(() => {
32
+ document.addEventListener('mousedown', close, true);
33
+ }, 0);
34
+ return () => {
35
+ clearTimeout(timer);
36
+ document.removeEventListener('mousedown', close, true);
37
+ };
38
+ }, [open]);
39
+
40
+ useEffect(() => {
41
+ if (!open) return;
42
+ window.addEventListener('scroll', updateRect, true);
43
+ window.addEventListener('resize', updateRect);
44
+ return () => {
45
+ window.removeEventListener('scroll', updateRect, true);
46
+ window.removeEventListener('resize', updateRect);
47
+ };
48
+ }, [open, updateRect]);
49
+
50
+ useEffect(() => {
51
+ if (open) { updateRect(); setTimeout(() => inputRef.current?.focus(), 50); }
52
+ }, [open, updateRect]);
53
+
54
+ function toggle(opt) {
55
+ const isSelected = selected.some(s => s.value === opt.value);
56
+ if (isSelected) {
57
+ onChange(selected.filter(s => s.value !== opt.value));
58
+ } else {
59
+ onChange([...selected, opt]);
60
+ }
61
+ }
62
+
63
+ function remove(val) {
64
+ onChange(selected.filter(s => s.value !== val));
65
+ }
66
+
67
+ function toggleOpen() {
68
+ updateRect();
69
+ setOpen(o => !o);
70
+ if (open) setQuery('');
71
+ }
72
+
73
+ const menu = open && rect && createPortal(
74
+ <div
75
+ ref={menuRef}
76
+ style={{
77
+ position: 'fixed',
78
+ top: rect.bottom + 4,
79
+ left: rect.left,
80
+ width: rect.width,
81
+ zIndex: 999999,
82
+ background: 'var(--surface)',
83
+ border: '1px solid var(--border)',
84
+ borderRadius: 'var(--rads)',
85
+ boxShadow: 'var(--shadow-lg)',
86
+ overflow: 'hidden',
87
+ animation: 'dropdownOpen .12s ease',
88
+ }}>
89
+ <div style={{ padding: '6px 8px', borderBottom: '1px solid var(--border-light)', display: 'flex', alignItems: 'center', gap: 6 }}>
90
+ <Search size={12} style={{ color: 'var(--muted)', flexShrink: 0 }} />
91
+ <input
92
+ ref={inputRef}
93
+ value={query}
94
+ onChange={e => setQuery(e.target.value)}
95
+ placeholder="Search..."
96
+ style={{ border: 'none', background: 'transparent', outline: 'none', fontSize: '.78rem', color: 'var(--primary-text)', width: '100%', fontFamily: 'var(--sans)' }}
97
+ />
98
+ </div>
99
+ <div style={{ maxHeight: 200, overflowY: 'auto', scrollbarWidth: 'thin' }}>
100
+ {filtered.length === 0 ? (
101
+ <div style={{ padding: '10px 12px', fontSize: '.78rem', color: 'var(--muted)', textAlign: 'center' }}>No results</div>
102
+ ) : filtered.map(o => {
103
+ const isSelected = selected.some(s => s.value === o.value);
104
+ return (
105
+ <div key={o.value}
106
+ onMouseDown={e => e.stopPropagation()}
107
+ onClick={() => toggle(o)} style={{
108
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
109
+ padding: '8px 12px', fontSize: '.8rem', cursor: 'pointer',
110
+ background: isSelected ? 'var(--accent-bg)' : 'transparent',
111
+ color: isSelected ? 'var(--accent)' : 'var(--primary-text)',
112
+ fontWeight: isSelected ? 500 : 400,
113
+ transition: 'background .1s',
114
+ }}
115
+ onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--hover-bg)'; }}
116
+ onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent'; }}>
117
+ <span>{o.label}</span>
118
+ {isSelected && <Check size={12} />}
119
+ </div>
120
+ );
121
+ })}
122
+ </div>
123
+ </div>,
124
+ document.body
125
+ );
126
+
127
+ return (
128
+ <div ref={btnRef} style={{ position: 'relative' }}>
129
+ <button type="button" onClick={toggleOpen} style={{
130
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 6,
131
+ width: '100%', padding: '6px 10px', minHeight: 34,
132
+ border: `1px solid ${open ? 'var(--accent)' : 'var(--border)'}`,
133
+ borderRadius: 'var(--rads)',
134
+ background: 'var(--surface)',
135
+ boxShadow: open ? '0 0 0 3px rgba(249,158,44,.12)' : 'var(--shadow)',
136
+ cursor: 'pointer', fontSize: '.8rem', fontFamily: 'var(--sans)',
137
+ transition: 'border-color .15s, box-shadow .15s',
138
+ }}
139
+ onMouseEnter={e => { if (!open) e.currentTarget.style.borderColor = 'var(--accent)'; }}
140
+ onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border)'; }}>
141
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, flex: 1, minWidth: 0 }}>
142
+ {selected.length === 0 ? (
143
+ <span style={{ color: 'var(--muted)', fontSize: '.8rem' }}>{placeholder}</span>
144
+ ) : selected.map(s => (
145
+ <span key={s.value} style={{
146
+ display: 'inline-flex', alignItems: 'center', gap: 4,
147
+ padding: '2px 6px', borderRadius: 'var(--radxs)',
148
+ background: 'var(--accent-bg)', color: 'var(--accent)',
149
+ fontSize: '.72rem', fontWeight: 500,
150
+ border: '1px solid var(--accent-border)',
151
+ }}>
152
+ {s.label}
153
+ <span
154
+ role="button"
155
+ onClick={e => { e.stopPropagation(); remove(s.value); }}
156
+ style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', lineHeight: 1 }}>
157
+ <X size={10} />
158
+ </span>
159
+ </span>
160
+ ))}
161
+ </div>
162
+ <ChevronDown size={13} style={{ color: 'var(--muted)', flexShrink: 0, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s' }} />
163
+ </button>
164
+ {menu}
165
+ </div>
166
+ );
167
+ }
@@ -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
+ }