cynx-ui 1.3.7 → 1.3.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.
Files changed (2) hide show
  1. package/components/TabBar.jsx +308 -74
  2. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { useRef, useEffect } from 'react';
1
+ import { useState, useRef, useEffect } from 'react';
2
2
 
3
3
  const STYLES = `
4
4
  @keyframes cx-tabSlide {
@@ -19,10 +19,64 @@ function ensureStyles() {
19
19
 
20
20
  if (typeof document !== 'undefined') ensureStyles();
21
21
 
22
+ function ChevronIcon({ isOpen }) {
23
+ return (
24
+ <svg
25
+ width="12"
26
+ height="12"
27
+ viewBox="0 0 24 24"
28
+ fill="none"
29
+ stroke="currentColor"
30
+ strokeWidth="2.2"
31
+ strokeLinecap="round"
32
+ strokeLinejoin="round"
33
+ style={{
34
+ transition: 'transform 0.18s cubic-bezier(0.4, 0, 0.2, 1)',
35
+ transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
36
+ opacity: 0.75,
37
+ marginLeft: 2,
38
+ flexShrink: 0,
39
+ }}
40
+ >
41
+ <polyline points="6 9 12 15 18 9" />
42
+ </svg>
43
+ );
44
+ }
45
+
46
+ function CheckIcon() {
47
+ return (
48
+ <svg
49
+ width="13"
50
+ height="13"
51
+ viewBox="0 0 24 24"
52
+ fill="none"
53
+ stroke="var(--accent, #3b82f6)"
54
+ strokeWidth="2.5"
55
+ strokeLinecap="round"
56
+ strokeLinejoin="round"
57
+ style={{ flexShrink: 0 }}
58
+ >
59
+ <polyline points="20 6 9 17 4 12" />
60
+ </svg>
61
+ );
62
+ }
63
+
22
64
  export default function TabBar({ tabs, active, onChange, capitalize = false, padding, variant = 'underline' }) {
23
65
  const barRef = useRef(null);
24
66
  const indRef = useRef(null);
25
67
  const initDone = useRef(false);
68
+ const [openDropdownId, setOpenDropdownId] = useState(null);
69
+
70
+ // Click outside to close dropdowns
71
+ useEffect(() => {
72
+ const handleClickOutside = (e) => {
73
+ if (barRef.current && !barRef.current.contains(e.target)) {
74
+ setOpenDropdownId(null);
75
+ }
76
+ };
77
+ document.addEventListener('mousedown', handleClickOutside);
78
+ return () => document.removeEventListener('mousedown', handleClickOutside);
79
+ }, []);
26
80
 
27
81
  // Underline indicator positioning
28
82
  useEffect(() => {
@@ -31,7 +85,7 @@ export default function TabBar({ tabs, active, onChange, capitalize = false, pad
31
85
  const ind = indRef.current;
32
86
  if (!bar || !ind) return;
33
87
 
34
- const activeBtn = bar.querySelector(`[data-tabid="${active}"]`);
88
+ const activeBtn = bar.querySelector(`[data-tabid="${active}"]`) || bar.querySelector(`[data-active="true"]`);
35
89
  if (!activeBtn) return;
36
90
 
37
91
  const barRect = bar.getBoundingClientRect();
@@ -53,49 +107,143 @@ export default function TabBar({ tabs, active, onChange, capitalize = false, pad
53
107
 
54
108
  if (variant === 'pill') {
55
109
  return (
56
- <div style={{
57
- display: 'inline-flex', gap: 3,
58
- background: 'var(--grey-1, #f5f5f8)',
59
- borderRadius: 'var(--radxs, 6px)',
60
- padding: '3px',
61
- border: '1px solid var(--border-light, #ebebf0)',
62
- }}>
110
+ <div
111
+ ref={barRef}
112
+ style={{
113
+ display: 'inline-flex',
114
+ gap: 4,
115
+ background: 'var(--grey-1, #f5f5f8)',
116
+ borderRadius: 'var(--radxs, 6px)',
117
+ padding: '3px',
118
+ border: '1px solid var(--border-light, #ebebf0)',
119
+ position: 'relative',
120
+ alignItems: 'center',
121
+ flexWrap: 'wrap',
122
+ }}
123
+ >
63
124
  {tabs.map(t => {
64
- const isActive = active === t.id;
125
+ const hasItems = Array.isArray(t.items) && t.items.length > 0;
126
+ const activeSubItem = hasItems ? t.items.find(item => item.id === active) : null;
127
+ const isActive = active === t.id || !!activeSubItem;
128
+ const isOpen = openDropdownId === t.id;
129
+
130
+ const handleTabClick = () => {
131
+ if (hasItems) {
132
+ setOpenDropdownId(isOpen ? null : t.id);
133
+ } else {
134
+ setOpenDropdownId(null);
135
+ onChange(t.id);
136
+ }
137
+ };
138
+
65
139
  return (
66
- <button
67
- key={t.id}
68
- data-tabid={t.id}
69
- onClick={() => onChange(t.id)}
70
- style={{
71
- display: 'flex', alignItems: 'center', gap: 5,
72
- height: 24,
73
- padding: padding || '0 12px',
74
- border: 'none',
75
- borderRadius: 'var(--radxs, 6px)',
76
- background: isActive ? 'var(--surface,#fff)' : 'transparent',
77
- boxShadow: isActive ? 'var(--shadow, 0 1px 2px rgba(0,0,0,.06))' : 'none',
78
- fontSize: '.75rem', fontWeight: isActive ? 600 : 500,
79
- color: isActive ? 'var(--primary-text, #0f111c)' : 'var(--muted, #9b9daa)',
80
- cursor: 'pointer',
81
- transition: 'all .18s ease',
82
- fontFamily: 'inherit',
83
- whiteSpace: 'nowrap',
84
- textTransform: capitalize ? 'capitalize' : 'none',
85
- }}
86
- >
87
- {t.icon}{t.label}
88
- {t.count != null && t.count > 0 && (
89
- <span style={{
90
- background: isActive ? 'var(--accent-bg)' : 'var(--grey-2, #d7d8e0)',
91
- color: isActive ? 'var(--accent)' : 'var(--muted)',
92
- borderRadius: 10,
93
- padding: '1px 6px',
94
- fontSize: '.68rem',
95
- fontWeight: 600,
96
- }}>{t.count}</span>
140
+ <div key={t.id} style={{ position: 'relative' }}>
141
+ <button
142
+ type="button"
143
+ data-tabid={t.id}
144
+ data-active={isActive}
145
+ onClick={handleTabClick}
146
+ style={{
147
+ display: 'flex', alignItems: 'center', gap: 5,
148
+ height: 26,
149
+ padding: padding || '0 10px',
150
+ border: 'none',
151
+ borderRadius: 'var(--radxs, 6px)',
152
+ background: isActive ? 'var(--surface,#fff)' : isOpen ? 'var(--grey-2, #e5e7eb)' : 'transparent',
153
+ boxShadow: isActive ? 'var(--shadow, 0 1px 3px rgba(0,0,0,.08))' : 'none',
154
+ fontSize: '.75rem', fontWeight: isActive ? 600 : 500,
155
+ color: isActive ? 'var(--primary-text, #0f111c)' : 'var(--muted, #9b9daa)',
156
+ cursor: 'pointer',
157
+ transition: 'all .18s ease',
158
+ fontFamily: 'inherit',
159
+ whiteSpace: 'nowrap',
160
+ textTransform: capitalize ? 'capitalize' : 'none',
161
+ }}
162
+ >
163
+ {t.icon}{t.label}
164
+ {activeSubItem && (
165
+ <span style={{
166
+ fontSize: '.68rem',
167
+ fontWeight: 600,
168
+ color: 'var(--accent, #3b82f6)',
169
+ background: 'var(--accent-bg, rgba(59,130,246,0.12))',
170
+ padding: '1px 6px',
171
+ borderRadius: 4,
172
+ marginLeft: 2,
173
+ }}>
174
+ {activeSubItem.label}
175
+ </span>
176
+ )}
177
+ {hasItems && <ChevronIcon isOpen={isOpen} />}
178
+ {t.count != null && t.count > 0 && (
179
+ <span style={{
180
+ background: isActive ? 'var(--accent-bg)' : 'var(--grey-2, #d7d8e0)',
181
+ color: isActive ? 'var(--accent)' : 'var(--muted)',
182
+ borderRadius: 10,
183
+ padding: '1px 6px',
184
+ fontSize: '.68rem',
185
+ fontWeight: 600,
186
+ }}>{t.count}</span>
187
+ )}
188
+ </button>
189
+
190
+ {/* Dropdown Menu for Pill Tab */}
191
+ {isOpen && hasItems && (
192
+ <div style={{
193
+ position: 'absolute',
194
+ top: 'calc(100% + 4px)',
195
+ left: 0,
196
+ zIndex: 9999,
197
+ minWidth: 185,
198
+ background: 'var(--surface, #fff)',
199
+ border: '1px solid var(--border-light, #ebebf0)',
200
+ borderRadius: 'var(--radxs, 6px)',
201
+ boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
202
+ padding: '4px',
203
+ display: 'flex',
204
+ flexDirection: 'column',
205
+ gap: 2,
206
+ }}>
207
+ {t.items.map(subItem => {
208
+ const isSubSelected = active === subItem.id;
209
+ return (
210
+ <button
211
+ key={subItem.id}
212
+ type="button"
213
+ onClick={() => {
214
+ onChange(subItem.id);
215
+ setOpenDropdownId(null);
216
+ }}
217
+ style={{
218
+ display: 'flex',
219
+ alignItems: 'center',
220
+ justifyContent: 'space-between',
221
+ padding: '6px 10px',
222
+ fontSize: '.75rem',
223
+ fontWeight: isSubSelected ? 600 : 400,
224
+ color: isSubSelected ? 'var(--accent, #3b82f6)' : 'var(--primary-text, #0f111c)',
225
+ background: isSubSelected ? 'var(--accent-bg, rgba(59,130,246,0.08))' : 'transparent',
226
+ borderRadius: 4,
227
+ border: 'none',
228
+ cursor: 'pointer',
229
+ textAlign: 'left',
230
+ transition: 'background .15s',
231
+ }}
232
+ onMouseEnter={(e) => {
233
+ if (!isSubSelected) e.currentTarget.style.background = 'var(--grey-1, #f5f5f8)';
234
+ }}
235
+ onMouseLeave={(e) => {
236
+ if (!isSubSelected) e.currentTarget.style.background = 'transparent';
237
+ }}
238
+ >
239
+ <span>{subItem.label}</span>
240
+ {isSubSelected && <CheckIcon />}
241
+ </button>
242
+ );
243
+ })}
244
+ </div>
97
245
  )}
98
- </button>
246
+ </div>
99
247
  );
100
248
  })}
101
249
  </div>
@@ -106,7 +254,7 @@ export default function TabBar({ tabs, active, onChange, capitalize = false, pad
106
254
  const defaultPadding = padding || '10px 16px';
107
255
 
108
256
  return (
109
- <div ref={barRef} style={{ display:'flex', position:'relative' }}>
257
+ <div ref={barRef} style={{ display:'flex', position:'relative', alignItems: 'center' }}>
110
258
  <div ref={indRef} style={{
111
259
  position:'absolute', bottom:'-1px', left:0,
112
260
  height:2, width:0,
@@ -117,39 +265,125 @@ export default function TabBar({ tabs, active, onChange, capitalize = false, pad
117
265
  }} />
118
266
 
119
267
  {tabs.map(t => {
120
- const isActive = active === t.id;
268
+ const hasItems = Array.isArray(t.items) && t.items.length > 0;
269
+ const activeSubItem = hasItems ? t.items.find(item => item.id === active) : null;
270
+ const isActive = active === t.id || !!activeSubItem;
271
+ const isOpen = openDropdownId === t.id;
272
+
273
+ const handleTabClick = () => {
274
+ if (hasItems) {
275
+ setOpenDropdownId(isOpen ? null : t.id);
276
+ } else {
277
+ setOpenDropdownId(null);
278
+ onChange(t.id);
279
+ }
280
+ };
281
+
121
282
  return (
122
- <button
123
- key={t.id}
124
- data-tabid={t.id}
125
- onClick={() => onChange(t.id)}
126
- style={{
127
- display:'flex', alignItems:'center', gap:6,
128
- padding: defaultPadding,
129
- border:'none', background:'none',
130
- fontSize:'.82rem', fontWeight:500,
131
- color: isActive ? 'var(--accent)' : 'var(--muted)',
132
- cursor:'pointer',
133
- transition:'color .18s',
134
- marginBottom:'-1px',
135
- position:'relative', zIndex:1,
136
- fontFamily:'inherit',
137
- whiteSpace:'nowrap',
138
- textTransform: capitalize ? 'capitalize' : 'none',
139
- }}
140
- >
141
- {t.icon}{t.label}
142
- {t.count != null && t.count > 0 && (
143
- <span style={{
144
- background: isActive ? 'var(--accent-bg)' : 'var(--grey-1)',
145
- color: isActive ? 'var(--accent)' : 'var(--muted)',
146
- borderRadius: 10,
147
- padding: '1px 6px',
148
- fontSize: '.68rem',
149
- transition: 'background .18s, color .18s',
150
- }}>{t.count}</span>
283
+ <div key={t.id} style={{ position: 'relative' }}>
284
+ <button
285
+ type="button"
286
+ data-tabid={t.id}
287
+ data-active={isActive}
288
+ onClick={handleTabClick}
289
+ style={{
290
+ display:'flex', alignItems:'center', gap:6,
291
+ padding: defaultPadding,
292
+ border:'none', background:'none',
293
+ fontSize:'.82rem', fontWeight:500,
294
+ color: isActive ? 'var(--accent)' : 'var(--muted)',
295
+ cursor:'pointer',
296
+ transition:'color .18s',
297
+ marginBottom:'-1px',
298
+ position:'relative', zIndex:1,
299
+ fontFamily:'inherit',
300
+ whiteSpace:'nowrap',
301
+ textTransform: capitalize ? 'capitalize' : 'none',
302
+ }}
303
+ >
304
+ {t.icon}{t.label}
305
+ {activeSubItem && (
306
+ <span style={{
307
+ fontSize: '.68rem',
308
+ fontWeight: 600,
309
+ color: 'var(--accent, #3b82f6)',
310
+ background: 'var(--accent-bg, rgba(59,130,246,0.12))',
311
+ padding: '1px 6px',
312
+ borderRadius: 4,
313
+ }}>
314
+ {activeSubItem.label}
315
+ </span>
316
+ )}
317
+ {hasItems && <ChevronIcon isOpen={isOpen} />}
318
+ {t.count != null && t.count > 0 && (
319
+ <span style={{
320
+ background: isActive ? 'var(--accent-bg)' : 'var(--grey-1)',
321
+ color: isActive ? 'var(--accent)' : 'var(--muted)',
322
+ borderRadius: 10,
323
+ padding: '1px 6px',
324
+ fontSize: '.68rem',
325
+ transition: 'background .18s, color .18s',
326
+ }}>{t.count}</span>
327
+ )}
328
+ </button>
329
+
330
+ {/* Dropdown Menu for Underline Tab */}
331
+ {isOpen && hasItems && (
332
+ <div style={{
333
+ position: 'absolute',
334
+ top: 'calc(100% + 2px)',
335
+ left: 0,
336
+ zIndex: 9999,
337
+ minWidth: 190,
338
+ background: 'var(--surface, #fff)',
339
+ border: '1px solid var(--border-light, #ebebf0)',
340
+ borderRadius: 'var(--radxs, 6px)',
341
+ boxShadow: '0 6px 20px rgba(0,0,0,0.12)',
342
+ padding: '4px',
343
+ display: 'flex',
344
+ flexDirection: 'column',
345
+ gap: 2,
346
+ }}>
347
+ {t.items.map(subItem => {
348
+ const isSubSelected = active === subItem.id;
349
+ return (
350
+ <button
351
+ key={subItem.id}
352
+ type="button"
353
+ onClick={() => {
354
+ onChange(subItem.id);
355
+ setOpenDropdownId(null);
356
+ }}
357
+ style={{
358
+ display: 'flex',
359
+ alignItems: 'center',
360
+ justify: 'space-between',
361
+ padding: '6px 10px',
362
+ fontSize: '.78rem',
363
+ fontWeight: isSubSelected ? 600 : 400,
364
+ color: isSubSelected ? 'var(--accent, #3b82f6)' : 'var(--primary-text, #0f111c)',
365
+ background: isSubSelected ? 'var(--accent-bg, rgba(59,130,246,0.08))' : 'transparent',
366
+ borderRadius: 4,
367
+ border: 'none',
368
+ cursor: 'pointer',
369
+ textAlign: 'left',
370
+ transition: 'background .15s',
371
+ }}
372
+ onMouseEnter={(e) => {
373
+ if (!isSubSelected) e.currentTarget.style.background = 'var(--grey-1, #f5f5f8)';
374
+ }}
375
+ onMouseLeave={(e) => {
376
+ if (!isSubSelected) e.currentTarget.style.background = 'transparent';
377
+ }}
378
+ >
379
+ <span>{subItem.label}</span>
380
+ {isSubSelected && <CheckIcon />}
381
+ </button>
382
+ );
383
+ })}
384
+ </div>
151
385
  )}
152
- </button>
386
+ </div>
153
387
  );
154
388
  })}
155
389
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cynx-ui",
3
- "version": "1.3.7",
3
+ "version": "1.3.8",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.js"