@stasho/ds 0.13.1 → 0.15.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stasho/ds",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "stasho design system",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,10 +49,16 @@
49
49
  "./copyable-text": "./src/components/copyable-text/copyable-text.tsx",
50
50
  "./dialog": "./src/components/dialog/dialog.tsx",
51
51
  "./drawer": "./src/components/drawer/drawer.tsx",
52
+ "./empty-state": "./src/components/empty-state/empty-state.tsx",
52
53
  "./logo": "./src/components/logo/logo.tsx",
53
54
  "./progress-bar": "./src/components/progress-bar/progress-bar.tsx",
54
55
  "./stepper": "./src/components/stepper/stepper.tsx",
55
56
  "./loader": "./src/components/loader/loader.tsx",
57
+ "./popover": "./src/components/popover/popover.tsx",
58
+ "./dropdown-menu": "./src/components/dropdown-menu/dropdown-menu.tsx",
59
+ "./project-switcher": "./src/components/project-switcher/project-switcher.tsx",
60
+ "./sidebar": "./src/components/sidebar/sidebar.tsx",
61
+ "./header": "./src/components/header/header.tsx",
56
62
  "./lib/cn": "./src/lib/cn.ts",
57
63
  "./styles/tokens.css": "./src/styles/tokens.css"
58
64
  },
@@ -29,9 +29,8 @@ const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
29
29
  <DialogPrimitive.Overlay
30
30
  className={cn(
31
31
  "fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
32
- "data-[state=open]:animate-in data-[state=open]:fade-in-0",
33
- "data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
34
- "motion-reduce:animate-none",
32
+ "motion-safe:data-[state=open]:animate-overlay-in",
33
+ "motion-safe:data-[state=closed]:animate-overlay-out",
35
34
  )}
36
35
  />
37
36
  <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
@@ -39,9 +38,8 @@ const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
39
38
  ref={ref}
40
39
  className={cn(
41
40
  "relative w-full max-w-md rounded-xl border border-edge bg-surface p-6",
42
- "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
43
- "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
44
- "motion-reduce:animate-none",
41
+ "motion-safe:data-[state=open]:animate-pop-in",
42
+ "motion-safe:data-[state=closed]:animate-pop-out",
45
43
  className,
46
44
  )}
47
45
  {...(locked
@@ -48,9 +48,8 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
48
48
  <DialogPrimitive.Overlay
49
49
  className={cn(
50
50
  "fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
51
- "data-[state=open]:animate-in data-[state=open]:fade-in-0",
52
- "data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
53
- "motion-reduce:animate-none",
51
+ "motion-safe:data-[state=open]:animate-overlay-in",
52
+ "motion-safe:data-[state=closed]:animate-overlay-out",
54
53
  )}
55
54
  />
56
55
  <DialogPrimitive.Content
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import { DropdownMenu as RadixMenu } from "radix-ui";
4
+ import { forwardRef, type ComponentPropsWithoutRef } from "react";
5
+ import { cn } from "@ac/lib/cn";
6
+
7
+ /** Non-modal by default: Radix's modal scroll-lock pads the body for the
8
+ missing scrollbar, visibly shifting the whole page when a menu opens. */
9
+ export function DropdownMenu(
10
+ props: ComponentPropsWithoutRef<typeof RadixMenu.Root>,
11
+ ) {
12
+ return <RadixMenu.Root modal={false} {...props} />;
13
+ }
14
+ export const DropdownMenuTrigger = RadixMenu.Trigger;
15
+ export const DropdownMenuPortal = RadixMenu.Portal;
16
+ export const DropdownMenuGroup = RadixMenu.Group;
17
+ export const DropdownMenuLabel = RadixMenu.Label;
18
+ export const DropdownMenuSeparator = forwardRef<
19
+ HTMLDivElement,
20
+ ComponentPropsWithoutRef<typeof RadixMenu.Separator>
21
+ >(function DropdownMenuSeparator({ className, ...props }, ref) {
22
+ return (
23
+ <RadixMenu.Separator
24
+ ref={ref}
25
+ className={cn("my-1 h-px bg-edge", className)}
26
+ {...props}
27
+ />
28
+ );
29
+ });
30
+
31
+ type ContentProps = ComponentPropsWithoutRef<typeof RadixMenu.Content>;
32
+
33
+ export const DropdownMenuContent = forwardRef<HTMLDivElement, ContentProps>(
34
+ function DropdownMenuContent(
35
+ { className, side = "bottom", align = "start", sideOffset = 6, ...props },
36
+ ref,
37
+ ) {
38
+ return (
39
+ <RadixMenu.Portal>
40
+ <RadixMenu.Content
41
+ ref={ref}
42
+ side={side}
43
+ align={align}
44
+ sideOffset={sideOffset}
45
+ className={cn(
46
+ "z-50 min-w-[12rem] rounded-md border border-edge bg-background p-1 shadow-lg",
47
+ "outline-none",
48
+ "motion-safe:data-[state=open]:animate-pop-in",
49
+ "motion-safe:data-[state=closed]:animate-pop-out",
50
+ className,
51
+ )}
52
+ {...props}
53
+ />
54
+ </RadixMenu.Portal>
55
+ );
56
+ },
57
+ );
58
+
59
+ type ItemProps = ComponentPropsWithoutRef<typeof RadixMenu.Item>;
60
+
61
+ export const DropdownMenuItem = forwardRef<HTMLDivElement, ItemProps>(
62
+ function DropdownMenuItem({ className, ...props }, ref) {
63
+ return (
64
+ <RadixMenu.Item
65
+ ref={ref}
66
+ className={cn(
67
+ "flex cursor-pointer select-none items-center gap-2 rounded px-2 py-1.5 text-sm",
68
+ "outline-none",
69
+ "data-[highlighted]:bg-muted data-[highlighted]:text-foreground",
70
+ "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
71
+ className,
72
+ )}
73
+ {...props}
74
+ />
75
+ );
76
+ },
77
+ );
@@ -0,0 +1,53 @@
1
+ import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
2
+ import { cn } from "@ac/lib/cn";
3
+
4
+ type EmptyStateProps = Omit<
5
+ HTMLAttributes<HTMLDivElement>,
6
+ "title" | "children"
7
+ > & {
8
+ title: ReactNode;
9
+ description?: ReactNode;
10
+ icon?: ReactNode;
11
+ action?: ReactNode;
12
+ };
13
+
14
+ const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(
15
+ ({ title, description, icon, action, className, ...rest }, ref) => (
16
+ <div
17
+ ref={ref}
18
+ className={cn(
19
+ "flex flex-col items-center gap-4 px-6 py-12 text-center",
20
+ className,
21
+ )}
22
+ {...rest}
23
+ >
24
+ {icon ? (
25
+ <span
26
+ aria-hidden="true"
27
+ className="text-muted-foreground [&>svg]:size-8"
28
+ >
29
+ {icon}
30
+ </span>
31
+ ) : null}
32
+ <div className="flex flex-col gap-1.5">
33
+ <h3 className="font-heading font-bold text-lg text-foreground">
34
+ {title}
35
+ </h3>
36
+ {description ? (
37
+ <p className="mx-auto max-w-sm text-sm text-muted-foreground">
38
+ {description}
39
+ </p>
40
+ ) : null}
41
+ </div>
42
+ {action ? (
43
+ <div className="flex flex-wrap items-center justify-center gap-3">
44
+ {action}
45
+ </div>
46
+ ) : null}
47
+ </div>
48
+ ),
49
+ );
50
+
51
+ EmptyState.displayName = "EmptyState";
52
+
53
+ export { EmptyState, type EmptyStateProps };
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ import { Children, Fragment, type ReactNode } from "react";
4
+ import { Slot } from "radix-ui";
5
+ import { cn } from "@ac/lib/cn";
6
+
7
+ /* ── Header ────────────────────────────────────── */
8
+
9
+ export interface HeaderProps {
10
+ children?: ReactNode;
11
+ rightSlot?: ReactNode;
12
+ className?: string;
13
+ }
14
+
15
+ export function Header({ children, rightSlot, className }: HeaderProps) {
16
+ return (
17
+ <header
18
+ className={cn(
19
+ "sticky top-0 z-30",
20
+ "flex h-16 shrink-0 items-center gap-4",
21
+ "border-b border-edge bg-background px-4",
22
+ className,
23
+ )}
24
+ >
25
+ <a
26
+ href="#main"
27
+ className={cn(
28
+ "sr-only focus:not-sr-only",
29
+ "focus:absolute focus:left-2 focus:top-2 focus:z-50",
30
+ "focus:rounded focus:bg-accent focus:px-3 focus:py-1.5",
31
+ "focus:text-sm focus:font-bold focus:text-accent-foreground",
32
+ "focus:outline-none focus:ring-2 focus:ring-accent",
33
+ )}
34
+ >
35
+ Skip to content
36
+ </a>
37
+ <div className="flex min-w-0 flex-1 items-center gap-3">{children}</div>
38
+ {rightSlot && (
39
+ <div className="flex shrink-0 items-center">{rightSlot}</div>
40
+ )}
41
+ </header>
42
+ );
43
+ }
44
+
45
+ /* ── HeaderBreadcrumb ──────────────────────────── */
46
+
47
+ export interface HeaderBreadcrumbProps {
48
+ children: ReactNode;
49
+ className?: string;
50
+ ariaLabel?: string;
51
+ }
52
+
53
+ export function HeaderBreadcrumb({
54
+ children,
55
+ className,
56
+ ariaLabel = "Breadcrumb",
57
+ }: HeaderBreadcrumbProps) {
58
+ const items = Children.toArray(children);
59
+ return (
60
+ <nav aria-label={ariaLabel} className={cn("min-w-0", className)}>
61
+ <ol className="flex items-center gap-2 text-sm text-foreground/80">
62
+ {items.map((child, idx) => (
63
+ <Fragment key={idx}>
64
+ {idx > 0 && (
65
+ <li aria-hidden="true" className="text-foreground/30">
66
+ /
67
+ </li>
68
+ )}
69
+ {child}
70
+ </Fragment>
71
+ ))}
72
+ </ol>
73
+ </nav>
74
+ );
75
+ }
76
+
77
+ /* ── HeaderBreadcrumbSegment ───────────────────── */
78
+
79
+ export interface HeaderBreadcrumbSegmentProps {
80
+ children: ReactNode;
81
+ current?: boolean;
82
+ asChild?: boolean;
83
+ className?: string;
84
+ }
85
+
86
+ export function HeaderBreadcrumbSegment({
87
+ children,
88
+ current,
89
+ asChild,
90
+ className,
91
+ }: HeaderBreadcrumbSegmentProps) {
92
+ const Comp = asChild ? Slot.Root : "span";
93
+ return (
94
+ <li className={cn("min-w-0 truncate", className)}>
95
+ <Comp
96
+ {...(current ? { "aria-current": "page" } : {})}
97
+ className="truncate text-foreground"
98
+ >
99
+ {children}
100
+ </Comp>
101
+ </li>
102
+ );
103
+ }
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import { Popover as RadixPopover } from "radix-ui";
4
+ import { forwardRef, type ComponentPropsWithoutRef } from "react";
5
+ import { cn } from "@ac/lib/cn";
6
+
7
+ export const Popover = RadixPopover.Root;
8
+ export const PopoverTrigger = RadixPopover.Trigger;
9
+ export const PopoverAnchor = RadixPopover.Anchor;
10
+ export const PopoverClose = RadixPopover.Close;
11
+
12
+ type PopoverContentProps = ComponentPropsWithoutRef<typeof RadixPopover.Content>;
13
+
14
+ export const PopoverContent = forwardRef<
15
+ HTMLDivElement,
16
+ PopoverContentProps
17
+ >(function PopoverContent(
18
+ { className, side = "top", align = "start", sideOffset = 8, ...props },
19
+ ref,
20
+ ) {
21
+ return (
22
+ <RadixPopover.Portal>
23
+ <RadixPopover.Content
24
+ ref={ref}
25
+ side={side}
26
+ align={align}
27
+ sideOffset={sideOffset}
28
+ className={cn(
29
+ "z-50 rounded-md border border-edge bg-background p-3 shadow-lg",
30
+ "outline-none",
31
+ "motion-safe:data-[state=open]:animate-pop-in",
32
+ "motion-safe:data-[state=closed]:animate-pop-out",
33
+ className,
34
+ )}
35
+ {...props}
36
+ />
37
+ </RadixPopover.Portal>
38
+ );
39
+ });
40
+
41
+ export { type PopoverContentProps };
@@ -0,0 +1,254 @@
1
+ import { forwardRef, useState } from "react";
2
+ import { Popover } from "radix-ui";
3
+ import { Command } from "cmdk";
4
+ import {
5
+ CaretDown,
6
+ Check,
7
+ List,
8
+ Plus,
9
+ SquaresFour,
10
+ } from "@phosphor-icons/react";
11
+ import { cn } from "@ac/lib/cn";
12
+
13
+ type ProjectSwitcherItem = {
14
+ id: string;
15
+ label: string;
16
+ /** Extra search terms (e.g. full repo name, stored project name). */
17
+ keywords?: string[];
18
+ };
19
+
20
+ type ProjectSwitcherGroup = {
21
+ /** Stable key — labels are not unique (encrypted placeholders collide). */
22
+ id: string;
23
+ label: string;
24
+ items: ProjectSwitcherItem[];
25
+ };
26
+
27
+ type ProjectSwitcherProps = {
28
+ groups: ProjectSwitcherGroup[];
29
+ solos: ProjectSwitcherItem[];
30
+ currentId: string;
31
+ triggerLabel: string;
32
+ collapsed?: boolean;
33
+ onSelect: (id: string) => void;
34
+ onViewAll: () => void;
35
+ onNewProject: () => void;
36
+ searchPlaceholder?: string;
37
+ emptyMessage?: string;
38
+ viewAllLabel?: string;
39
+ newProjectLabel?: string;
40
+ className?: string;
41
+ };
42
+
43
+ function itemMatches(item: ProjectSwitcherItem, needle: string): boolean {
44
+ if (item.label.toLowerCase().includes(needle)) return true;
45
+ return (item.keywords ?? []).some((k) => k.toLowerCase().includes(needle));
46
+ }
47
+
48
+ const itemClasses = (isCurrent: boolean, indent: boolean) =>
49
+ cn(
50
+ "flex cursor-pointer select-none items-center gap-2 rounded-sm",
51
+ "px-3 py-2 text-sm text-foreground outline-none",
52
+ "data-[selected=true]:bg-muted",
53
+ indent && "pl-6",
54
+ isCurrent && "font-semibold",
55
+ );
56
+
57
+ const actionClasses = cn(
58
+ "flex cursor-pointer select-none items-center gap-2 rounded-sm",
59
+ "px-3 py-2 text-sm text-foreground outline-none",
60
+ "data-[selected=true]:bg-muted",
61
+ );
62
+
63
+ const ProjectSwitcher = forwardRef<HTMLButtonElement, ProjectSwitcherProps>(
64
+ (
65
+ {
66
+ groups,
67
+ solos,
68
+ currentId,
69
+ triggerLabel,
70
+ collapsed = false,
71
+ onSelect,
72
+ onViewAll,
73
+ onNewProject,
74
+ searchPlaceholder = "Search projects…",
75
+ emptyMessage = "No matches",
76
+ viewAllLabel = "View all projects",
77
+ newProjectLabel = "New project",
78
+ className,
79
+ },
80
+ ref,
81
+ ) => {
82
+ const [open, setOpen] = useState(false);
83
+ const [query, setQuery] = useState("");
84
+ const needle = query.trim().toLowerCase();
85
+
86
+ // Group-first filtering: a group stays WHOLE when its own label or any
87
+ // child matches; solos filter individually. Manual (shouldFilter=false)
88
+ // because cmdk's scorer would re-rank rows and break the caller's
89
+ // deterministic ordering.
90
+ const visibleGroups = needle
91
+ ? groups.filter(
92
+ (g) =>
93
+ g.label.toLowerCase().includes(needle) ||
94
+ g.items.some((i) => itemMatches(i, needle)),
95
+ )
96
+ : groups;
97
+ const visibleSolos = needle
98
+ ? solos.filter((i) => itemMatches(i, needle))
99
+ : solos;
100
+ const nothingMatches =
101
+ visibleGroups.length === 0 && visibleSolos.length === 0;
102
+
103
+ const close = () => {
104
+ setOpen(false);
105
+ setQuery("");
106
+ };
107
+
108
+ const renderItem = (item: ProjectSwitcherItem, indent: boolean) => (
109
+ <Command.Item
110
+ key={item.id}
111
+ value={item.id}
112
+ onSelect={() => {
113
+ close();
114
+ onSelect(item.id);
115
+ }}
116
+ aria-current={item.id === currentId || undefined}
117
+ className={itemClasses(item.id === currentId, indent)}
118
+ >
119
+ <span className="truncate">{item.label}</span>
120
+ {item.id === currentId && (
121
+ <Check
122
+ weight="bold"
123
+ className="ml-auto size-3.5 shrink-0 text-accent"
124
+ aria-hidden="true"
125
+ />
126
+ )}
127
+ </Command.Item>
128
+ );
129
+
130
+ return (
131
+ <Popover.Root
132
+ open={open}
133
+ onOpenChange={(next) => {
134
+ setOpen(next);
135
+ if (!next) setQuery("");
136
+ }}
137
+ >
138
+ <Popover.Trigger
139
+ ref={ref}
140
+ aria-label={triggerLabel}
141
+ title={collapsed ? triggerLabel : undefined}
142
+ className={cn(
143
+ "flex w-full items-center rounded-md",
144
+ "text-sm font-semibold text-foreground",
145
+ "hover:bg-muted",
146
+ "focus-visible:outline-none focus-visible:ring-2",
147
+ "focus-visible:ring-accent",
148
+ collapsed
149
+ ? "h-9 justify-center px-0"
150
+ : "gap-2 border border-edge bg-surface px-3 py-2",
151
+ className,
152
+ )}
153
+ >
154
+ {collapsed ? (
155
+ <SquaresFour size={20} aria-hidden="true" />
156
+ ) : (
157
+ <>
158
+ <span className="flex-1 truncate text-left">{triggerLabel}</span>
159
+ <CaretDown
160
+ size={12}
161
+ aria-hidden="true"
162
+ className="shrink-0 text-foreground/60"
163
+ />
164
+ </>
165
+ )}
166
+ </Popover.Trigger>
167
+ <Popover.Portal>
168
+ <Popover.Content
169
+ align="start"
170
+ sideOffset={4}
171
+ className={cn(
172
+ "z-50 w-72 overflow-hidden rounded-sm",
173
+ "bg-popover-bg border border-popover-border shadow",
174
+ )}
175
+ >
176
+ <Command shouldFilter={false}>
177
+ <Command.Input
178
+ value={query}
179
+ onValueChange={setQuery}
180
+ placeholder={searchPlaceholder}
181
+ className={cn(
182
+ "w-full border-b border-edge bg-transparent px-4 py-2.5",
183
+ "text-sm text-foreground placeholder:text-muted-foreground",
184
+ "outline-none",
185
+ )}
186
+ />
187
+ <Command.List className="max-h-64 overflow-y-auto p-1">
188
+ {nothingMatches && (
189
+ <div className="px-4 py-6 text-center text-sm text-muted-foreground">
190
+ {emptyMessage}
191
+ </div>
192
+ )}
193
+ {visibleGroups.map((g) => (
194
+ <Command.Group
195
+ key={g.id}
196
+ value={g.id}
197
+ heading={g.label}
198
+ className={cn(
199
+ "[&_[cmdk-group-heading]]:px-3",
200
+ "[&_[cmdk-group-heading]]:pb-1",
201
+ "[&_[cmdk-group-heading]]:pt-2",
202
+ "[&_[cmdk-group-heading]]:text-[11px]",
203
+ "[&_[cmdk-group-heading]]:uppercase",
204
+ "[&_[cmdk-group-heading]]:tracking-wider",
205
+ "[&_[cmdk-group-heading]]:text-muted-foreground",
206
+ )}
207
+ >
208
+ {g.items.map((i) => renderItem(i, true))}
209
+ </Command.Group>
210
+ ))}
211
+ {visibleSolos.map((i) => renderItem(i, false))}
212
+ <Command.Separator
213
+ alwaysRender
214
+ className="my-1 h-px bg-edge"
215
+ />
216
+ <Command.Item
217
+ value="__view-all"
218
+ onSelect={() => {
219
+ close();
220
+ onViewAll();
221
+ }}
222
+ className={actionClasses}
223
+ >
224
+ <List size={14} aria-hidden="true" />
225
+ {viewAllLabel}
226
+ </Command.Item>
227
+ <Command.Item
228
+ value="__new-project"
229
+ onSelect={() => {
230
+ close();
231
+ onNewProject();
232
+ }}
233
+ className={actionClasses}
234
+ >
235
+ <Plus size={14} aria-hidden="true" />
236
+ {newProjectLabel}
237
+ </Command.Item>
238
+ </Command.List>
239
+ </Command>
240
+ </Popover.Content>
241
+ </Popover.Portal>
242
+ </Popover.Root>
243
+ );
244
+ },
245
+ );
246
+
247
+ ProjectSwitcher.displayName = "ProjectSwitcher";
248
+
249
+ export {
250
+ ProjectSwitcher,
251
+ type ProjectSwitcherProps,
252
+ type ProjectSwitcherGroup,
253
+ type ProjectSwitcherItem,
254
+ };
@@ -0,0 +1,327 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useState,
9
+ type MouseEvent,
10
+ type ReactNode,
11
+ } from "react";
12
+ import { CaretDoubleLeft, CaretDoubleRight } from "@phosphor-icons/react";
13
+ import { LogoLetter, LogoWordmark } from "@ac/components/logo/logo";
14
+ import {
15
+ Tooltip,
16
+ TooltipContent,
17
+ TooltipTrigger,
18
+ } from "@ac/components/tooltip/tooltip";
19
+ import { cn } from "@ac/lib/cn";
20
+
21
+ /* ── Sidebar (root + context) ──────────────────── */
22
+
23
+ export interface SidebarProps {
24
+ collapsed?: boolean;
25
+ defaultCollapsed?: boolean;
26
+ onCollapsedChange?: (collapsed: boolean) => void;
27
+ storageKey?: string;
28
+ children: ReactNode;
29
+ className?: string;
30
+ }
31
+
32
+ interface SidebarContextValue {
33
+ collapsed: boolean;
34
+ toggle: () => void;
35
+ }
36
+
37
+ const SidebarContext = createContext<SidebarContextValue | null>(null);
38
+
39
+ export function useSidebarContext(): SidebarContextValue {
40
+ const ctx = useContext(SidebarContext);
41
+ if (!ctx) {
42
+ throw new Error(
43
+ "Sidebar subcomponents must be rendered inside <Sidebar>",
44
+ );
45
+ }
46
+ return ctx;
47
+ }
48
+
49
+ function readPersisted(storageKey: string | undefined): boolean | null {
50
+ if (typeof window === "undefined" || !storageKey) return null;
51
+ const raw = window.localStorage.getItem(storageKey);
52
+ if (raw === "true") return true;
53
+ if (raw === "false") return false;
54
+ return null;
55
+ }
56
+
57
+ export function Sidebar({
58
+ collapsed: controlled,
59
+ defaultCollapsed,
60
+ onCollapsedChange,
61
+ storageKey,
62
+ children,
63
+ className,
64
+ }: SidebarProps) {
65
+ const isControlled = controlled !== undefined;
66
+ const [internal, setInternal] = useState<boolean>(() => {
67
+ if (isControlled) return controlled;
68
+ return defaultCollapsed ?? false;
69
+ });
70
+
71
+ // Hydrate uncontrolled state from localStorage on mount.
72
+ useEffect(() => {
73
+ if (isControlled || !storageKey) return;
74
+ const persisted = readPersisted(storageKey);
75
+ if (persisted !== null) setInternal(persisted);
76
+ }, [isControlled, storageKey]);
77
+
78
+ const collapsed = isControlled ? controlled : internal;
79
+
80
+ const toggle = useCallback(() => {
81
+ const next = !collapsed;
82
+ if (!isControlled) setInternal(next);
83
+ if (storageKey && typeof window !== "undefined") {
84
+ window.localStorage.setItem(storageKey, next ? "true" : "false");
85
+ }
86
+ onCollapsedChange?.(next);
87
+ }, [collapsed, isControlled, onCollapsedChange, storageKey]);
88
+
89
+ return (
90
+ <SidebarContext.Provider value={{ collapsed, toggle }}>
91
+ <aside
92
+ data-collapsed={collapsed || undefined}
93
+ className={cn(
94
+ "group/sidebar shrink-0",
95
+ "sticky top-0 z-40 h-screen",
96
+ "border-r border-edge bg-background",
97
+ "flex flex-col",
98
+ "transition-[width] duration-200 ease-out",
99
+ "motion-reduce:transition-none",
100
+ collapsed ? "w-14" : "w-60",
101
+ className,
102
+ )}
103
+ >
104
+ {children}
105
+ </aside>
106
+ </SidebarContext.Provider>
107
+ );
108
+ }
109
+
110
+ /* ── SidebarHeader ─────────────────────────────── */
111
+
112
+ export interface SidebarHeaderProps {
113
+ className?: string;
114
+ children?: ReactNode;
115
+ }
116
+
117
+ export function SidebarHeader({ className, children }: SidebarHeaderProps) {
118
+ return (
119
+ <div
120
+ className={cn(
121
+ "flex h-16 shrink-0 items-center px-4",
122
+ "border-b border-edge",
123
+ "group-data-[collapsed]/sidebar:justify-center",
124
+ className,
125
+ )}
126
+ >
127
+ {children ?? (
128
+ <>
129
+ <LogoWordmark
130
+ aria-label="Stasho"
131
+ className="h-7 text-foreground group-data-[collapsed]/sidebar:hidden"
132
+ />
133
+ <LogoLetter
134
+ aria-label="Stasho"
135
+ className="hidden h-7 text-foreground group-data-[collapsed]/sidebar:block"
136
+ />
137
+ </>
138
+ )}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ /* ── SidebarNav ────────────────────────────────── */
144
+
145
+ export interface SidebarNavProps {
146
+ children: ReactNode;
147
+ className?: string;
148
+ ariaLabel?: string;
149
+ }
150
+
151
+ export function SidebarNav({
152
+ children,
153
+ className,
154
+ ariaLabel = "Main",
155
+ }: SidebarNavProps) {
156
+ return (
157
+ <nav
158
+ aria-label={ariaLabel}
159
+ className={cn("flex-1 overflow-y-auto p-2", className)}
160
+ >
161
+ <ul className="flex flex-col gap-1">{children}</ul>
162
+ </nav>
163
+ );
164
+ }
165
+
166
+ /* ── SidebarSection ────────────────────────────── */
167
+
168
+ export interface SidebarSectionProps {
169
+ title?: string;
170
+ children: ReactNode;
171
+ className?: string;
172
+ }
173
+
174
+ export function SidebarSection({
175
+ title,
176
+ children,
177
+ className,
178
+ }: SidebarSectionProps) {
179
+ const { collapsed } = useSidebarContext();
180
+ return (
181
+ <div
182
+ role="group"
183
+ aria-label={title ?? "Section"}
184
+ className={cn("flex flex-col gap-1", className)}
185
+ >
186
+ {title && !collapsed && (
187
+ <div className="px-2 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wider text-foreground/50">
188
+ {title}
189
+ </div>
190
+ )}
191
+ <ul className="flex flex-col gap-1">{children}</ul>
192
+ </div>
193
+ );
194
+ }
195
+
196
+ /* ── SidebarItem ───────────────────────────────── */
197
+
198
+ export interface SidebarItemProps {
199
+ icon: ReactNode;
200
+ label: string;
201
+ href: string;
202
+ active?: boolean;
203
+ collapsed?: boolean;
204
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
205
+ className?: string;
206
+ target?: string;
207
+ rel?: string;
208
+ }
209
+
210
+ export function SidebarItem({
211
+ icon,
212
+ label,
213
+ href,
214
+ active,
215
+ collapsed: collapsedProp,
216
+ onClick,
217
+ className,
218
+ target,
219
+ rel,
220
+ }: SidebarItemProps) {
221
+ const ctx = useSidebarContext();
222
+ const collapsed = collapsedProp ?? ctx.collapsed;
223
+
224
+ const link = (
225
+ <a
226
+ href={href}
227
+ target={target}
228
+ rel={rel}
229
+ onClick={onClick}
230
+ aria-current={active ? "page" : undefined}
231
+ className={cn(
232
+ "group flex items-center gap-3 rounded-md",
233
+ "px-3 py-2 text-sm font-medium",
234
+ "transition-colors duration-150",
235
+ "focus-visible:outline-none focus-visible:ring-2",
236
+ "focus-visible:ring-accent focus-visible:ring-offset-2",
237
+ "motion-reduce:transition-none",
238
+ collapsed && "justify-center px-2",
239
+ active
240
+ ? "bg-accent/10 text-accent"
241
+ : "text-foreground/80 hover:bg-muted hover:text-foreground",
242
+ className,
243
+ )}
244
+ >
245
+ <span
246
+ className={cn(
247
+ "shrink-0 flex items-center justify-center",
248
+ "[&>svg]:size-5",
249
+ active
250
+ ? "text-accent"
251
+ : "text-foreground/60 group-hover:text-foreground",
252
+ )}
253
+ aria-hidden="true"
254
+ >
255
+ {icon}
256
+ </span>
257
+ <span className={cn("truncate", collapsed && "sr-only")}>{label}</span>
258
+ </a>
259
+ );
260
+
261
+ return (
262
+ <li>
263
+ {collapsed ? (
264
+ <Tooltip>
265
+ <TooltipTrigger asChild>{link}</TooltipTrigger>
266
+ <TooltipContent side="right">{label}</TooltipContent>
267
+ </Tooltip>
268
+ ) : (
269
+ link
270
+ )}
271
+ </li>
272
+ );
273
+ }
274
+
275
+ /* ── SidebarFooter ─────────────────────────────── */
276
+
277
+ export interface SidebarFooterProps {
278
+ children: ReactNode;
279
+ className?: string;
280
+ }
281
+
282
+ export function SidebarFooter({ children, className }: SidebarFooterProps) {
283
+ return (
284
+ <div className={cn("shrink-0 px-3 py-2 empty:hidden", className)}>
285
+ {children}
286
+ </div>
287
+ );
288
+ }
289
+
290
+ /* ── SidebarCollapseToggle ─────────────────────── */
291
+
292
+ export interface SidebarCollapseToggleProps {
293
+ className?: string;
294
+ }
295
+
296
+ export function SidebarCollapseToggle({
297
+ className,
298
+ }: SidebarCollapseToggleProps) {
299
+ const { collapsed, toggle } = useSidebarContext();
300
+ return (
301
+ <div className={cn("shrink-0 border-t border-edge p-2", className)}>
302
+ <button
303
+ type="button"
304
+ onClick={toggle}
305
+ aria-expanded={!collapsed}
306
+ aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
307
+ className={cn(
308
+ "flex w-full items-center gap-3 rounded-md px-3 py-2",
309
+ "text-sm font-medium text-foreground/80",
310
+ "transition-colors duration-150",
311
+ "hover:bg-muted hover:text-foreground",
312
+ "focus-visible:outline-none focus-visible:ring-2",
313
+ "focus-visible:ring-accent focus-visible:ring-offset-2",
314
+ "motion-reduce:transition-none",
315
+ collapsed ? "justify-center px-2" : "justify-end",
316
+ )}
317
+ >
318
+ <span
319
+ aria-hidden="true"
320
+ className="shrink-0 flex items-center justify-center text-foreground/60 [&>svg]:size-4"
321
+ >
322
+ {collapsed ? <CaretDoubleRight /> : <CaretDoubleLeft />}
323
+ </span>
324
+ </button>
325
+ </div>
326
+ );
327
+ }
@@ -18,10 +18,8 @@ const TooltipContent = forwardRef<
18
18
  [
19
19
  "z-50 max-w-[260px] rounded-sm bg-popover-bg border border-popover-border px-3 py-1.5",
20
20
  "text-xs leading-snug text-foreground shadow-sm",
21
- "animate-in fade-in-0 zoom-in-95",
22
- "data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
23
- "data-[state=closed]:zoom-out-95",
24
- "motion-reduce:animate-none",
21
+ "motion-safe:animate-pop-in",
22
+ "motion-safe:data-[state=closed]:animate-pop-out",
25
23
  ].join(" "),
26
24
  className,
27
25
  )}
@@ -443,4 +443,40 @@
443
443
  @keyframes drawer-out-right {
444
444
  from { transform: translateX(0); }
445
445
  to { transform: translateX(100%); }
446
+ }
447
+
448
+ /* ── Overlay fade + pop (fade + 95% zoom) ──────── */
449
+
450
+ /* Same @theme-registered-utility mechanism as Accordion/Drawer above so the
451
+ `data-[state=...]:` variant composes and reduced motion is honored by gating
452
+ the animation INTO motion-safe. `overlay-*` is the frosted-scrim fade shared
453
+ by the Dialog and Drawer overlays; `pop-*` is the fade + 95% zoom shared by
454
+ Dialog content and Tooltip. Replaces the inert tw-animate-css class strings
455
+ (`animate-in`/`fade-in-0`/`zoom-in-95`) those components used to carry — the
456
+ DS has no tw-animate-css, so those resolved to nothing. */
457
+ @theme {
458
+ --animate-overlay-in: overlay-in 200ms ease-out;
459
+ --animate-overlay-out: overlay-out 150ms ease-in;
460
+ --animate-pop-in: pop-in 200ms ease-out;
461
+ --animate-pop-out: pop-out 150ms ease-in;
462
+ }
463
+
464
+ @keyframes overlay-in {
465
+ from { opacity: 0; }
466
+ to { opacity: 1; }
467
+ }
468
+
469
+ @keyframes overlay-out {
470
+ from { opacity: 1; }
471
+ to { opacity: 0; }
472
+ }
473
+
474
+ @keyframes pop-in {
475
+ from { opacity: 0; transform: scale(0.95); }
476
+ to { opacity: 1; transform: scale(1); }
477
+ }
478
+
479
+ @keyframes pop-out {
480
+ from { opacity: 1; transform: scale(1); }
481
+ to { opacity: 0; transform: scale(0.95); }
446
482
  }