@vvedantb/ui 0.5.4 → 0.6.1
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/README.md +184 -5
- package/app/README.md +100 -0
- package/package.json +11 -10
- package/shell/src/AppShell.tsx +130 -43
- package/shell/src/AppSidebar.tsx +27 -125
- package/shell/src/Sidebar.tsx +78 -0
- package/shell/src/SidebarAccountMenu.tsx +169 -77
- package/shell/src/SidebarChrome.tsx +295 -0
- package/shell/src/SidebarHeader.tsx +6 -7
- package/shell/src/SidebarPanel.tsx +95 -33
- package/shell/src/SidebarRail.tsx +74 -28
- package/shell/src/SignOutDialog.tsx +24 -51
- package/shell/src/app-shell-classes.ts +6 -0
- package/shell/src/index.ts +9 -1
- package/src/index.ts +9 -1
- package/src/ui/confirm-dialog-parts.tsx +355 -0
- package/src/ui/confirm-dialog.tsx +65 -106
- package/src/ui/overlay-a11y.ts +5 -3
- package/tokens/README.md +98 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useId,
|
|
6
|
+
type ComponentProps,
|
|
7
|
+
type KeyboardEvent,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { AnimatePresence, motion, type HTMLMotionProps, type PanInfo } from "motion/react";
|
|
11
|
+
import { IconMenu2 } from "@tabler/icons-react";
|
|
12
|
+
import { Button, type ButtonProps } from "../../src/ui/button";
|
|
13
|
+
import { motionEase, motionTiming } from "../../src/motion/presets";
|
|
14
|
+
import { cn } from "../../src/utils/cn";
|
|
15
|
+
import { mobileMenuButtonClass } from "./app-shell-classes";
|
|
16
|
+
import { SidebarResizeHandle, useSidebarResizeContext, type SidebarResizeHandleProps } from "./SidebarResize";
|
|
17
|
+
|
|
18
|
+
type SidebarContextValue = {
|
|
19
|
+
isDesktop: boolean;
|
|
20
|
+
mobileOpen: boolean;
|
|
21
|
+
onMobileOpenChange: (open: boolean) => void;
|
|
22
|
+
collapsed: boolean;
|
|
23
|
+
panelHidden: boolean;
|
|
24
|
+
drawerId: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const SidebarContext = createContext<SidebarContextValue | null>(null);
|
|
28
|
+
|
|
29
|
+
/** The nearest `Sidebar.Root`, or `null`. */
|
|
30
|
+
export function useOptionalSidebar(): SidebarContextValue | null {
|
|
31
|
+
return useContext(SidebarContext);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function useSidebar(part: string): SidebarContextValue {
|
|
35
|
+
const sidebar = useContext(SidebarContext);
|
|
36
|
+
if (!sidebar) throw new Error(`Sidebar.${part} must be inside Sidebar.Root`);
|
|
37
|
+
return sidebar;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type SidebarRootProps = {
|
|
41
|
+
/** From the app's desktop media query (for example `(min-width: 768px)`). */
|
|
42
|
+
isDesktop: boolean;
|
|
43
|
+
/** Mobile drawer state. Owned by the app (which also closes it on route change). */
|
|
44
|
+
mobileOpen: boolean;
|
|
45
|
+
onMobileOpenChange: (open: boolean) => void;
|
|
46
|
+
/** Desktop: show only the rail. `Sidebar.Panel` hides itself (`md:hidden`). Default `false`. */
|
|
47
|
+
collapsed?: boolean;
|
|
48
|
+
/** The current section has no panel: rail-wide on every viewport, `Sidebar.Panel` renders nothing. Default `false`. */
|
|
49
|
+
panelHidden?: boolean;
|
|
50
|
+
/** `id` of the drawer (`Sidebar.Aside`), used by the menu button `aria-controls`. Default from `useId`. */
|
|
51
|
+
id?: string;
|
|
52
|
+
children?: ReactNode;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Sidebar state for the other parts. Renders no DOM. Closes the drawer when the desktop breakpoint is reached. */
|
|
56
|
+
function Root({
|
|
57
|
+
isDesktop,
|
|
58
|
+
mobileOpen,
|
|
59
|
+
onMobileOpenChange,
|
|
60
|
+
collapsed = false,
|
|
61
|
+
panelHidden = false,
|
|
62
|
+
id,
|
|
63
|
+
children,
|
|
64
|
+
}: SidebarRootProps) {
|
|
65
|
+
const autoId = useId();
|
|
66
|
+
|
|
67
|
+
// Reaching the desktop breakpoint closes the drawer, so it does not reopen
|
|
68
|
+
// on the way back down. Runs on breakpoint change only.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (isDesktop && mobileOpen) onMobileOpenChange(false);
|
|
71
|
+
}, [isDesktop]);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<SidebarContext.Provider
|
|
75
|
+
value={{ isDesktop, mobileOpen, onMobileOpenChange, collapsed, panelHidden, drawerId: id ?? autoId }}
|
|
76
|
+
>
|
|
77
|
+
{children}
|
|
78
|
+
</SidebarContext.Provider>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function closeOnEscape(sidebar: SidebarContextValue, event: KeyboardEvent) {
|
|
83
|
+
if (event.key === "Escape" && !sidebar.isDesktop && sidebar.mobileOpen) {
|
|
84
|
+
sidebar.onMobileOpenChange(false);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type SidebarMobileHeaderProps = ComponentProps<"header">;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Fixed header below `md`. Escape inside it closes the drawer. Three columns
|
|
92
|
+
* keep the title centred and stop it overlapping the actions: it truncates instead.
|
|
93
|
+
*/
|
|
94
|
+
function MobileHeader({ className, onKeyDown, ...props }: SidebarMobileHeaderProps) {
|
|
95
|
+
const sidebar = useSidebar("MobileHeader");
|
|
96
|
+
return (
|
|
97
|
+
<header
|
|
98
|
+
{...props}
|
|
99
|
+
onKeyDown={(event) => {
|
|
100
|
+
onKeyDown?.(event);
|
|
101
|
+
closeOnEscape(sidebar, event);
|
|
102
|
+
}}
|
|
103
|
+
className={cn(
|
|
104
|
+
"fixed inset-x-0 top-0 z-30 grid h-[var(--app-shell-mobile-header-height)] grid-cols-[minmax(max-content,1fr)_minmax(0,auto)_minmax(max-content,1fr)] items-center gap-2 bg-surface px-3 pt-[env(safe-area-inset-top,0px)] pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))] md:hidden",
|
|
105
|
+
className,
|
|
106
|
+
)}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const defaultMobileMenuIcon = () => <IconMenu2 size={20} />;
|
|
112
|
+
|
|
113
|
+
export type SidebarMobileMenuButtonProps = Omit<ButtonProps, "children"> & {
|
|
114
|
+
/** `aria-label`. Default `"Open navigation menu"`. */
|
|
115
|
+
label?: string;
|
|
116
|
+
/** Icon for the drawer state. Default a 20px menu icon. */
|
|
117
|
+
icon?: (open: boolean) => ReactNode;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Opens the drawer. */
|
|
121
|
+
function MobileMenuButton({
|
|
122
|
+
label = "Open navigation menu",
|
|
123
|
+
icon = defaultMobileMenuIcon,
|
|
124
|
+
className,
|
|
125
|
+
onClick,
|
|
126
|
+
...props
|
|
127
|
+
}: SidebarMobileMenuButtonProps) {
|
|
128
|
+
const sidebar = useSidebar("MobileMenuButton");
|
|
129
|
+
return (
|
|
130
|
+
<Button
|
|
131
|
+
type="button"
|
|
132
|
+
variant="ghost"
|
|
133
|
+
size="icon"
|
|
134
|
+
{...props}
|
|
135
|
+
onClick={(event) => {
|
|
136
|
+
onClick?.(event);
|
|
137
|
+
sidebar.onMobileOpenChange(true);
|
|
138
|
+
}}
|
|
139
|
+
aria-label={label}
|
|
140
|
+
aria-expanded={sidebar.mobileOpen}
|
|
141
|
+
aria-controls={sidebar.drawerId}
|
|
142
|
+
className={cn(mobileMenuButtonClass, "justify-self-start", className)}
|
|
143
|
+
>
|
|
144
|
+
{icon(sidebar.mobileOpen)}
|
|
145
|
+
</Button>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type SidebarMobileTitleProps = ComponentProps<"h1">;
|
|
150
|
+
|
|
151
|
+
/** Centred title in the mobile header. Without children, an empty `<span>` keeps the middle column. */
|
|
152
|
+
function MobileTitle({ className, children, ...props }: SidebarMobileTitleProps) {
|
|
153
|
+
if (!children) return <span aria-hidden="true" />;
|
|
154
|
+
return (
|
|
155
|
+
<h1
|
|
156
|
+
{...props}
|
|
157
|
+
className={cn(
|
|
158
|
+
"pointer-events-none min-w-0 truncate text-center text-base font-semibold leading-tight tracking-tight text-foreground",
|
|
159
|
+
className,
|
|
160
|
+
)}
|
|
161
|
+
>
|
|
162
|
+
{children}
|
|
163
|
+
</h1>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type SidebarMobileActionsProps = ComponentProps<"div">;
|
|
168
|
+
|
|
169
|
+
/** Trailing actions in the mobile header. Without children, a menu-button-sized spacer keeps the title centred. */
|
|
170
|
+
function MobileActions({ className, children, ...props }: SidebarMobileActionsProps) {
|
|
171
|
+
if (!children) {
|
|
172
|
+
return <div {...props} className={cn("h-11 w-11 shrink-0 justify-self-end", className)} aria-hidden="true" />;
|
|
173
|
+
}
|
|
174
|
+
return (
|
|
175
|
+
<div
|
|
176
|
+
data-app-sidebar-mobile-actions=""
|
|
177
|
+
{...props}
|
|
178
|
+
className={cn("flex shrink-0 items-center justify-self-end gap-2", className)}
|
|
179
|
+
>
|
|
180
|
+
{children}
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export type SidebarBackdropProps = Omit<HTMLMotionProps<"button">, "children"> & {
|
|
186
|
+
/** `aria-label`. Default `"Close navigation"`. */
|
|
187
|
+
label?: string;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** Drawer backdrop below `md`; a click closes the drawer. Fades in and out. */
|
|
191
|
+
function Backdrop({ label = "Close navigation", className, onClick, ...props }: SidebarBackdropProps) {
|
|
192
|
+
const sidebar = useSidebar("Backdrop");
|
|
193
|
+
return (
|
|
194
|
+
<AnimatePresence initial={false}>
|
|
195
|
+
{sidebar.mobileOpen && !sidebar.isDesktop ? (
|
|
196
|
+
<motion.button
|
|
197
|
+
type="button"
|
|
198
|
+
aria-label={label}
|
|
199
|
+
{...props}
|
|
200
|
+
className={cn("fixed inset-0 z-40 bg-backdrop/80 md:hidden", className)}
|
|
201
|
+
onClick={(event) => {
|
|
202
|
+
onClick?.(event);
|
|
203
|
+
sidebar.onMobileOpenChange(false);
|
|
204
|
+
}}
|
|
205
|
+
initial={{ opacity: 0 }}
|
|
206
|
+
animate={{ opacity: 1 }}
|
|
207
|
+
exit={{ opacity: 0 }}
|
|
208
|
+
transition={{ duration: motionTiming.sidebar, ease: motionEase }}
|
|
209
|
+
/>
|
|
210
|
+
) : null}
|
|
211
|
+
</AnimatePresence>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export type SidebarAsideProps = HTMLMotionProps<"aside">;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The `<aside>`: fixed on desktop, a swipe-to-close drawer below it. Width
|
|
219
|
+
* reads the `--app-shell-*` variables. `id` overrides the Root drawer id
|
|
220
|
+
* (keep them equal so `aria-controls` points here).
|
|
221
|
+
*/
|
|
222
|
+
function Aside({ id, className, onKeyDown, children, ...props }: SidebarAsideProps) {
|
|
223
|
+
const sidebar = useSidebar("Aside");
|
|
224
|
+
const resize = useSidebarResizeContext();
|
|
225
|
+
const { isDesktop, mobileOpen, collapsed, panelHidden } = sidebar;
|
|
226
|
+
|
|
227
|
+
const handleDrawerDragEnd = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
|
228
|
+
if (isDesktop || !mobileOpen) return;
|
|
229
|
+
if (info.offset.x < -72 || info.velocity.x < -500) {
|
|
230
|
+
sidebar.onMobileOpenChange(false);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<motion.aside
|
|
236
|
+
id={id ?? sidebar.drawerId}
|
|
237
|
+
data-sidebar-layout={isDesktop ? "desktop" : "drawer"}
|
|
238
|
+
{...props}
|
|
239
|
+
inert={!isDesktop && !mobileOpen}
|
|
240
|
+
onKeyDown={(event) => {
|
|
241
|
+
onKeyDown?.(event);
|
|
242
|
+
closeOnEscape(sidebar, event);
|
|
243
|
+
}}
|
|
244
|
+
className={cn(
|
|
245
|
+
"fixed inset-y-0 left-0 z-50 flex bg-background pb-[env(safe-area-inset-bottom,0px)] pt-[env(safe-area-inset-top,0px)] md:py-0",
|
|
246
|
+
panelHidden
|
|
247
|
+
? "w-[var(--app-shell-rail-width)]"
|
|
248
|
+
: "w-[min(var(--app-shell-sidebar-width),calc(100vw-1.5rem))]",
|
|
249
|
+
collapsed || panelHidden
|
|
250
|
+
? "md:w-[var(--app-shell-rail-width)]"
|
|
251
|
+
: "md:w-[var(--app-shell-sidebar-width)]",
|
|
252
|
+
!resize?.isResizing &&
|
|
253
|
+
"md:transition-[width] md:[transition-duration:280ms] md:[transition-timing-function:cubic-bezier(0.22,1,0.36,1)]",
|
|
254
|
+
className,
|
|
255
|
+
)}
|
|
256
|
+
initial={false}
|
|
257
|
+
animate={{ x: isDesktop || mobileOpen ? 0 : "-100%" }}
|
|
258
|
+
transition={{ duration: motionTiming.sidebar, ease: motionEase }}
|
|
259
|
+
drag={!isDesktop && mobileOpen ? "x" : false}
|
|
260
|
+
dragDirectionLock
|
|
261
|
+
dragConstraints={{ left: 0, right: 0 }}
|
|
262
|
+
dragElastic={{ left: 0.45, right: 0.08 }}
|
|
263
|
+
onDragEnd={handleDrawerDragEnd}
|
|
264
|
+
>
|
|
265
|
+
{children}
|
|
266
|
+
</motion.aside>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export type SidebarResizeHandlePartProps = SidebarResizeHandleProps;
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* `SidebarResizeHandle` inside a resizable `AppShell`. Under a `Sidebar.Root`
|
|
274
|
+
* it shows only on desktop while the panel shows (not collapsed or hidden).
|
|
275
|
+
* Place it inside `Sidebar.Aside`.
|
|
276
|
+
*/
|
|
277
|
+
function ResizeHandle(props: SidebarResizeHandlePartProps) {
|
|
278
|
+
const sidebar = useOptionalSidebar();
|
|
279
|
+
const resize = useSidebarResizeContext();
|
|
280
|
+
const show =
|
|
281
|
+
resize !== null &&
|
|
282
|
+
(sidebar === null || (sidebar.isDesktop && !sidebar.collapsed && !sidebar.panelHidden));
|
|
283
|
+
return show ? <SidebarResizeHandle {...props} /> : null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export const SidebarChromeParts = {
|
|
287
|
+
Root,
|
|
288
|
+
MobileHeader,
|
|
289
|
+
MobileMenuButton,
|
|
290
|
+
MobileTitle,
|
|
291
|
+
MobileActions,
|
|
292
|
+
Backdrop,
|
|
293
|
+
Aside,
|
|
294
|
+
ResizeHandle,
|
|
295
|
+
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { sidebarHeaderRowClass } from "./app-shell-classes";
|
|
2
3
|
import { cn } from "./cn.js";
|
|
4
|
+
import { PanelActions, PanelTitle } from "./SidebarPanel";
|
|
3
5
|
|
|
4
6
|
export type SidebarHeaderProps = {
|
|
5
7
|
title: string;
|
|
@@ -7,15 +9,12 @@ export type SidebarHeaderProps = {
|
|
|
7
9
|
className?: string;
|
|
8
10
|
};
|
|
9
11
|
|
|
12
|
+
/** Header row: a title and optional trailing actions. Inside a panel, compose `Sidebar.PanelHeader` instead. */
|
|
10
13
|
export function SidebarHeader({ title, trailing, className }: SidebarHeaderProps) {
|
|
11
14
|
return (
|
|
12
|
-
<div className={cn(
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
</h2>
|
|
16
|
-
{trailing ? (
|
|
17
|
-
<div className="flex items-center justify-end gap-0.5">{trailing}</div>
|
|
18
|
-
) : null}
|
|
15
|
+
<div className={cn(sidebarHeaderRowClass, className)}>
|
|
16
|
+
<PanelTitle>{title}</PanelTitle>
|
|
17
|
+
{trailing ? <PanelActions>{trailing}</PanelActions> : null}
|
|
19
18
|
</div>
|
|
20
19
|
);
|
|
21
20
|
}
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
1
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
2
2
|
import { IconX } from "@tabler/icons-react";
|
|
3
|
-
import { Button } from "../../src/ui/button";
|
|
3
|
+
import { Button, type ButtonProps } from "../../src/ui/button";
|
|
4
4
|
import { cn } from "../../src/utils/cn";
|
|
5
|
-
import { panelCloseButtonClass } from "./app-shell-classes";
|
|
6
|
-
import {
|
|
5
|
+
import { panelCloseButtonClass, sidebarHeaderRowClass } from "./app-shell-classes";
|
|
6
|
+
import { useOptionalSidebar } from "./SidebarChrome";
|
|
7
7
|
|
|
8
8
|
export type SidebarPanelProps = {
|
|
9
9
|
title: string;
|
|
10
|
-
/**
|
|
10
|
+
/** Legacy (still supported): rendered in the header, before the close button. Compose `Sidebar.PanelActions`. */
|
|
11
11
|
headerTrailing?: ReactNode;
|
|
12
|
-
/**
|
|
12
|
+
/** Legacy (still supported): when set, a close button follows `headerTrailing`. Pass it on the mobile drawer only. */
|
|
13
13
|
onClose?: () => void;
|
|
14
|
-
/**
|
|
14
|
+
/** Legacy (still supported): close button label. Default `"Close navigation"`. */
|
|
15
15
|
closeLabel?: string;
|
|
16
|
-
/**
|
|
16
|
+
/** Legacy (still supported): merged onto the close button. */
|
|
17
17
|
closeButtonClassName?: string;
|
|
18
18
|
/** Desktop: hides the panel (`md:hidden`) and leaves the rail. */
|
|
19
19
|
collapsed?: boolean;
|
|
@@ -22,50 +22,112 @@ export type SidebarPanelProps = {
|
|
|
22
22
|
children?: ReactNode;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
/** Sidebar panel column beside the rail: a
|
|
25
|
+
/** Sidebar panel column beside the rail: a header and the app's body. Composed from `Sidebar.Panel` parts. */
|
|
26
26
|
export function SidebarPanel({
|
|
27
27
|
title,
|
|
28
28
|
headerTrailing,
|
|
29
29
|
onClose,
|
|
30
|
-
closeLabel
|
|
30
|
+
closeLabel,
|
|
31
31
|
closeButtonClassName,
|
|
32
32
|
collapsed = false,
|
|
33
33
|
className,
|
|
34
34
|
children,
|
|
35
35
|
}: SidebarPanelProps) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
{
|
|
40
|
-
{onClose ? (
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
className={cn(panelCloseButtonClass, closeButtonClassName)}
|
|
48
|
-
>
|
|
49
|
-
<IconX className="h-4 w-4" />
|
|
50
|
-
</Button>
|
|
36
|
+
return (
|
|
37
|
+
<Panel collapsed={collapsed} panelHidden={false} className={className}>
|
|
38
|
+
<PanelHeader>
|
|
39
|
+
<PanelTitle>{title}</PanelTitle>
|
|
40
|
+
{headerTrailing || onClose ? (
|
|
41
|
+
<PanelActions>
|
|
42
|
+
{headerTrailing}
|
|
43
|
+
{onClose ? (
|
|
44
|
+
<PanelClose onClick={onClose} label={closeLabel} className={closeButtonClassName} />
|
|
45
|
+
) : null}
|
|
46
|
+
</PanelActions>
|
|
51
47
|
) : null}
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
</PanelHeader>
|
|
49
|
+
{children}
|
|
50
|
+
</Panel>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
54
53
|
|
|
54
|
+
export type SidebarPanelRootProps = ComponentPropsWithoutRef<"div"> & {
|
|
55
|
+
/** Desktop: hides the panel (`md:hidden`). Default the `Sidebar.Root` `collapsed`, else `false`. */
|
|
56
|
+
collapsed?: boolean;
|
|
57
|
+
/** Renders nothing. Default the `Sidebar.Root` `panelHidden`, else `false`. */
|
|
58
|
+
panelHidden?: boolean;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Panel column `<div>` beside the rail. */
|
|
62
|
+
export function Panel({ collapsed, panelHidden, className, ...props }: SidebarPanelRootProps) {
|
|
63
|
+
const sidebar = useOptionalSidebar();
|
|
64
|
+
if (panelHidden ?? sidebar?.panelHidden ?? false) return null;
|
|
55
65
|
return (
|
|
56
66
|
<div
|
|
67
|
+
{...props}
|
|
57
68
|
className={cn(
|
|
58
69
|
// No border-r: the main content card floats beside this panel, so a
|
|
59
70
|
// right edge line reads as a hard divider.
|
|
60
71
|
"relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background",
|
|
61
|
-
collapsed && "md:hidden",
|
|
72
|
+
(collapsed ?? sidebar?.collapsed ?? false) && "md:hidden",
|
|
62
73
|
className,
|
|
63
74
|
)}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type SidebarPanelHeaderProps = ComponentPropsWithoutRef<"div">;
|
|
80
|
+
|
|
81
|
+
/** Padded header row of the panel. Holds `PanelTitle` and `PanelActions`. `className` goes to the outer padding `<div>`. */
|
|
82
|
+
export function PanelHeader({ className, children, ...props }: SidebarPanelHeaderProps) {
|
|
83
|
+
return (
|
|
84
|
+
<div {...props} className={cn("px-2 pt-3", className)}>
|
|
85
|
+
<div className={sidebarHeaderRowClass}>{children}</div>
|
|
69
86
|
</div>
|
|
70
87
|
);
|
|
71
88
|
}
|
|
89
|
+
|
|
90
|
+
export type SidebarPanelTitleProps = ComponentPropsWithoutRef<"h2">;
|
|
91
|
+
|
|
92
|
+
/** Panel title `<h2>`, truncated. */
|
|
93
|
+
export function PanelTitle({ className, ...props }: SidebarPanelTitleProps) {
|
|
94
|
+
return (
|
|
95
|
+
<h2
|
|
96
|
+
{...props}
|
|
97
|
+
className={cn(
|
|
98
|
+
"min-w-0 flex-1 truncate text-left text-base font-semibold leading-none tracking-tight text-foreground",
|
|
99
|
+
className,
|
|
100
|
+
)}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type SidebarPanelActionsProps = ComponentPropsWithoutRef<"div">;
|
|
106
|
+
|
|
107
|
+
/** Trailing actions in the panel header. */
|
|
108
|
+
export function PanelActions({ className, ...props }: SidebarPanelActionsProps) {
|
|
109
|
+
return <div {...props} className={cn("flex items-center justify-end gap-0.5", className)} />;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type SidebarPanelCloseProps = ButtonProps & {
|
|
113
|
+
/** `aria-label`. Default `"Close navigation"`. */
|
|
114
|
+
label?: string;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Ghost close button. Without `onClick` it closes the `Sidebar.Root` drawer. */
|
|
118
|
+
export function PanelClose({ label = "Close navigation", onClick, className, children, ...props }: SidebarPanelCloseProps) {
|
|
119
|
+
const sidebar = useOptionalSidebar();
|
|
120
|
+
return (
|
|
121
|
+
<Button
|
|
122
|
+
type="button"
|
|
123
|
+
variant="ghost"
|
|
124
|
+
size="icon-sm"
|
|
125
|
+
{...props}
|
|
126
|
+
onClick={onClick ?? (() => sidebar?.onMobileOpenChange(false))}
|
|
127
|
+
aria-label={label}
|
|
128
|
+
className={cn(panelCloseButtonClass, className)}
|
|
129
|
+
>
|
|
130
|
+
{children ?? <IconX className="h-4 w-4" />}
|
|
131
|
+
</Button>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -28,7 +28,13 @@ export type SidebarRailProps = Omit<ComponentPropsWithoutRef<"div">, "children"
|
|
|
28
28
|
footerClassName?: string;
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Icon rail column. Width reads `--app-shell-rail-width`; override with `className`. Other props go to the root `<div>`.
|
|
33
|
+
*
|
|
34
|
+
* Legacy (still supported): the `top`, `footer`, `bodyAsChild`, `tabs` and
|
|
35
|
+
* `*ClassName` props. Compose `Sidebar.Rail`, `.RailHeader`, `.RailBody` and
|
|
36
|
+
* `.RailFooter` instead.
|
|
37
|
+
*/
|
|
32
38
|
export function SidebarRail({
|
|
33
39
|
top,
|
|
34
40
|
children,
|
|
@@ -42,11 +48,24 @@ export function SidebarRail({
|
|
|
42
48
|
footerClassName,
|
|
43
49
|
...props
|
|
44
50
|
}: SidebarRailProps) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
return (
|
|
52
|
+
<Rail {...props} showDivider={showDivider} className={className}>
|
|
53
|
+
{top == null ? null : <RailHeader className={topClassName}>{top}</RailHeader>}
|
|
54
|
+
<RailBody asChild={bodyAsChild} tabs={tabs} className={bodyClassName}>
|
|
55
|
+
{children}
|
|
56
|
+
</RailBody>
|
|
57
|
+
{footer == null ? null : <RailFooter className={footerClassName}>{footer}</RailFooter>}
|
|
58
|
+
</Rail>
|
|
49
59
|
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type SidebarRailRootProps = ComponentPropsWithoutRef<"div"> & {
|
|
63
|
+
/** Adds a right border between the rail and the panel. Default `false`. */
|
|
64
|
+
showDivider?: boolean;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Rail column `<div>`. Width reads `--app-shell-rail-width`. */
|
|
68
|
+
export function Rail({ showDivider = false, className, ...props }: SidebarRailRootProps) {
|
|
50
69
|
return (
|
|
51
70
|
<div
|
|
52
71
|
{...props}
|
|
@@ -55,29 +74,43 @@ export function SidebarRail({
|
|
|
55
74
|
showDivider && "border-r border-separator",
|
|
56
75
|
className,
|
|
57
76
|
)}
|
|
58
|
-
|
|
59
|
-
{top == null ? null : (
|
|
60
|
-
<div className={cn("flex w-full flex-col items-center gap-1.5 px-0 pt-3", topClassName)}>
|
|
61
|
-
{top}
|
|
62
|
-
</div>
|
|
63
|
-
)}
|
|
64
|
-
{tabs ? (
|
|
65
|
-
<TabsPrimitive.List asChild={bodyAsChild} className={bodyClass}>
|
|
66
|
-
{children}
|
|
67
|
-
</TabsPrimitive.List>
|
|
68
|
-
) : (
|
|
69
|
-
<Body className={bodyClass}>{children}</Body>
|
|
70
|
-
)}
|
|
71
|
-
{footer == null ? null : (
|
|
72
|
-
<div className={cn("flex w-full flex-col items-center gap-1.5 py-3", footerClassName)}>
|
|
73
|
-
{footer}
|
|
74
|
-
</div>
|
|
75
|
-
)}
|
|
76
|
-
</div>
|
|
77
|
+
/>
|
|
77
78
|
);
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
export type
|
|
81
|
+
export type SidebarRailHeaderProps = ComponentPropsWithoutRef<"div">;
|
|
82
|
+
|
|
83
|
+
/** Block pinned at the top of the rail, above the scroll body. */
|
|
84
|
+
export function RailHeader({ className, ...props }: SidebarRailHeaderProps) {
|
|
85
|
+
return <div {...props} className={cn("flex w-full flex-col items-center gap-1.5 px-0 pt-3", className)} />;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type SidebarRailBodyProps = ComponentPropsWithoutRef<"div"> & {
|
|
89
|
+
/** Render the body as its single child element (for example a `<nav>`), with the body classes merged. */
|
|
90
|
+
asChild?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Render a Radix tabs list (arrow-key roving focus). Use it inside a Tabs
|
|
93
|
+
* root with `orientation="vertical"` and give each item a `tabValue`.
|
|
94
|
+
*/
|
|
95
|
+
tabs?: boolean;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Scroll body of the rail. */
|
|
99
|
+
export function RailBody({ asChild = false, tabs = false, className, ...props }: SidebarRailBodyProps) {
|
|
100
|
+
const bodyClass = cn("flex w-full flex-1 flex-col items-center gap-1.5 overflow-y-auto py-2", className);
|
|
101
|
+
if (tabs) return <TabsPrimitive.List {...props} asChild={asChild} className={bodyClass} />;
|
|
102
|
+
const Body = asChild ? Slot : "div";
|
|
103
|
+
return <Body {...props} className={bodyClass} />;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type SidebarRailFooterProps = ComponentPropsWithoutRef<"div">;
|
|
107
|
+
|
|
108
|
+
/** Block pinned at the bottom of the rail. */
|
|
109
|
+
export function RailFooter({ className, ...props }: SidebarRailFooterProps) {
|
|
110
|
+
return <div {...props} className={cn("flex w-full flex-col items-center gap-1.5 py-3", className)} />;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type SidebarRailItemProps = Omit<ComponentPropsWithoutRef<"button">, "children"> & {
|
|
81
114
|
/** `aria-label`, and the tooltip unless `tooltip` is set. */
|
|
82
115
|
label: string;
|
|
83
116
|
/** Active state. With `asChild`, also sets `aria-current="page"`. */
|
|
@@ -99,7 +132,7 @@ export type RailTileProps = Omit<ComponentPropsWithoutRef<"button">, "children">
|
|
|
99
132
|
};
|
|
100
133
|
|
|
101
134
|
/** Rail tile with a right-side tooltip. Renders a native `<button>`, its child with `asChild`, or a tabs trigger with `tabValue`. */
|
|
102
|
-
export function
|
|
135
|
+
export function RailItem({
|
|
103
136
|
label,
|
|
104
137
|
active = false,
|
|
105
138
|
tooltip,
|
|
@@ -110,7 +143,7 @@ export function RailTile({
|
|
|
110
143
|
className,
|
|
111
144
|
children,
|
|
112
145
|
...props
|
|
113
|
-
}:
|
|
146
|
+
}: SidebarRailItemProps) {
|
|
114
147
|
return (
|
|
115
148
|
<SidebarIconTooltip label={tooltip ?? label} enabled={showTooltip}>
|
|
116
149
|
{tabValue !== undefined ? (
|
|
@@ -148,8 +181,21 @@ export function RailTile({
|
|
|
148
181
|
);
|
|
149
182
|
}
|
|
150
183
|
|
|
184
|
+
export type RailTileProps = SidebarRailItemProps;
|
|
185
|
+
|
|
186
|
+
/** Legacy name of `Sidebar.RailItem`. */
|
|
187
|
+
export const RailTile = RailItem;
|
|
188
|
+
|
|
189
|
+
export type SidebarRailSeparatorProps = ComponentPropsWithoutRef<"div">;
|
|
190
|
+
|
|
191
|
+
/** Short horizontal rule between rail items. */
|
|
192
|
+
export function RailSeparator({ className, ...props }: SidebarRailSeparatorProps) {
|
|
193
|
+
return <div {...props} className={cn("h-px w-8 bg-separator", className)} aria-hidden />;
|
|
194
|
+
}
|
|
195
|
+
|
|
151
196
|
export type RailDividerProps = { className?: string };
|
|
152
197
|
|
|
198
|
+
/** Legacy name of `Sidebar.RailSeparator`. */
|
|
153
199
|
export function RailDivider({ className }: RailDividerProps) {
|
|
154
|
-
return <
|
|
200
|
+
return <RailSeparator className={className} />;
|
|
155
201
|
}
|