@stasho/ds 0.13.0 → 0.14.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.0",
3
+ "version": "0.14.0",
4
4
  "description": "stasho design system",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,10 +49,15 @@
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
+ "./sidebar": "./src/components/sidebar/sidebar.tsx",
60
+ "./header": "./src/components/header/header.tsx",
56
61
  "./lib/cn": "./src/lib/cn.ts",
57
62
  "./styles/tokens.css": "./src/styles/tokens.css"
58
63
  },
@@ -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,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
+ }
@@ -193,7 +193,9 @@ type OverflowTriggerProps = {
193
193
 
194
194
  const OverflowTrigger = forwardRef<HTMLButtonElement, OverflowTriggerProps>(
195
195
  ({ isPill, isSmall, hiddenTabs, hasActiveHidden, visible }, ref) => (
196
- <DropdownMenu.Root>
196
+ // Non-modal: Radix's modal scroll-lock pads <body> for the missing
197
+ // scrollbar, visibly shifting/squeezing the page on mobile viewports.
198
+ <DropdownMenu.Root modal={false}>
197
199
  <DropdownMenu.Trigger asChild>
198
200
  <button
199
201
  ref={ref}
@@ -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
  }