@wealthx/shadcn 1.3.0 → 1.3.1

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.
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * - All icons must be Lucide icons (LucideIcon type).
11
11
  * - Supports collapsible sub-items (accordion).
12
- * - Collapsed state: icon-only, metrics hidden.
12
+ * - Collapsed state: icon-only, metrics animated out.
13
13
  * - metricsGroups: optional financial summary rows (Frontend sidebar only).
14
14
  * - No internal navigation — consumers wire onNavigate / onLogout.
15
15
  */
@@ -23,8 +23,10 @@ import {
23
23
  PanelLeftOpen,
24
24
  } from "lucide-react";
25
25
  import type { LucideIcon } from "lucide-react";
26
+ import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
26
27
  import { cn } from "@/lib/utils";
27
28
  import { formatCurrency } from "@/lib/format-currency";
29
+ import { Accordion, AccordionContent, AccordionItem } from "./accordion";
28
30
  import { Button } from "./button";
29
31
  import {
30
32
  Tooltip,
@@ -108,7 +110,6 @@ function getInitials(name: string): string {
108
110
  .slice(0, 2);
109
111
  }
110
112
 
111
-
112
113
  function navIconCn(isActive: boolean): string {
113
114
  return cn(
114
115
  "shrink-0 transition-colors",
@@ -219,7 +220,7 @@ function SidebarNavItemView({
219
220
  >
220
221
  <Icon
221
222
  className={navIconCn(item.isActive ?? false)}
222
- size={18}
223
+ size={24}
223
224
  strokeWidth={1.75}
224
225
  />
225
226
  {!collapsed && <span className="truncate">{item.title}</span>}
@@ -264,7 +265,7 @@ function CollapsibleNavItem({
264
265
  >
265
266
  <Icon
266
267
  className={navIconCn(hasActiveChild)}
267
- size={18}
268
+ size={24}
268
269
  strokeWidth={1.75}
269
270
  />
270
271
  </Button>
@@ -273,65 +274,71 @@ function CollapsibleNavItem({
273
274
  }
274
275
 
275
276
  return (
276
- <div>
277
- <Button
278
- type="button"
279
- variant="ghost"
280
- onClick={() => setOpen((prev) => !prev)}
281
- className={cn(
282
- "group h-auto w-full justify-start gap-3 px-3 py-2.5 text-base font-medium transition-colors",
283
- "text-brand-secondary-foreground/70 hover:bg-white/10 hover:text-brand-secondary-foreground",
284
- "border-l-4 border-transparent",
285
- hasActiveChild &&
286
- "bg-white/15 text-brand-secondary-foreground border-primary",
287
- )}
288
- >
289
- <Icon
290
- className={navIconCn(hasActiveChild)}
291
- size={18}
292
- strokeWidth={1.75}
293
- />
294
- <span className="flex-1 truncate text-left">{item.title}</span>
295
- <ChevronDown
296
- className={cn(
297
- "ml-auto shrink-0 text-brand-secondary-foreground/40 transition-transform duration-200",
298
- open && "rotate-180",
299
- )}
300
- size={14}
301
- strokeWidth={2}
302
- />
303
- </Button>
304
-
305
- {open && item.subItems && (
306
- <div className="ml-9 border-l border-white/15 pl-3">
307
- {item.subItems.map((sub) => (
308
- <Button
309
- key={sub.href}
310
- type="button"
311
- variant="ghost"
312
- onClick={() => onNavigate?.(sub.href)}
277
+ <Accordion
278
+ value={open ? [item.href] : []}
279
+ onValueChange={(values) => setOpen(values.length > 0)}
280
+ >
281
+ <AccordionItem className="border-none" value={item.href}>
282
+ <AccordionPrimitive.Header className="flex">
283
+ <AccordionPrimitive.Trigger
284
+ className={cn(
285
+ "group flex h-auto w-full items-center justify-start gap-3 px-3 py-2.5 text-base font-medium transition-colors",
286
+ "text-brand-secondary-foreground/70 hover:bg-white/10 hover:text-brand-secondary-foreground",
287
+ "border-l-4 border-transparent",
288
+ hasActiveChild &&
289
+ "bg-white/15 text-brand-secondary-foreground border-primary",
290
+ )}
291
+ >
292
+ <Icon
293
+ className={navIconCn(hasActiveChild)}
294
+ size={24}
295
+ strokeWidth={1.75}
296
+ />
297
+ <span className="flex-1 truncate text-left">{item.title}</span>
298
+ <ChevronDown
313
299
  className={cn(
314
- "h-auto w-full justify-start gap-2 py-1.5 pl-1 text-sm transition-colors",
315
- "text-brand-secondary-foreground/50 hover:text-brand-secondary-foreground",
316
- sub.isActive && "text-primary font-medium",
300
+ "ml-auto shrink-0 text-brand-secondary-foreground/40 transition-transform duration-200",
301
+ "group-data-[panel-open]:rotate-180",
317
302
  )}
318
- >
319
- <ChevronRight
320
- size={11}
321
- strokeWidth={2}
322
- className={cn(
323
- "shrink-0",
324
- sub.isActive
325
- ? "text-primary"
326
- : "text-brand-secondary-foreground/30",
327
- )}
328
- />
329
- <span className="truncate">{sub.title}</span>
330
- </Button>
331
- ))}
332
- </div>
333
- )}
334
- </div>
303
+ size={14}
304
+ strokeWidth={2}
305
+ />
306
+ </AccordionPrimitive.Trigger>
307
+ </AccordionPrimitive.Header>
308
+
309
+ {item.subItems && (
310
+ <AccordionContent className="p-0 text-inherit">
311
+ <div className="ml-9 border-l border-white/15 pl-3">
312
+ {item.subItems.map((sub) => (
313
+ <Button
314
+ key={sub.href}
315
+ type="button"
316
+ variant="ghost"
317
+ onClick={() => onNavigate?.(sub.href)}
318
+ className={cn(
319
+ "h-auto w-full justify-start gap-2 py-1.5 pl-1 text-sm transition-colors",
320
+ "text-brand-secondary-foreground/50 hover:text-brand-secondary-foreground",
321
+ sub.isActive && "text-primary font-medium",
322
+ )}
323
+ >
324
+ <ChevronRight
325
+ size={11}
326
+ strokeWidth={2}
327
+ className={cn(
328
+ "shrink-0",
329
+ sub.isActive
330
+ ? "text-primary"
331
+ : "text-brand-secondary-foreground/30",
332
+ )}
333
+ />
334
+ <span className="truncate">{sub.title}</span>
335
+ </Button>
336
+ ))}
337
+ </div>
338
+ </AccordionContent>
339
+ )}
340
+ </AccordionItem>
341
+ </Accordion>
335
342
  );
336
343
  }
337
344
 
@@ -350,6 +357,27 @@ export function SidebarNav({
350
357
  className,
351
358
  }: SidebarNavProps) {
352
359
  const [userMenuOpen, setUserMenuOpen] = React.useState(false);
360
+ const navScrollRef = React.useRef<HTMLDivElement>(null);
361
+ const expandedScrollRef = React.useRef(0);
362
+
363
+ React.useEffect(() => {
364
+ if (collapsed) setUserMenuOpen(false);
365
+ }, [collapsed]);
366
+
367
+ // Preserve nav items scroll position across collapse/expand transitions.
368
+ // Cleanup saves scrollTop before the DOM changes; setup restores it when expanding.
369
+ React.useLayoutEffect(() => {
370
+ const nav = navScrollRef.current;
371
+ if (!nav) return;
372
+ if (!collapsed) {
373
+ nav.scrollTop = expandedScrollRef.current;
374
+ }
375
+ return () => {
376
+ if (!collapsed && nav) {
377
+ expandedScrollRef.current = nav.scrollTop;
378
+ }
379
+ };
380
+ }, [collapsed]);
353
381
 
354
382
  return (
355
383
  <TooltipProvider>
@@ -366,96 +394,125 @@ export function SidebarNav({
366
394
  className,
367
395
  )}
368
396
  >
369
- {/* Logo */}
370
- {!collapsed && logo && (
371
- <div className="flex items-center border-b border-white/15 px-5 py-4">
372
- <img
373
- src={logo}
374
- alt="Logo"
375
- className="h-8 w-auto object-contain object-left"
376
- style={{ filter: "brightness(0) invert(1)" }}
377
- />
397
+ {/* Logo — crossfade between full and icon variant */}
398
+ {(logo || logoCollapsed) && (
399
+ <div className="relative flex items-center border-b border-white/15 py-4 overflow-hidden">
400
+ {logo && (
401
+ <img
402
+ src={logo}
403
+ alt="Logo"
404
+ className={cn(
405
+ "h-8 w-auto object-contain object-left px-5 transition-opacity duration-200",
406
+ collapsed ? "opacity-0" : "opacity-100",
407
+ )}
408
+ style={{ filter: "brightness(0) invert(1)" }}
409
+ />
410
+ )}
411
+ {logoCollapsed && (
412
+ <img
413
+ src={logoCollapsed}
414
+ alt="Logo"
415
+ className={cn(
416
+ "absolute inset-y-0 left-0 right-0 m-auto h-8 w-8 object-contain transition-opacity duration-200",
417
+ collapsed ? "opacity-100" : "opacity-0",
418
+ )}
419
+ style={{ filter: "brightness(0) invert(1)" }}
420
+ />
421
+ )}
378
422
  </div>
379
423
  )}
380
- {collapsed && logoCollapsed && (
381
- <div className="flex items-center justify-center border-b border-white/15 py-4">
382
- <img
383
- src={logoCollapsed}
384
- alt="Logo"
385
- className="h-8 w-8 object-contain"
386
- style={{ filter: "brightness(0) invert(1)" }}
387
- />
424
+
425
+ {/* User section crossfade between expanded and collapsed */}
426
+ <div className="relative border-b border-white/15">
427
+ {/* Expanded — in flow (defines height), no transition (overlay handles it) */}
428
+ <div
429
+ className={cn(
430
+ collapsed ? "opacity-0 pointer-events-none" : "opacity-100",
431
+ )}
432
+ >
433
+ <Accordion
434
+ value={userMenuOpen ? ["user-menu"] : []}
435
+ onValueChange={(values) => setUserMenuOpen(values.length > 0)}
436
+ >
437
+ <AccordionItem className="border-none" value="user-menu">
438
+ <AccordionPrimitive.Header className="flex">
439
+ <AccordionPrimitive.Trigger
440
+ className={cn(
441
+ "group flex h-auto w-full items-center justify-start gap-3 px-5 py-5 text-base transition-colors",
442
+ "text-brand-secondary-foreground hover:bg-white/10",
443
+ )}
444
+ >
445
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center font-semibold text-xs bg-primary text-primary-foreground">
446
+ {getInitials(userName)}
447
+ </div>
448
+ <span className="flex-1 truncate text-left font-medium text-brand-secondary-foreground">
449
+ {userName}
450
+ </span>
451
+ <ChevronDown
452
+ className="ml-auto shrink-0 text-brand-secondary-foreground/50 transition-transform duration-200 group-data-[panel-open]:rotate-180"
453
+ size={16}
454
+ strokeWidth={2}
455
+ />
456
+ </AccordionPrimitive.Trigger>
457
+ </AccordionPrimitive.Header>
458
+
459
+ <AccordionContent className="p-0 text-inherit">
460
+ <div className="border-t border-white/15 bg-black/20">
461
+ <Button
462
+ type="button"
463
+ variant="ghost"
464
+ onClick={onLogout}
465
+ className={cn(
466
+ "h-auto w-full justify-start gap-3 px-5 py-3 text-base",
467
+ "text-brand-secondary-foreground/70 hover:bg-white/10 hover:text-brand-secondary-foreground transition-colors",
468
+ )}
469
+ >
470
+ <LogOut
471
+ size={16}
472
+ strokeWidth={1.75}
473
+ className="shrink-0 text-destructive"
474
+ />
475
+ <span>Logout</span>
476
+ </Button>
477
+ </div>
478
+ </AccordionContent>
479
+ </AccordionItem>
480
+ </Accordion>
388
481
  </div>
389
- )}
390
482
 
391
- {/* User section */}
392
- <div className="border-b border-white/15">
483
+ {/* Collapsed absolute overlay, centered avatar */}
393
484
  <NavTooltip label={userName} collapsed={collapsed}>
394
- <Button
395
- type="button"
396
- variant="ghost"
397
- onClick={() => !collapsed && setUserMenuOpen((prev) => !prev)}
485
+ <div
398
486
  className={cn(
399
- "group h-auto w-full justify-start gap-3 px-5 py-5 text-base transition-colors",
400
- "text-brand-secondary-foreground hover:bg-white/10",
401
- collapsed && "justify-center px-2 py-4",
487
+ "absolute inset-0 flex items-center justify-center transition-opacity duration-200",
488
+ collapsed ? "opacity-100" : "opacity-0 pointer-events-none",
402
489
  )}
403
490
  >
404
491
  <div className="flex h-8 w-8 shrink-0 items-center justify-center font-semibold text-xs bg-primary text-primary-foreground">
405
492
  {getInitials(userName)}
406
493
  </div>
407
- {!collapsed && (
408
- <>
409
- <span className="flex-1 truncate text-left font-medium text-brand-secondary-foreground">
410
- {userName}
411
- </span>
412
- <ChevronDown
413
- className={cn(
414
- "shrink-0 text-brand-secondary-foreground/50 transition-transform duration-200",
415
- userMenuOpen && "rotate-180",
416
- )}
417
- size={16}
418
- strokeWidth={2}
419
- />
420
- </>
421
- )}
422
- </Button>
423
- </NavTooltip>
424
-
425
- {/* Logout dropdown */}
426
- {!collapsed && userMenuOpen && (
427
- <div className="border-t border-white/15 bg-black/20">
428
- <Button
429
- type="button"
430
- variant="ghost"
431
- onClick={onLogout}
432
- className={cn(
433
- "h-auto w-full justify-start gap-3 px-5 py-3 text-base",
434
- "text-brand-secondary-foreground/70 hover:bg-white/10 hover:text-brand-secondary-foreground transition-colors",
435
- )}
436
- >
437
- <LogOut
438
- size={16}
439
- strokeWidth={1.75}
440
- className="shrink-0 text-destructive"
441
- />
442
- <span>Logout</span>
443
- </Button>
444
494
  </div>
445
- )}
495
+ </NavTooltip>
446
496
  </div>
447
497
 
448
- {/* Financial metrics (Frontend sidebar only, hidden when collapsed) */}
449
- {!collapsed && !!metricsGroups?.length && (
450
- <div>
451
- {metricsGroups.map((group, i) => (
452
- <MetricsGroup key={i} group={group} />
453
- ))}
454
- </div>
498
+ {/* Financial metrics animated in/out with sidebar collapse to prevent nav items jumping */}
499
+ {!!metricsGroups?.length && (
500
+ <Accordion
501
+ value={!collapsed ? ["metrics"] : []}
502
+ onValueChange={() => {}}
503
+ >
504
+ <AccordionItem className="border-none" value="metrics">
505
+ <AccordionContent className="p-0 text-inherit">
506
+ {metricsGroups.map((group, i) => (
507
+ <MetricsGroup key={i} group={group} />
508
+ ))}
509
+ </AccordionContent>
510
+ </AccordionItem>
511
+ </Accordion>
455
512
  )}
456
513
 
457
514
  {/* Nav items */}
458
- <div className="flex flex-col overflow-y-auto py-3">
515
+ <div ref={navScrollRef} className="flex flex-col overflow-y-auto py-3">
459
516
  {items.map((item) =>
460
517
  item.isCollapsible ? (
461
518
  <CollapsibleNavItem
@@ -487,20 +544,20 @@ export function SidebarNav({
487
544
  variant="ghost"
488
545
  onClick={() => onCollapsedChange(!collapsed)}
489
546
  className={cn(
490
- "h-auto w-full justify-start gap-3 px-3 py-3 transition-colors",
547
+ "h-12 w-full justify-start gap-3 px-3 py-3 transition-colors",
491
548
  "text-brand-secondary-foreground/80 hover:bg-white/10 hover:text-brand-secondary-foreground",
492
549
  collapsed && "justify-center px-2",
493
550
  )}
494
551
  >
495
552
  {collapsed ? (
496
553
  <PanelLeftOpen
497
- size={18}
554
+ size={24}
498
555
  strokeWidth={1.75}
499
556
  className="shrink-0"
500
557
  />
501
558
  ) : (
502
559
  <PanelLeftClose
503
- size={18}
560
+ size={24}
504
561
  strokeWidth={1.75}
505
562
  className="shrink-0"
506
563
  />