@wealthx/shadcn 1.3.0 → 1.3.2

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>
@@ -357,105 +385,131 @@ export function SidebarNav({
357
385
  data-slot="sidebar-nav"
358
386
  data-collapsed={collapsed}
359
387
  className={cn(
360
- // Force dark-mode CSS variable resolution — sidebar is always dark-backgrounded
361
- // regardless of system theme, so semantic tokens (destructive, success, etc.)
362
- // must use their dark-mode values to maintain WCAG contrast.
363
- "dark flex h-full flex-col bg-brand-secondary text-brand-secondary-foreground",
388
+ "flex h-full flex-col bg-brand-secondary text-brand-secondary-foreground",
364
389
  "transition-all duration-200 ease-in-out",
365
390
  collapsed ? "w-14" : "w-[279px]",
366
391
  className,
367
392
  )}
368
393
  >
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
- />
394
+ {/* Logo — crossfade between full and icon variant */}
395
+ {(logo || logoCollapsed) && (
396
+ <div className="relative flex items-center border-b border-white/15 py-4 overflow-hidden">
397
+ {logo && (
398
+ <img
399
+ src={logo}
400
+ alt="Logo"
401
+ className={cn(
402
+ "h-8 w-auto object-contain object-left px-5 transition-opacity duration-200",
403
+ collapsed ? "opacity-0" : "opacity-100",
404
+ )}
405
+ style={{ filter: "brightness(0) invert(1)" }}
406
+ />
407
+ )}
408
+ {logoCollapsed && (
409
+ <img
410
+ src={logoCollapsed}
411
+ alt="Logo"
412
+ className={cn(
413
+ "absolute inset-y-0 left-0 right-0 m-auto h-8 w-8 object-contain transition-opacity duration-200",
414
+ collapsed ? "opacity-100" : "opacity-0",
415
+ )}
416
+ style={{ filter: "brightness(0) invert(1)" }}
417
+ />
418
+ )}
378
419
  </div>
379
420
  )}
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
- />
421
+
422
+ {/* User section crossfade between expanded and collapsed */}
423
+ <div className="relative border-b border-white/15">
424
+ {/* Expanded — in flow (defines height), no transition (overlay handles it) */}
425
+ <div
426
+ className={cn(
427
+ collapsed ? "opacity-0 pointer-events-none" : "opacity-100",
428
+ )}
429
+ >
430
+ <Accordion
431
+ value={userMenuOpen ? ["user-menu"] : []}
432
+ onValueChange={(values) => setUserMenuOpen(values.length > 0)}
433
+ >
434
+ <AccordionItem className="border-none" value="user-menu">
435
+ <AccordionPrimitive.Header className="flex">
436
+ <AccordionPrimitive.Trigger
437
+ className={cn(
438
+ "group flex h-auto w-full items-center justify-start gap-3 px-5 py-5 text-base transition-colors",
439
+ "text-brand-secondary-foreground hover:bg-white/10",
440
+ )}
441
+ >
442
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center font-semibold text-xs bg-primary text-primary-foreground">
443
+ {getInitials(userName)}
444
+ </div>
445
+ <span className="flex-1 truncate text-left font-medium text-brand-secondary-foreground">
446
+ {userName}
447
+ </span>
448
+ <ChevronDown
449
+ className="ml-auto shrink-0 text-brand-secondary-foreground/50 transition-transform duration-200 group-data-[panel-open]:rotate-180"
450
+ size={16}
451
+ strokeWidth={2}
452
+ />
453
+ </AccordionPrimitive.Trigger>
454
+ </AccordionPrimitive.Header>
455
+
456
+ <AccordionContent className="p-0 text-inherit">
457
+ <div className="border-t border-white/15 bg-black/20">
458
+ <Button
459
+ type="button"
460
+ variant="ghost"
461
+ onClick={onLogout}
462
+ className={cn(
463
+ "h-auto w-full justify-start gap-3 px-5 py-3 text-base",
464
+ "text-brand-secondary-foreground/70 hover:bg-white/10 hover:text-brand-secondary-foreground transition-colors",
465
+ )}
466
+ >
467
+ <LogOut
468
+ size={16}
469
+ strokeWidth={1.75}
470
+ className="shrink-0 text-destructive"
471
+ />
472
+ <span>Logout</span>
473
+ </Button>
474
+ </div>
475
+ </AccordionContent>
476
+ </AccordionItem>
477
+ </Accordion>
388
478
  </div>
389
- )}
390
479
 
391
- {/* User section */}
392
- <div className="border-b border-white/15">
480
+ {/* Collapsed absolute overlay, centered avatar */}
393
481
  <NavTooltip label={userName} collapsed={collapsed}>
394
- <Button
395
- type="button"
396
- variant="ghost"
397
- onClick={() => !collapsed && setUserMenuOpen((prev) => !prev)}
482
+ <div
398
483
  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",
484
+ "absolute inset-0 flex items-center justify-center transition-opacity duration-200",
485
+ collapsed ? "opacity-100" : "opacity-0 pointer-events-none",
402
486
  )}
403
487
  >
404
488
  <div className="flex h-8 w-8 shrink-0 items-center justify-center font-semibold text-xs bg-primary text-primary-foreground">
405
489
  {getInitials(userName)}
406
490
  </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
491
  </div>
445
- )}
492
+ </NavTooltip>
446
493
  </div>
447
494
 
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>
495
+ {/* Financial metrics animated in/out with sidebar collapse to prevent nav items jumping */}
496
+ {!!metricsGroups?.length && (
497
+ <Accordion
498
+ value={!collapsed ? ["metrics"] : []}
499
+ onValueChange={() => {}}
500
+ >
501
+ <AccordionItem className="border-none" value="metrics">
502
+ <AccordionContent className="p-0 text-inherit">
503
+ {metricsGroups.map((group, i) => (
504
+ <MetricsGroup key={i} group={group} />
505
+ ))}
506
+ </AccordionContent>
507
+ </AccordionItem>
508
+ </Accordion>
455
509
  )}
456
510
 
457
511
  {/* Nav items */}
458
- <div className="flex flex-col overflow-y-auto py-3">
512
+ <div ref={navScrollRef} className="flex flex-col overflow-y-auto py-3">
459
513
  {items.map((item) =>
460
514
  item.isCollapsible ? (
461
515
  <CollapsibleNavItem
@@ -487,20 +541,20 @@ export function SidebarNav({
487
541
  variant="ghost"
488
542
  onClick={() => onCollapsedChange(!collapsed)}
489
543
  className={cn(
490
- "h-auto w-full justify-start gap-3 px-3 py-3 transition-colors",
544
+ "h-12 w-full justify-start gap-3 px-3 py-3 transition-colors",
491
545
  "text-brand-secondary-foreground/80 hover:bg-white/10 hover:text-brand-secondary-foreground",
492
546
  collapsed && "justify-center px-2",
493
547
  )}
494
548
  >
495
549
  {collapsed ? (
496
550
  <PanelLeftOpen
497
- size={18}
551
+ size={24}
498
552
  strokeWidth={1.75}
499
553
  className="shrink-0"
500
554
  />
501
555
  ) : (
502
556
  <PanelLeftClose
503
- size={18}
557
+ size={24}
504
558
  strokeWidth={1.75}
505
559
  className="shrink-0"
506
560
  />