@stasho/ds 0.13.1 → 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 +6 -1
- package/src/components/dialog/dialog.tsx +4 -6
- package/src/components/drawer/drawer.tsx +2 -3
- package/src/components/dropdown-menu/dropdown-menu.tsx +77 -0
- package/src/components/empty-state/empty-state.tsx +53 -0
- package/src/components/header/header.tsx +103 -0
- package/src/components/popover/popover.tsx +41 -0
- package/src/components/sidebar/sidebar.tsx +327 -0
- package/src/components/tooltip/tooltip.tsx +2 -4
- package/src/styles/tokens.css +36 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stasho/ds",
|
|
3
|
-
"version": "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-
|
|
33
|
-
"data-[state=closed]:animate-
|
|
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
|
-
"
|
|
43
|
-
"
|
|
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-
|
|
52
|
-
"data-[state=closed]:animate-
|
|
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
|
+
}
|
|
@@ -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-
|
|
22
|
-
"data-[state=closed]:animate-
|
|
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
|
)}
|
package/src/styles/tokens.css
CHANGED
|
@@ -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
|
}
|