cynx-ui 1.2.15 → 1.2.17

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