@exegia/corpora-ui 0.8.0 → 0.9.0

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 (30) hide show
  1. package/dist-lib/components/blocks/nav/__tests__/sidebar-block.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/nav/sidebar-block.d.ts +67 -0
  3. package/dist-lib/components/blocks/profile/__tests__/profile-card-block.test.d.ts +1 -0
  4. package/dist-lib/components/blocks/profile/profile-card-block.d.ts +77 -0
  5. package/dist-lib/components/composed/__tests__/user-avatar.test.d.ts +1 -0
  6. package/dist-lib/components/composed/user-avatar.d.ts +30 -0
  7. package/dist-lib/components/motion/animated-sidebar.d.ts +98 -0
  8. package/dist-lib/components/motion/shared-layout-bg.d.ts +13 -0
  9. package/dist-lib/components/ui/avatar.d.ts +6 -0
  10. package/dist-lib/components/ui/menu.d.ts +43 -0
  11. package/dist-lib/components/ui/skeleton.d.ts +2 -0
  12. package/dist-lib/index.d.ts +9 -0
  13. package/dist-lib/index.js +1896 -864
  14. package/dist-lib/index.js.map +1 -1
  15. package/dist-lib/lib/ease.d.ts +46 -0
  16. package/package.json +1 -1
  17. package/src/components/blocks/nav/__tests__/sidebar-block.test.tsx +125 -0
  18. package/src/components/blocks/nav/sidebar-block.tsx +264 -0
  19. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +134 -0
  20. package/src/components/blocks/profile/profile-card-block.tsx +281 -0
  21. package/src/components/composed/__tests__/user-avatar.test.tsx +85 -0
  22. package/src/components/composed/user-avatar.tsx +98 -0
  23. package/src/components/motion/animated-sidebar.tsx +1145 -0
  24. package/src/components/motion/shared-layout-bg.tsx +151 -0
  25. package/src/components/ui/avatar.tsx +52 -0
  26. package/src/components/ui/menu.tsx +352 -0
  27. package/src/components/ui/skeleton.tsx +18 -0
  28. package/src/index.css +4 -0
  29. package/src/index.ts +11 -0
  30. package/src/lib/ease.ts +53 -0
@@ -0,0 +1,1145 @@
1
+ "use client";
2
+ // beui.dev/components/motion/animated-sidebar
3
+
4
+ import { ChevronRight } from "lucide-react";
5
+ import {
6
+ AnimatePresence,
7
+ type HTMLMotionProps,
8
+ motion,
9
+ useReducedMotion,
10
+ type Variants,
11
+ } from "motion/react";
12
+ import {
13
+ type ButtonHTMLAttributes,
14
+ type CSSProperties,
15
+ createContext,
16
+ forwardRef,
17
+ type HTMLAttributes,
18
+ type ReactNode,
19
+ useCallback,
20
+ useContext,
21
+ useEffect,
22
+ useId,
23
+ useRef,
24
+ useState,
25
+ useSyncExternalStore,
26
+ } from "react";
27
+ import { createPortal } from "react-dom";
28
+ import { SharedLayoutBg } from "@/components/motion/shared-layout-bg";
29
+ import {
30
+ EASE_DRAWER,
31
+ EASE_OUT,
32
+ SPRING_LAYOUT,
33
+ SPRING_PRESS,
34
+ } from "@/lib/ease";
35
+ import { cn } from "@/lib/utils";
36
+
37
+ type SidebarState = "expanded" | "collapsed";
38
+ type SidebarSide = "left" | "right";
39
+ type SidebarVariant = "sidebar" | "floating" | "inset";
40
+ type SidebarCollapsible = "offcanvas" | "icon" | "none";
41
+
42
+ const MOBILE_QUERY = "(max-width: 767px)";
43
+ const SIDEBAR_KEYBOARD_SHORTCUT = "b";
44
+
45
+ const PANEL_TRANSITION = {
46
+ duration: 0.36,
47
+ ease: EASE_DRAWER,
48
+ } as const;
49
+
50
+ // The desktop rail settles at a hard zero-width boundary. Keep the spring
51
+ // critically damped so it cannot overshoot, pause against that boundary, and
52
+ // then snap back during the final frame.
53
+ const SIDEBAR_MORPH_TRANSITION = {
54
+ type: "spring",
55
+ stiffness: 380,
56
+ damping: 35,
57
+ mass: 0.75,
58
+ } as const;
59
+
60
+ const LABEL_ENTER_TRANSITION = {
61
+ duration: 0.2,
62
+ delay: 0.08,
63
+ ease: EASE_OUT,
64
+ } as const;
65
+
66
+ const LABEL_EXIT_TRANSITION = {
67
+ duration: 0.12,
68
+ ease: EASE_OUT,
69
+ } as const;
70
+
71
+ const SUBMENU_TRANSITION = {
72
+ duration: 0.18,
73
+ ease: EASE_OUT,
74
+ } as const;
75
+
76
+ const SUBMENU_VARIANTS: Variants = {
77
+ closed: {
78
+ opacity: 0,
79
+ clipPath: "inset(0 0 100% 0 round 8px)",
80
+ transition: {
81
+ duration: 0.14,
82
+ ease: EASE_OUT,
83
+ staggerChildren: 0.025,
84
+ staggerDirection: -1,
85
+ },
86
+ },
87
+ open: {
88
+ opacity: 1,
89
+ clipPath: "inset(0 0 0% 0 round 8px)",
90
+ transition: {
91
+ duration: 0.2,
92
+ delayChildren: 0.035,
93
+ ease: EASE_OUT,
94
+ staggerChildren: 0.045,
95
+ },
96
+ },
97
+ };
98
+
99
+ const SUBMENU_ITEM_VARIANTS: Variants = {
100
+ closed: {
101
+ opacity: 0,
102
+ y: -6,
103
+ filter: "blur(3px)",
104
+ },
105
+ open: {
106
+ opacity: 1,
107
+ y: 0,
108
+ filter: "blur(0px)",
109
+ transition: SUBMENU_TRANSITION,
110
+ },
111
+ };
112
+
113
+ const REDUCED_TRANSITION = {
114
+ duration: 0.16,
115
+ ease: EASE_OUT,
116
+ } as const;
117
+
118
+ const FOCUSABLE_SELECTOR = [
119
+ "a[href]",
120
+ "button:not([disabled])",
121
+ "input:not([disabled])",
122
+ "select:not([disabled])",
123
+ "textarea:not([disabled])",
124
+ "[tabindex]:not([tabindex='-1'])",
125
+ ].join(",");
126
+
127
+ function subscribeToMobileQuery(callback: () => void) {
128
+ const query = window.matchMedia(MOBILE_QUERY);
129
+ query.addEventListener("change", callback);
130
+ return () => query.removeEventListener("change", callback);
131
+ }
132
+
133
+ function getMobileSnapshot() {
134
+ return window.matchMedia(MOBILE_QUERY).matches;
135
+ }
136
+
137
+ function getServerMobileSnapshot() {
138
+ return false;
139
+ }
140
+
141
+ function useIsMobile() {
142
+ return useSyncExternalStore(
143
+ subscribeToMobileQuery,
144
+ getMobileSnapshot,
145
+ getServerMobileSnapshot,
146
+ );
147
+ }
148
+
149
+ interface AnimatedSidebarContextValue {
150
+ isMobile: boolean;
151
+ layoutId: string;
152
+ open: boolean;
153
+ openMobile: boolean;
154
+ reduce: boolean;
155
+ setOpen: (open: boolean) => void;
156
+ setOpenMobile: (open: boolean) => void;
157
+ state: SidebarState;
158
+ toggleSidebar: () => void;
159
+ triggerRef: React.RefObject<HTMLButtonElement | null>;
160
+ }
161
+
162
+ const AnimatedSidebarContext =
163
+ createContext<AnimatedSidebarContextValue | null>(null);
164
+
165
+ interface AnimatedSidebarPanelContextValue {
166
+ collapsed: boolean;
167
+ collapsible: SidebarCollapsible;
168
+ side: SidebarSide;
169
+ }
170
+
171
+ const AnimatedSidebarPanelContext =
172
+ createContext<AnimatedSidebarPanelContextValue | null>(null);
173
+
174
+ export function useAnimatedSidebar() {
175
+ const context = useContext(AnimatedSidebarContext);
176
+ if (!context) {
177
+ throw new Error(
178
+ "useAnimatedSidebar must be used inside AnimatedSidebarProvider.",
179
+ );
180
+ }
181
+ return context;
182
+ }
183
+
184
+ function useAnimatedSidebarPanel() {
185
+ const context = useContext(AnimatedSidebarPanelContext);
186
+ if (!context) {
187
+ throw new Error(
188
+ "Animated Sidebar parts must be used inside AnimatedSidebar.",
189
+ );
190
+ }
191
+ return context;
192
+ }
193
+
194
+ type SidebarProviderStyle = CSSProperties & {
195
+ "--sidebar-width"?: string;
196
+ "--sidebar-width-icon"?: string;
197
+ "--sidebar-width-mobile"?: string;
198
+ };
199
+
200
+ export interface AnimatedSidebarProviderProps
201
+ extends HTMLAttributes<HTMLDivElement> {
202
+ open?: boolean;
203
+ defaultOpen?: boolean;
204
+ onOpenChange?: (open: boolean) => void;
205
+ openMobile?: boolean;
206
+ defaultOpenMobile?: boolean;
207
+ onOpenMobileChange?: (open: boolean) => void;
208
+ style?: SidebarProviderStyle;
209
+ }
210
+
211
+ export function AnimatedSidebarProvider({
212
+ children,
213
+ open,
214
+ defaultOpen = true,
215
+ onOpenChange,
216
+ openMobile,
217
+ defaultOpenMobile = false,
218
+ onOpenMobileChange,
219
+ className,
220
+ style,
221
+ ...props
222
+ }: AnimatedSidebarProviderProps) {
223
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
224
+ const [internalOpenMobile, setInternalOpenMobile] =
225
+ useState(defaultOpenMobile);
226
+ const isMobile = useIsMobile();
227
+ const reduce = useReducedMotion() ?? false;
228
+ const generatedId = useId();
229
+ const triggerRef = useRef<HTMLButtonElement>(null);
230
+ const desktopOpen = open ?? internalOpen;
231
+ const mobileOpen = openMobile ?? internalOpenMobile;
232
+
233
+ const setOpen = useCallback(
234
+ (nextOpen: boolean) => {
235
+ if (open === undefined) setInternalOpen(nextOpen);
236
+ onOpenChange?.(nextOpen);
237
+ },
238
+ [onOpenChange, open],
239
+ );
240
+
241
+ const setOpenMobile = useCallback(
242
+ (nextOpen: boolean) => {
243
+ if (openMobile === undefined) setInternalOpenMobile(nextOpen);
244
+ onOpenMobileChange?.(nextOpen);
245
+ },
246
+ [onOpenMobileChange, openMobile],
247
+ );
248
+
249
+ const toggleSidebar = useCallback(() => {
250
+ if (isMobile) setOpenMobile(!mobileOpen);
251
+ else setOpen(!desktopOpen);
252
+ }, [desktopOpen, isMobile, mobileOpen, setOpen, setOpenMobile]);
253
+
254
+ useEffect(() => {
255
+ const handleShortcut = (event: KeyboardEvent) => {
256
+ if (
257
+ event.key.toLowerCase() === SIDEBAR_KEYBOARD_SHORTCUT &&
258
+ (event.metaKey || event.ctrlKey)
259
+ ) {
260
+ event.preventDefault();
261
+ toggleSidebar();
262
+ }
263
+ };
264
+
265
+ window.addEventListener("keydown", handleShortcut);
266
+ return () => window.removeEventListener("keydown", handleShortcut);
267
+ }, [toggleSidebar]);
268
+
269
+ return (
270
+ <AnimatedSidebarContext.Provider
271
+ value={{
272
+ isMobile,
273
+ layoutId: `${generatedId}-active`,
274
+ open: desktopOpen,
275
+ openMobile: mobileOpen,
276
+ reduce,
277
+ setOpen,
278
+ setOpenMobile,
279
+ state: desktopOpen ? "expanded" : "collapsed",
280
+ toggleSidebar,
281
+ triggerRef,
282
+ }}
283
+ >
284
+ <div
285
+ {...props}
286
+ data-slot="sidebar-wrapper"
287
+ data-state={desktopOpen ? "expanded" : "collapsed"}
288
+ style={{
289
+ "--sidebar-width": "16rem",
290
+ "--sidebar-width-icon": "4.25rem",
291
+ "--sidebar-width-mobile": "18rem",
292
+ ...style,
293
+ }}
294
+ className={cn(
295
+ "group/sidebar-wrapper flex min-h-svh w-full min-w-0",
296
+ className,
297
+ )}
298
+ >
299
+ {children}
300
+ </div>
301
+ </AnimatedSidebarContext.Provider>
302
+ );
303
+ }
304
+
305
+ function MobileSidebar({
306
+ ariaLabel,
307
+ children,
308
+ className,
309
+ side,
310
+ }: {
311
+ ariaLabel: string;
312
+ children: ReactNode;
313
+ className?: string;
314
+ side: SidebarSide;
315
+ }) {
316
+ const context = useAnimatedSidebar();
317
+ const panelRef = useRef<HTMLDivElement>(null);
318
+ const [mounted, setMounted] = useState(false);
319
+
320
+ useEffect(() => setMounted(true), []);
321
+
322
+ useEffect(() => {
323
+ if (!context.openMobile) return;
324
+
325
+ const body = document.body;
326
+ const scrollY = window.scrollY;
327
+ const previousBodyStyles = {
328
+ left: body.style.left,
329
+ overflow: body.style.overflow,
330
+ position: body.style.position,
331
+ right: body.style.right,
332
+ top: body.style.top,
333
+ };
334
+
335
+ body.style.position = "fixed";
336
+ body.style.top = `-${scrollY}px`;
337
+ body.style.left = "0";
338
+ body.style.right = "0";
339
+ body.style.overflow = "hidden";
340
+
341
+ const focusFrame = requestAnimationFrame(() => {
342
+ const firstFocusable =
343
+ panelRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
344
+ (firstFocusable ?? panelRef.current)?.focus({ preventScroll: true });
345
+ });
346
+
347
+ return () => {
348
+ cancelAnimationFrame(focusFrame);
349
+ body.style.position = previousBodyStyles.position;
350
+ body.style.top = previousBodyStyles.top;
351
+ body.style.left = previousBodyStyles.left;
352
+ body.style.right = previousBodyStyles.right;
353
+ body.style.overflow = previousBodyStyles.overflow;
354
+ window.scrollTo(0, scrollY);
355
+ context.triggerRef.current?.focus({ preventScroll: true });
356
+ };
357
+ }, [context.openMobile, context.triggerRef]);
358
+
359
+ if (!mounted) return null;
360
+
361
+ return createPortal(
362
+ <div
363
+ className={cn(
364
+ "pointer-events-none fixed inset-0 z-50 md:hidden",
365
+ context.openMobile ? "visible" : "invisible",
366
+ )}
367
+ >
368
+ <motion.button
369
+ type="button"
370
+ aria-label="Close sidebar"
371
+ tabIndex={context.openMobile ? 0 : -1}
372
+ initial={false}
373
+ animate={{ opacity: context.openMobile ? 1 : 0 }}
374
+ transition={
375
+ context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION
376
+ }
377
+ onClick={() => context.setOpenMobile(false)}
378
+ className={cn(
379
+ "absolute inset-0 bg-black/40",
380
+ context.openMobile
381
+ ? "pointer-events-auto"
382
+ : "pointer-events-none",
383
+ )}
384
+ />
385
+
386
+ <motion.div
387
+ ref={panelRef}
388
+ role="dialog"
389
+ aria-modal="true"
390
+ aria-label={ariaLabel}
391
+ aria-hidden={!context.openMobile}
392
+ inert={!context.openMobile}
393
+ tabIndex={-1}
394
+ data-mobile="true"
395
+ data-state={context.openMobile ? "expanded" : "collapsed"}
396
+ data-side={side}
397
+ initial={false}
398
+ animate={{
399
+ opacity: context.reduce
400
+ ? context.openMobile
401
+ ? 1
402
+ : 0
403
+ : 1,
404
+ x: context.reduce
405
+ ? 0
406
+ : context.openMobile
407
+ ? "0%"
408
+ : side === "left"
409
+ ? "-100%"
410
+ : "100%",
411
+ }}
412
+ transition={
413
+ context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION
414
+ }
415
+ onKeyDown={(event) => {
416
+ if (event.key === "Escape") {
417
+ event.preventDefault();
418
+ context.setOpenMobile(false);
419
+ return;
420
+ }
421
+
422
+ if (event.key !== "Tab") return;
423
+ const focusable = panelRef.current
424
+ ? Array.from(
425
+ panelRef.current.querySelectorAll<HTMLElement>(
426
+ FOCUSABLE_SELECTOR,
427
+ ),
428
+ )
429
+ : [];
430
+
431
+ if (focusable.length === 0) {
432
+ event.preventDefault();
433
+ panelRef.current?.focus();
434
+ return;
435
+ }
436
+
437
+ const first = focusable[0];
438
+ const last = focusable[focusable.length - 1];
439
+ if (event.shiftKey && document.activeElement === first) {
440
+ event.preventDefault();
441
+ last.focus();
442
+ } else if (!event.shiftKey && document.activeElement === last) {
443
+ event.preventDefault();
444
+ first.focus();
445
+ }
446
+ }}
447
+ className={cn(
448
+ "pointer-events-auto absolute inset-y-0 flex h-dvh w-(--sidebar-width-mobile) max-w-[88vw] flex-col overflow-hidden",
449
+ "border-border bg-background shadow-2xl will-change-transform",
450
+ side === "left" ? "left-0 border-r" : "right-0 border-l",
451
+ !context.openMobile && "pointer-events-none",
452
+ className,
453
+ )}
454
+ >
455
+ <AnimatedSidebarPanelContext.Provider
456
+ value={{ collapsed: false, collapsible: "none", side }}
457
+ >
458
+ {children}
459
+ </AnimatedSidebarPanelContext.Provider>
460
+ </motion.div>
461
+ </div>,
462
+ document.body,
463
+ );
464
+ }
465
+
466
+ export interface AnimatedSidebarProps
467
+ extends Omit<HTMLMotionProps<"aside">, "children"> {
468
+ children?: ReactNode;
469
+ side?: SidebarSide;
470
+ variant?: SidebarVariant;
471
+ collapsible?: SidebarCollapsible;
472
+ ariaLabel?: string;
473
+ panelClassName?: string;
474
+ }
475
+
476
+ export const AnimatedSidebar = forwardRef<HTMLElement, AnimatedSidebarProps>(
477
+ function AnimatedSidebar(
478
+ {
479
+ side = "left",
480
+ variant = "sidebar",
481
+ collapsible = "icon",
482
+ ariaLabel = "Sidebar",
483
+ children,
484
+ className,
485
+ panelClassName,
486
+ style,
487
+ ...props
488
+ },
489
+ forwardedRef,
490
+ ) {
491
+ const context = useAnimatedSidebar();
492
+ const collapsed = collapsible !== "none" && !context.open;
493
+ const offcanvas = collapsed && collapsible === "offcanvas";
494
+ const width = offcanvas
495
+ ? "0px"
496
+ : collapsed
497
+ ? "var(--sidebar-width-icon)"
498
+ : "var(--sidebar-width)";
499
+
500
+ if (context.isMobile) {
501
+ return (
502
+ <MobileSidebar
503
+ ariaLabel={ariaLabel}
504
+ className={className}
505
+ side={side}
506
+ >
507
+ {children}
508
+ </MobileSidebar>
509
+ );
510
+ }
511
+
512
+ return (
513
+ <motion.aside
514
+ {...props}
515
+ ref={forwardedRef}
516
+ initial={false}
517
+ aria-label={ariaLabel}
518
+ data-slot="sidebar"
519
+ data-state={collapsed ? "collapsed" : "expanded"}
520
+ data-collapsible={collapsible}
521
+ data-variant={variant}
522
+ data-side={side}
523
+ animate={{ width }}
524
+ transition={
525
+ context.reduce ? { duration: 0 } : SIDEBAR_MORPH_TRANSITION
526
+ }
527
+ style={style}
528
+ className={cn(
529
+ "group/sidebar relative hidden h-auto shrink-0 md:block will-change-[width]",
530
+ "peer",
531
+ side === "right" && "order-last",
532
+ className,
533
+ )}
534
+ >
535
+ <motion.div
536
+ initial={false}
537
+ animate={{
538
+ opacity: offcanvas ? 0 : 1,
539
+ x: offcanvas ? (side === "left" ? "-100%" : "100%") : "0%",
540
+ }}
541
+ transition={
542
+ context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION
543
+ }
544
+ className={cn(
545
+ "sticky top-0 flex h-svh w-full flex-col overflow-hidden bg-background",
546
+ collapsible === "offcanvas" && "w-[var(--sidebar-width)]",
547
+ variant === "sidebar" &&
548
+ (side === "left" ? "border-border border-r" : "border-border border-l"),
549
+ variant === "floating" &&
550
+ "m-2 h-[calc(100svh-1rem)] rounded-2xl border border-border shadow-sm",
551
+ variant === "inset" && "m-2 h-[calc(100svh-1rem)] rounded-2xl",
552
+ panelClassName,
553
+ )}
554
+ >
555
+ <AnimatedSidebarPanelContext.Provider
556
+ value={{ collapsed, collapsible, side }}
557
+ >
558
+ {children}
559
+ </AnimatedSidebarPanelContext.Provider>
560
+ </motion.div>
561
+ </motion.aside>
562
+ );
563
+ },
564
+ );
565
+
566
+ export interface AnimatedSidebarTriggerProps
567
+ extends ButtonHTMLAttributes<HTMLButtonElement> {}
568
+
569
+ export const AnimatedSidebarTrigger = forwardRef<
570
+ HTMLButtonElement,
571
+ AnimatedSidebarTriggerProps
572
+ >(function AnimatedSidebarTrigger(
573
+ { className, onClick, type = "button", ...props },
574
+ forwardedRef,
575
+ ) {
576
+ const context = useAnimatedSidebar();
577
+ const expanded = context.isMobile ? context.openMobile : context.open;
578
+
579
+ return (
580
+ <button
581
+ {...props}
582
+ ref={(node) => {
583
+ context.triggerRef.current = node;
584
+ if (typeof forwardedRef === "function") forwardedRef(node);
585
+ else if (forwardedRef) forwardedRef.current = node;
586
+ }}
587
+ type={type}
588
+ aria-label={props["aria-label"] ?? "Toggle sidebar"}
589
+ aria-expanded={expanded}
590
+ data-slot="sidebar-trigger"
591
+ data-state={expanded ? "expanded" : "collapsed"}
592
+ onClick={(event) => {
593
+ onClick?.(event);
594
+ if (!event.defaultPrevented) context.toggleSidebar();
595
+ }}
596
+ className={cn(
597
+ "inline-flex size-10 shrink-0 items-center justify-center rounded-xl outline-none",
598
+ "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
599
+ className,
600
+ )}
601
+ />
602
+ );
603
+ });
604
+
605
+ export interface AnimatedSidebarCloseProps
606
+ extends ButtonHTMLAttributes<HTMLButtonElement> {}
607
+
608
+ export const AnimatedSidebarClose = forwardRef<
609
+ HTMLButtonElement,
610
+ AnimatedSidebarCloseProps
611
+ >(function AnimatedSidebarClose(
612
+ { className, onClick, type = "button", ...props },
613
+ forwardedRef,
614
+ ) {
615
+ const context = useAnimatedSidebar();
616
+
617
+ return (
618
+ <button
619
+ {...props}
620
+ ref={forwardedRef}
621
+ type={type}
622
+ aria-label={props["aria-label"] ?? "Close sidebar"}
623
+ onClick={(event) => {
624
+ onClick?.(event);
625
+ if (event.defaultPrevented) return;
626
+ if (context.isMobile) context.setOpenMobile(false);
627
+ else context.setOpen(false);
628
+ }}
629
+ className={cn(
630
+ "inline-flex size-10 shrink-0 items-center justify-center rounded-xl outline-none",
631
+ "focus-visible:ring-2 focus-visible:ring-ring",
632
+ className,
633
+ )}
634
+ />
635
+ );
636
+ });
637
+
638
+ export interface AnimatedSidebarRailProps
639
+ extends ButtonHTMLAttributes<HTMLButtonElement> {}
640
+
641
+ export const AnimatedSidebarRail = forwardRef<
642
+ HTMLButtonElement,
643
+ AnimatedSidebarRailProps
644
+ >(function AnimatedSidebarRail(
645
+ { className, onClick, type = "button", ...props },
646
+ forwardedRef,
647
+ ) {
648
+ const context = useAnimatedSidebar();
649
+ const panel = useAnimatedSidebarPanel();
650
+
651
+ return (
652
+ <button
653
+ {...props}
654
+ ref={forwardedRef}
655
+ type={type}
656
+ data-side={panel.side}
657
+ aria-label={props["aria-label"] ?? "Toggle sidebar"}
658
+ title="Toggle sidebar"
659
+ tabIndex={-1}
660
+ onClick={(event) => {
661
+ onClick?.(event);
662
+ if (!event.defaultPrevented) context.toggleSidebar();
663
+ }}
664
+ className={cn(
665
+ "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 outline-none md:block",
666
+ "after:absolute after:inset-y-0 after:left-1/2 after:w-px after:bg-transparent after:transition-colors hover:after:bg-border",
667
+ "data-[side=right]:right-0 data-[side=right]:translate-x-1/2 data-[side=left]:left-full",
668
+ className,
669
+ )}
670
+ />
671
+ );
672
+ });
673
+
674
+ export interface AnimatedSidebarInsetProps
675
+ extends HTMLMotionProps<"main"> {}
676
+
677
+ export const AnimatedSidebarInset = forwardRef<
678
+ HTMLElement,
679
+ AnimatedSidebarInsetProps
680
+ >(function AnimatedSidebarInset({ className, ...props }, forwardedRef) {
681
+ return (
682
+ <motion.main
683
+ {...props}
684
+ ref={forwardedRef}
685
+ data-slot="sidebar-inset"
686
+ className={cn(
687
+ "relative flex min-h-svh min-w-0 flex-1 flex-col bg-background",
688
+ "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-2xl md:peer-data-[variant=inset]:shadow-sm",
689
+ className,
690
+ )}
691
+ />
692
+ );
693
+ });
694
+
695
+ export const AnimatedSidebarHeader = forwardRef<
696
+ HTMLDivElement,
697
+ HTMLAttributes<HTMLDivElement>
698
+ >(function AnimatedSidebarHeader({ className, ...props }, forwardedRef) {
699
+ return (
700
+ <div
701
+ {...props}
702
+ ref={forwardedRef}
703
+ data-slot="sidebar-header"
704
+ className={cn("flex shrink-0 flex-col gap-2 p-3", className)}
705
+ />
706
+ );
707
+ });
708
+
709
+ export const AnimatedSidebarContent = forwardRef<
710
+ HTMLDivElement,
711
+ HTMLAttributes<HTMLDivElement>
712
+ >(function AnimatedSidebarContent({ className, ...props }, forwardedRef) {
713
+ return (
714
+ <div
715
+ {...props}
716
+ ref={forwardedRef}
717
+ data-slot="sidebar-content"
718
+ className={cn(
719
+ "flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden overscroll-contain px-2 py-2",
720
+ className,
721
+ )}
722
+ />
723
+ );
724
+ });
725
+
726
+ export const AnimatedSidebarFooter = forwardRef<
727
+ HTMLDivElement,
728
+ HTMLAttributes<HTMLDivElement>
729
+ >(function AnimatedSidebarFooter({ className, ...props }, forwardedRef) {
730
+ return (
731
+ <div
732
+ {...props}
733
+ ref={forwardedRef}
734
+ data-slot="sidebar-footer"
735
+ className={cn(
736
+ "flex shrink-0 flex-col gap-2 border-border border-t p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]",
737
+ className,
738
+ )}
739
+ />
740
+ );
741
+ });
742
+
743
+ export const AnimatedSidebarGroup = forwardRef<
744
+ HTMLDivElement,
745
+ HTMLAttributes<HTMLDivElement>
746
+ >(function AnimatedSidebarGroup({ className, ...props }, forwardedRef) {
747
+ return (
748
+ <div
749
+ {...props}
750
+ ref={forwardedRef}
751
+ data-slot="sidebar-group"
752
+ className={cn("flex w-full min-w-0 flex-col px-1 py-1.5", className)}
753
+ />
754
+ );
755
+ });
756
+
757
+ export const AnimatedSidebarGroupLabel = forwardRef<
758
+ HTMLDivElement,
759
+ HTMLAttributes<HTMLDivElement>
760
+ >(function AnimatedSidebarGroupLabel(
761
+ { children, className, ...props },
762
+ forwardedRef,
763
+ ) {
764
+ const { collapsed } = useAnimatedSidebarPanel();
765
+
766
+ return (
767
+ <div
768
+ {...props}
769
+ ref={forwardedRef}
770
+ aria-hidden={collapsed}
771
+ data-slot="sidebar-group-label"
772
+ className={cn(
773
+ "mb-1 h-7 overflow-hidden px-2 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground transition-opacity",
774
+ collapsed ? "opacity-0" : "opacity-100",
775
+ className,
776
+ )}
777
+ >
778
+ {children}
779
+ </div>
780
+ );
781
+ });
782
+
783
+ export const AnimatedSidebarGroupContent = forwardRef<
784
+ HTMLDivElement,
785
+ HTMLAttributes<HTMLDivElement>
786
+ >(function AnimatedSidebarGroupContent(
787
+ { className, ...props },
788
+ forwardedRef,
789
+ ) {
790
+ return (
791
+ <div
792
+ {...props}
793
+ ref={forwardedRef}
794
+ data-slot="sidebar-group-content"
795
+ className={cn("w-full min-w-0", className)}
796
+ />
797
+ );
798
+ });
799
+
800
+ export const AnimatedSidebarMenu = forwardRef<
801
+ HTMLUListElement,
802
+ HTMLAttributes<HTMLUListElement>
803
+ >(function AnimatedSidebarMenu(
804
+ { children, className, ...props },
805
+ forwardedRef,
806
+ ) {
807
+ return (
808
+ <SharedLayoutBg
809
+ {...props}
810
+ ref={forwardedRef as React.Ref<HTMLElement>}
811
+ as="ul"
812
+ inset={0}
813
+ pillClassName="rounded-xl bg-muted/70"
814
+ pillContainerClassName="inset-y-auto top-0 h-9"
815
+ data-slot="sidebar-menu"
816
+ className={cn("flex w-full min-w-0 list-none flex-col gap-0.5", className)}
817
+ >
818
+ {children}
819
+ </SharedLayoutBg>
820
+ );
821
+ });
822
+
823
+ export const AnimatedSidebarMenuItem = forwardRef<
824
+ HTMLLIElement,
825
+ HTMLMotionProps<"li">
826
+ >(function AnimatedSidebarMenuItem({ className, ...props }, forwardedRef) {
827
+ return (
828
+ <motion.li
829
+ {...props}
830
+ ref={forwardedRef}
831
+ layout="position"
832
+ transition={SPRING_LAYOUT}
833
+ data-slot="sidebar-menu-item"
834
+ className={cn("relative", className)}
835
+ />
836
+ );
837
+ });
838
+
839
+ export interface AnimatedSidebarMenuSubProps
840
+ extends Omit<HTMLMotionProps<"ul">, "children"> {
841
+ open: boolean;
842
+ children?: ReactNode;
843
+ }
844
+
845
+ export const AnimatedSidebarMenuSub = forwardRef<
846
+ HTMLUListElement,
847
+ AnimatedSidebarMenuSubProps
848
+ >(function AnimatedSidebarMenuSub(
849
+ { open, children, className, ...props },
850
+ forwardedRef,
851
+ ) {
852
+ const context = useAnimatedSidebar();
853
+ const panel = useAnimatedSidebarPanel();
854
+
855
+ return (
856
+ <AnimatePresence initial={false} mode="popLayout">
857
+ {open && !panel.collapsed ? (
858
+ <motion.ul
859
+ {...props}
860
+ ref={forwardedRef}
861
+ key="sidebar-submenu"
862
+ variants={context.reduce ? undefined : SUBMENU_VARIANTS}
863
+ initial={context.reduce ? false : "closed"}
864
+ animate={context.reduce ? { opacity: 1 } : "open"}
865
+ exit={context.reduce ? { opacity: 0 } : "closed"}
866
+ transition={context.reduce ? { duration: 0.12 } : undefined}
867
+ data-slot="sidebar-menu-sub"
868
+ className={cn(
869
+ "relative mt-1 ml-5 flex min-w-0 flex-col gap-0.5 border-border border-l pl-3",
870
+ className,
871
+ )}
872
+ >
873
+ {children}
874
+ </motion.ul>
875
+ ) : null}
876
+ </AnimatePresence>
877
+ );
878
+ });
879
+
880
+ export const AnimatedSidebarMenuSubItem = forwardRef<
881
+ HTMLLIElement,
882
+ HTMLMotionProps<"li">
883
+ >(function AnimatedSidebarMenuSubItem(
884
+ { className, ...props },
885
+ forwardedRef,
886
+ ) {
887
+ return (
888
+ <motion.li
889
+ {...props}
890
+ ref={forwardedRef}
891
+ variants={SUBMENU_ITEM_VARIANTS}
892
+ data-slot="sidebar-menu-sub-item"
893
+ className={cn("relative min-w-0", className)}
894
+ />
895
+ );
896
+ });
897
+
898
+ export interface AnimatedSidebarMenuSubButtonProps {
899
+ children: ReactNode;
900
+ icon?: ReactNode;
901
+ href?: string;
902
+ isActive?: boolean;
903
+ disabled?: boolean;
904
+ closeOnSelect?: boolean;
905
+ target?: "_blank" | "_self" | "_parent" | "_top";
906
+ rel?: string;
907
+ onSelect?: () => void;
908
+ className?: string;
909
+ }
910
+
911
+ export function AnimatedSidebarMenuSubButton({
912
+ children,
913
+ icon,
914
+ href,
915
+ isActive = false,
916
+ disabled = false,
917
+ closeOnSelect = true,
918
+ target,
919
+ rel,
920
+ onSelect,
921
+ className,
922
+ }: AnimatedSidebarMenuSubButtonProps) {
923
+ const context = useAnimatedSidebar();
924
+
925
+ const select = (
926
+ event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>,
927
+ ) => {
928
+ if (disabled) {
929
+ event.preventDefault();
930
+ return;
931
+ }
932
+ onSelect?.();
933
+ if (context.isMobile && closeOnSelect) context.setOpenMobile(false);
934
+ };
935
+
936
+ const content = (
937
+ <>
938
+ <span
939
+ aria-hidden="true"
940
+ className="grid size-4 shrink-0 place-items-center"
941
+ >
942
+ {icon ?? <span className="size-1 rounded-full bg-current" />}
943
+ </span>
944
+ <span className="min-w-0 flex-1 truncate">{children}</span>
945
+ </>
946
+ );
947
+
948
+ const interactiveClassName = cn(
949
+ "flex min-h-8 w-full min-w-0 items-center gap-2 rounded-lg px-2 text-left text-xs outline-none",
950
+ "text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground",
951
+ "focus-visible:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring",
952
+ isActive && "bg-muted/70 text-foreground",
953
+ disabled && "cursor-not-allowed opacity-40",
954
+ className,
955
+ );
956
+
957
+ return href ? (
958
+ <motion.a
959
+ href={href}
960
+ target={target}
961
+ rel={
962
+ rel ??
963
+ (target === "_blank" ? "noreferrer noopener" : undefined)
964
+ }
965
+ aria-current={isActive ? "page" : undefined}
966
+ aria-disabled={disabled || undefined}
967
+ tabIndex={disabled ? -1 : undefined}
968
+ onClick={select}
969
+ whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}
970
+ transition={SPRING_PRESS}
971
+ className={interactiveClassName}
972
+ >
973
+ {content}
974
+ </motion.a>
975
+ ) : (
976
+ <motion.button
977
+ type="button"
978
+ disabled={disabled}
979
+ aria-current={isActive ? "page" : undefined}
980
+ onClick={select}
981
+ whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}
982
+ transition={SPRING_PRESS}
983
+ className={interactiveClassName}
984
+ >
985
+ {content}
986
+ </motion.button>
987
+ );
988
+ }
989
+
990
+ export interface AnimatedSidebarMenuButtonProps {
991
+ children: ReactNode;
992
+ icon?: ReactNode;
993
+ badge?: ReactNode;
994
+ href?: string;
995
+ isActive?: boolean;
996
+ ariaExpanded?: boolean;
997
+ disabled?: boolean;
998
+ closeOnSelect?: boolean;
999
+ target?: "_blank" | "_self" | "_parent" | "_top";
1000
+ rel?: string;
1001
+ onSelect?: () => void;
1002
+ className?: string;
1003
+ }
1004
+
1005
+ export function AnimatedSidebarMenuButton({
1006
+ children,
1007
+ icon,
1008
+ badge,
1009
+ href,
1010
+ isActive = false,
1011
+ ariaExpanded,
1012
+ disabled = false,
1013
+ closeOnSelect,
1014
+ target,
1015
+ rel,
1016
+ onSelect,
1017
+ className,
1018
+ }: AnimatedSidebarMenuButtonProps) {
1019
+ const context = useAnimatedSidebar();
1020
+ const panel = useAnimatedSidebarPanel();
1021
+ const textLabel = typeof children === "string" ? children : undefined;
1022
+
1023
+ const select = (
1024
+ event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>,
1025
+ ) => {
1026
+ if (disabled) {
1027
+ event.preventDefault();
1028
+ return;
1029
+ }
1030
+ onSelect?.();
1031
+ const shouldCloseOnSelect =
1032
+ closeOnSelect ?? ariaExpanded === undefined;
1033
+ if (context.isMobile && shouldCloseOnSelect) {
1034
+ context.setOpenMobile(false);
1035
+ }
1036
+ };
1037
+
1038
+ const content = (
1039
+ <>
1040
+ {isActive ? (
1041
+ <motion.span
1042
+ layoutId={context.layoutId}
1043
+ transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}
1044
+ className="absolute inset-0 rounded-xl bg-muted"
1045
+ />
1046
+ ) : null}
1047
+ {icon ? (
1048
+ <span
1049
+ aria-hidden="true"
1050
+ className="relative z-10 grid size-5 shrink-0 place-items-center"
1051
+ >
1052
+ {icon}
1053
+ </span>
1054
+ ) : null}
1055
+ <motion.span
1056
+ initial={false}
1057
+ animate={{
1058
+ opacity: panel.collapsed ? 0 : 1,
1059
+ x: panel.collapsed ? -4 : 0,
1060
+ }}
1061
+ transition={
1062
+ context.reduce
1063
+ ? REDUCED_TRANSITION
1064
+ : panel.collapsed
1065
+ ? LABEL_EXIT_TRANSITION
1066
+ : LABEL_ENTER_TRANSITION
1067
+ }
1068
+ aria-hidden={panel.collapsed}
1069
+ className={cn(
1070
+ "relative z-10 min-w-0 flex-1 truncate",
1071
+ panel.collapsed && "pointer-events-none",
1072
+ )}
1073
+ >
1074
+ {children}
1075
+ </motion.span>
1076
+ {badge && !panel.collapsed ? (
1077
+ <span className="relative z-10 shrink-0 text-xs text-muted-foreground">
1078
+ {badge}
1079
+ </span>
1080
+ ) : null}
1081
+ {ariaExpanded !== undefined ? (
1082
+ <motion.span
1083
+ aria-hidden="true"
1084
+ initial={false}
1085
+ animate={{
1086
+ opacity: panel.collapsed ? 0 : 1,
1087
+ rotate: ariaExpanded ? 90 : 0,
1088
+ x: panel.collapsed ? 4 : 0,
1089
+ }}
1090
+ transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}
1091
+ className="relative z-10 grid size-4 shrink-0 place-items-center text-muted-foreground"
1092
+ >
1093
+ <ChevronRight className="size-3.5" />
1094
+ </motion.span>
1095
+ ) : null}
1096
+ </>
1097
+ );
1098
+
1099
+ const interactiveClassName = cn(
1100
+ "relative flex min-h-9 w-full min-w-0 items-center gap-2.5 overflow-hidden rounded-xl px-3 text-left text-sm font-medium outline-none",
1101
+ "text-muted-foreground transition-colors hover:text-foreground",
1102
+ "focus-visible:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring",
1103
+ isActive && "text-foreground",
1104
+ disabled && "cursor-not-allowed opacity-40",
1105
+ className,
1106
+ );
1107
+
1108
+ return href ? (
1109
+ <motion.a
1110
+ href={href}
1111
+ target={target}
1112
+ rel={
1113
+ rel ??
1114
+ (target === "_blank" ? "noreferrer noopener" : undefined)
1115
+ }
1116
+ aria-current={isActive ? "page" : undefined}
1117
+ aria-expanded={ariaExpanded}
1118
+ aria-disabled={disabled || undefined}
1119
+ aria-label={panel.collapsed ? textLabel : undefined}
1120
+ title={panel.collapsed ? textLabel : undefined}
1121
+ tabIndex={disabled ? -1 : undefined}
1122
+ onClick={select}
1123
+ whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}
1124
+ transition={SPRING_PRESS}
1125
+ className={interactiveClassName}
1126
+ >
1127
+ {content}
1128
+ </motion.a>
1129
+ ) : (
1130
+ <motion.button
1131
+ type="button"
1132
+ disabled={disabled}
1133
+ aria-current={isActive ? "page" : undefined}
1134
+ aria-expanded={ariaExpanded}
1135
+ aria-label={panel.collapsed ? textLabel : undefined}
1136
+ title={panel.collapsed ? textLabel : undefined}
1137
+ onClick={select}
1138
+ whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}
1139
+ transition={SPRING_PRESS}
1140
+ className={interactiveClassName}
1141
+ >
1142
+ {content}
1143
+ </motion.button>
1144
+ );
1145
+ }