@vvedantb/ui 0.4.4 → 0.5.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/README.md +51 -1
- package/package.json +2 -2
- package/shell/src/AppShell.tsx +83 -0
- package/shell/src/AppSidebar.tsx +162 -0
- package/shell/src/SidebarAccountMenu.tsx +121 -0
- package/shell/src/SidebarIconTooltip.tsx +34 -0
- package/shell/src/SidebarPanel.tsx +71 -0
- package/shell/src/SidebarRail.tsx +130 -0
- package/shell/src/SignOutDialog.tsx +93 -0
- package/shell/src/app-shell-classes.ts +44 -0
- package/shell/src/index.ts +25 -0
- package/src/index.ts +28 -0
- package/src/motion/panel-transition.tsx +12 -1
- package/shell/src/SessionsHoverCard.test.tsx +0 -256
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Shared React 19 web UI. Source-exported: no build step.
|
|
|
4
4
|
|
|
5
5
|
| Import | What it is |
|
|
6
6
|
| --- | --- |
|
|
7
|
-
| `@vvedantb/ui` | The only JavaScript entry. Primitives (Radix-based), AI elements, audio (`playChime`, `useReplyChime`, `playTone`), `toast` and `SonnerToaster`, the composer (`InputArea`, deprecated 0.3 `Composer`), shell (`SidebarHeader`, `PillTabs`, `SessionsHoverCard`), app helpers (confirm skip, update watcher, settings chrome, `useStableAuth`), `cn`, and the native tokens `tokens` and `Tokens`. See `app/README.md` in the repository for the app helpers. |
|
|
7
|
+
| `@vvedantb/ui` | The only JavaScript entry. Primitives (Radix-based), AI elements, audio (`playChime`, `useReplyChime`, `playTone`), `toast` and `SonnerToaster`, the composer (`InputArea`, deprecated 0.3 `Composer`), shell (`SidebarHeader`, `PillTabs`, `SessionsHoverCard`, and the app shell: `AppShell`, `AppSidebar`, `SidebarRail`, `RailTile`, `SidebarPanel`, `SidebarAccountMenu`, `SignOutDialog` and related helpers), app helpers (confirm skip, update watcher, settings chrome, `useStableAuth`), `cn`, and the native tokens `tokens` and `Tokens`. See `app/README.md` in the repository for the app helpers. |
|
|
8
8
|
| `@vvedantb/ui/tokens` | `tokens.css`: colour, radius, shadow, ease and font tokens for Tailwind v4. CSS. |
|
|
9
9
|
| `@vvedantb/ui/tokens/tokens.css` | Same file as `@vvedantb/ui/tokens`. CSS. |
|
|
10
10
|
| `@vvedantb/ui/tokens/css/*.css` | `index`, `base`, `dialog`, `modal`, `tabs`, `tabs-pills`, `utilities`, `scrollbar-thin`, `animations`, `compat-shadcn-rgb`. CSS. |
|
|
@@ -248,6 +248,56 @@ import {
|
|
|
248
248
|
- `SelectField`: a labelled single select built from `Select`. Children are `SelectItem`s. An empty `value` shows the placeholder. `id` goes to the trigger and the label points at it. `className` merges onto the trigger.
|
|
249
249
|
- Motion: `springTransition`, `springFastTransition` and `fadeUpSpring` presets. `FadePresence` takes optional `variants` (`hidden`, `show`, `exit`); omitted, it keeps its opacity fade. `PanelTransition` wraps children in React `ViewTransition` with `default="none"` and `enter`/`exit` classes (`vt-panel-in`, `vt-panel-out` by default). It needs a React build that exports `ViewTransition`, and the app supplies the `::view-transition-*` CSS.
|
|
250
250
|
|
|
251
|
+
## App shell (0.5.0)
|
|
252
|
+
|
|
253
|
+
A sidebar and main page layout, extracted from existing app markup. It is router-free: pass your own links (with `asChild`), state and media query result. Tooltips need a `TooltipProvider` above the shell.
|
|
254
|
+
|
|
255
|
+
```tsx
|
|
256
|
+
import {
|
|
257
|
+
AppShell, AppSidebar, SidebarRail, RailTile, RailDivider, SidebarPanel,
|
|
258
|
+
SidebarAccountMenu, DropdownMenuItem, railBadgeClass,
|
|
259
|
+
} from "@vvedantb/ui";
|
|
260
|
+
|
|
261
|
+
<AppShell
|
|
262
|
+
panelHidden={collapsed || !hasPanel}
|
|
263
|
+
sidebar={
|
|
264
|
+
<AppSidebar
|
|
265
|
+
isDesktop={isDesktop}
|
|
266
|
+
collapsed={collapsed}
|
|
267
|
+
panelHidden={!hasPanel}
|
|
268
|
+
mobileOpen={open}
|
|
269
|
+
onMobileOpenChange={setOpen}
|
|
270
|
+
mobileTitle={pageTitle}
|
|
271
|
+
rail={
|
|
272
|
+
<SidebarRail
|
|
273
|
+
showDivider={hasPanel && !(isDesktop && collapsed)}
|
|
274
|
+
top={<RailTile asChild label="Home" active={isHome}><Link to="/"><IconHome size={22} /></Link></RailTile>}
|
|
275
|
+
bodyClassName="scrollbar-thin"
|
|
276
|
+
footerClassName="border-t border-separator"
|
|
277
|
+
footer={<SidebarAccountMenu name={name} email={email} imageUrl={avatarUrl}><DropdownMenuItem>Settings</DropdownMenuItem></SidebarAccountMenu>}
|
|
278
|
+
>
|
|
279
|
+
<RailDivider />
|
|
280
|
+
</SidebarRail>
|
|
281
|
+
}
|
|
282
|
+
panel={<SidebarPanel title="Library" collapsed={collapsed} onClose={isDesktop ? undefined : () => setOpen(false)}>{nav}</SidebarPanel>}
|
|
283
|
+
/>
|
|
284
|
+
}
|
|
285
|
+
>
|
|
286
|
+
{page}
|
|
287
|
+
</AppShell>
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
- Widths: `AppShell` sets `--app-shell-rail-width` (default `4rem`), `--app-shell-sidebar-width` (rail plus panel, default `20rem`) and `--app-shell-mobile-header-height` (default `calc(3.5rem + env(safe-area-inset-top, 0px))`) as inline style. Change them with `railWidth`, `sidebarWidth` and `mobileHeaderHeight`. The other pieces read these variables. Outside `AppShell`, set the variables yourself or pass a width class with `className`.
|
|
291
|
+
- `AppShell`: `sidebar`, `children`, `panelHidden` (main offsets by the rail width instead of the sidebar width), `skipLinkLabel` (`"Skip to main content"`), `mainId` (`"main-content"`), `className`, `mainClassName`, `contentClassName`.
|
|
292
|
+
- `AppSidebar`: `rail`, `panel` (not rendered while `panelHidden`), `collapsed`, `panelHidden`, `isDesktop`, `mobileOpen`, `onMobileOpenChange`, `id` (default from `useId`), `asideProps` (`data-*` attributes for the `<aside>`), `className`, `mobileTitle`, `mobileMenuIcon` (`(open) => node`, default a menu icon), `mobileMenuLabel` (`"Open navigation menu"`), `closeLabel` (`"Close navigation"`). Swipe left or Escape closes the drawer. The drawer is `inert` while closed on mobile, and closes when `isDesktop` becomes `true`. Close it on route change in the app.
|
|
293
|
+
- `SidebarRail`: `top`, `children` (scroll body), `footer`, `showDivider`, `bodyAsChild` (the single child, for example a tabs list, becomes the body), `className`, `topClassName`, `bodyClassName`, `footerClassName`. Other HTML attributes (`id`, `data-*`, `aria-*`) go to the root `<div>`. The top and footer wrappers render only when their slot is set.
|
|
294
|
+
- `RailTile`: `label` (`aria-label` and tooltip), `active`, `tooltip`, `showTooltip` (default `true`), `badge` (inside the tile, after the children; style it with `railBadgeClass`), `asChild`, `className`. Without `asChild` it is a ghost `Button`; other props go to it. With `asChild` the child (for example a router link) gets the tile classes and `aria-current="page"` when active.
|
|
295
|
+
- `RailDivider`, `SidebarIconTooltip` (`label`, `enabled`, `side` `"right"`, `sideOffset` 8).
|
|
296
|
+
- `SidebarPanel`: `title`, `headerTrailing`, `onClose` (adds the close button; pass it on the mobile drawer only), `closeLabel`, `closeButtonClassName`, `collapsed` (`md:hidden`), `className`, `children`.
|
|
297
|
+
- `SidebarAccountMenu`: `name`, `email` (two-line label when set), `stackedLabel` (two-line layout even without `email`, default `false`), `imageUrl` (initials without it), `align` (`"center"`), `side` (`"right"`), `sideOffset` (8), `collisionPadding`, `contentClassName`, `children` (menu items). `SidebarAccountMenuPlaceholder` is the tile-sized loading box.
|
|
298
|
+
- `SignOutDialog`: `open`, `onOpenChange` (not called with `false` while `pending`), `pending`, `onConfirm`, `description`, `contentClassName`, `title` (`"Sign out"`), `question` (`"Sign out of your account?"`).
|
|
299
|
+
- Class helpers: `railTileClass`, `railTileStateClass(active)`, `railBadgeClass`, `sidebarNavRowClass(isIconOnly)`, `sidebarNavLinkTextClass(isActive)`, `sidebarSectionLabelClass`.
|
|
300
|
+
|
|
251
301
|
## Toaster
|
|
252
302
|
|
|
253
303
|
```tsx
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Shared React 19 UI for web apps: one JavaScript entry (primitives, shell pieces, app helpers, native tokens), design-token CSS, shared Tailwind v4 CSS and a Tailwind preset.",
|
|
6
6
|
"type": "module",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"types": "./src/index.ts",
|
|
41
41
|
"files": [
|
|
42
42
|
"src",
|
|
43
|
-
"shell/src",
|
|
43
|
+
"shell/src/**/*.{ts,tsx}",
|
|
44
44
|
"app/src",
|
|
45
45
|
"tokens/src",
|
|
46
46
|
"tokens/examples",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
import { cn } from "../../src/utils/cn";
|
|
3
|
+
|
|
4
|
+
export type AppShellProps = {
|
|
5
|
+
/** Rendered between the skip link and `<main>`, usually an `AppSidebar`. */
|
|
6
|
+
sidebar?: ReactNode;
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
/**
|
|
9
|
+
* `true` when only the rail shows on desktop (sidebar collapsed, or the
|
|
10
|
+
* current section has no panel). Main then offsets by the rail width
|
|
11
|
+
* instead of the sidebar width.
|
|
12
|
+
*/
|
|
13
|
+
panelHidden?: boolean;
|
|
14
|
+
/** Sets `--app-shell-rail-width`. Default `"4rem"`. */
|
|
15
|
+
railWidth?: string;
|
|
16
|
+
/** Sets `--app-shell-sidebar-width` (rail plus panel). Default `"20rem"`. */
|
|
17
|
+
sidebarWidth?: string;
|
|
18
|
+
/** Sets `--app-shell-mobile-header-height`. Default `"calc(3.5rem + env(safe-area-inset-top, 0px))"`. */
|
|
19
|
+
mobileHeaderHeight?: string;
|
|
20
|
+
/** Default `"Skip to main content"`. */
|
|
21
|
+
skipLinkLabel?: string;
|
|
22
|
+
/** `id` of `<main>` and the skip link target. Default `"main-content"`. */
|
|
23
|
+
mainId?: string;
|
|
24
|
+
className?: string;
|
|
25
|
+
mainClassName?: string;
|
|
26
|
+
/** Merged onto the content card inside `<main>`. */
|
|
27
|
+
contentClassName?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type ShellVars = CSSProperties & Record<`--app-shell-${string}`, string>;
|
|
31
|
+
|
|
32
|
+
/** Full-height app frame: skip link, a sidebar slot and the main content card. */
|
|
33
|
+
export function AppShell({
|
|
34
|
+
sidebar,
|
|
35
|
+
children,
|
|
36
|
+
panelHidden = false,
|
|
37
|
+
railWidth = "4rem",
|
|
38
|
+
sidebarWidth = "20rem",
|
|
39
|
+
mobileHeaderHeight = "calc(3.5rem + env(safe-area-inset-top, 0px))",
|
|
40
|
+
skipLinkLabel = "Skip to main content",
|
|
41
|
+
mainId = "main-content",
|
|
42
|
+
className,
|
|
43
|
+
mainClassName,
|
|
44
|
+
contentClassName,
|
|
45
|
+
}: AppShellProps) {
|
|
46
|
+
const style: ShellVars = {
|
|
47
|
+
"--app-shell-rail-width": railWidth,
|
|
48
|
+
"--app-shell-sidebar-width": sidebarWidth,
|
|
49
|
+
"--app-shell-mobile-header-height": mobileHeaderHeight,
|
|
50
|
+
};
|
|
51
|
+
return (
|
|
52
|
+
<div className={cn("relative h-dvh overflow-hidden bg-background", className)} style={style}>
|
|
53
|
+
<a
|
|
54
|
+
href={`#${mainId}`}
|
|
55
|
+
className="sr-only focus:absolute focus:left-3 focus:top-[max(0.75rem,env(safe-area-inset-top))] focus:z-[100] focus:block focus:rounded-lg focus:bg-surface-secondary focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
56
|
+
>
|
|
57
|
+
{skipLinkLabel}
|
|
58
|
+
</a>
|
|
59
|
+
{sidebar}
|
|
60
|
+
<main
|
|
61
|
+
id={mainId}
|
|
62
|
+
tabIndex={-1}
|
|
63
|
+
className={cn(
|
|
64
|
+
"relative z-10 flex h-full min-h-0 outline-none md:p-2",
|
|
65
|
+
panelHidden
|
|
66
|
+
? "md:ml-[var(--app-shell-rail-width)]"
|
|
67
|
+
: "md:ml-[var(--app-shell-sidebar-width)]",
|
|
68
|
+
"md:transition-[margin-left] md:[transition-duration:280ms] md:[transition-timing-function:cubic-bezier(0.22,1,0.36,1)]",
|
|
69
|
+
mainClassName,
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
<div
|
|
73
|
+
className={cn(
|
|
74
|
+
"flex min-h-0 flex-1 flex-col overflow-hidden bg-surface pt-[var(--app-shell-mobile-header-height)] md:rounded-lg md:pt-0",
|
|
75
|
+
contentClassName,
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{children}
|
|
79
|
+
</div>
|
|
80
|
+
</main>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { useEffect, useId, type KeyboardEvent, type ReactNode } from "react";
|
|
2
|
+
import { AnimatePresence, motion, type PanInfo } from "motion/react";
|
|
3
|
+
import { IconMenu2 } from "@tabler/icons-react";
|
|
4
|
+
import { Button } from "../../src/ui/button";
|
|
5
|
+
import { motionEase, motionTiming } from "../../src/motion/presets";
|
|
6
|
+
import { cn } from "../../src/utils/cn";
|
|
7
|
+
import { mobileMenuButtonClass } from "./app-shell-classes";
|
|
8
|
+
|
|
9
|
+
export type AppSidebarProps = {
|
|
10
|
+
/** Icon rail, usually a `SidebarRail`. */
|
|
11
|
+
rail: ReactNode;
|
|
12
|
+
/** Panel beside the rail, usually a `SidebarPanel`. Not rendered while `panelHidden`. */
|
|
13
|
+
panel?: ReactNode;
|
|
14
|
+
/** Desktop: show only the rail. The panel stays mounted (hide it with `SidebarPanel collapsed`). */
|
|
15
|
+
collapsed?: boolean;
|
|
16
|
+
/** The current section has no panel: the sidebar is rail-wide on every viewport. */
|
|
17
|
+
panelHidden?: boolean;
|
|
18
|
+
/** From the app's desktop media query (for example `(min-width: 768px)`). */
|
|
19
|
+
isDesktop: boolean;
|
|
20
|
+
/** Mobile drawer state. Owned by the app (which also closes it on route change). */
|
|
21
|
+
mobileOpen: boolean;
|
|
22
|
+
onMobileOpenChange: (open: boolean) => void;
|
|
23
|
+
/** `id` of the drawer, used by the menu button `aria-controls`. Default from `useId`. */
|
|
24
|
+
id?: string;
|
|
25
|
+
/** Extra `data-*` attributes for the `<aside>`, for example a view-transition target. */
|
|
26
|
+
asideProps?: Record<`data-${string}`, string | undefined>;
|
|
27
|
+
/** Merged onto the `<aside>`. */
|
|
28
|
+
className?: string;
|
|
29
|
+
/** Centred in the mobile header when truthy. */
|
|
30
|
+
mobileTitle?: ReactNode;
|
|
31
|
+
/** Menu button icon. Default a 20px menu icon. */
|
|
32
|
+
mobileMenuIcon?: (open: boolean) => ReactNode;
|
|
33
|
+
/** Default `"Open navigation menu"`. */
|
|
34
|
+
mobileMenuLabel?: string;
|
|
35
|
+
/** Backdrop label. Default `"Close navigation"`. */
|
|
36
|
+
closeLabel?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const defaultMobileMenuIcon = () => <IconMenu2 size={20} />;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Sidebar chrome: the mobile header, the drawer backdrop and the `<aside>`
|
|
43
|
+
* (fixed on desktop, a swipe-to-close drawer below it). Reads the
|
|
44
|
+
* `--app-shell-*` width variables set by `AppShell`.
|
|
45
|
+
*/
|
|
46
|
+
export function AppSidebar({
|
|
47
|
+
rail,
|
|
48
|
+
panel,
|
|
49
|
+
collapsed = false,
|
|
50
|
+
panelHidden = false,
|
|
51
|
+
isDesktop,
|
|
52
|
+
mobileOpen,
|
|
53
|
+
onMobileOpenChange,
|
|
54
|
+
id,
|
|
55
|
+
asideProps,
|
|
56
|
+
className,
|
|
57
|
+
mobileTitle,
|
|
58
|
+
mobileMenuIcon = defaultMobileMenuIcon,
|
|
59
|
+
mobileMenuLabel = "Open navigation menu",
|
|
60
|
+
closeLabel = "Close navigation",
|
|
61
|
+
}: AppSidebarProps) {
|
|
62
|
+
const autoId = useId();
|
|
63
|
+
const drawerId = id ?? autoId;
|
|
64
|
+
const layout = isDesktop ? "desktop" : "drawer";
|
|
65
|
+
|
|
66
|
+
// Reaching the desktop breakpoint closes the drawer, so it does not reopen
|
|
67
|
+
// on the way back down. Runs on breakpoint change only.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (isDesktop && mobileOpen) onMobileOpenChange(false);
|
|
70
|
+
}, [isDesktop]);
|
|
71
|
+
|
|
72
|
+
const close = () => onMobileOpenChange(false);
|
|
73
|
+
|
|
74
|
+
const handleDrawerDragEnd = (
|
|
75
|
+
_event: MouseEvent | TouchEvent | PointerEvent,
|
|
76
|
+
info: PanInfo,
|
|
77
|
+
) => {
|
|
78
|
+
if (isDesktop || !mobileOpen) return;
|
|
79
|
+
if (info.offset.x < -72 || info.velocity.x < -500) {
|
|
80
|
+
close();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const closeOnEscape = (event: KeyboardEvent) => {
|
|
85
|
+
if (event.key === "Escape" && !isDesktop && mobileOpen) {
|
|
86
|
+
close();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<>
|
|
92
|
+
<header
|
|
93
|
+
onKeyDown={closeOnEscape}
|
|
94
|
+
className="fixed inset-x-0 top-0 z-30 flex h-[var(--app-shell-mobile-header-height)] items-center gap-2 bg-background/80 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))] backdrop-blur-md md:hidden"
|
|
95
|
+
>
|
|
96
|
+
<Button
|
|
97
|
+
type="button"
|
|
98
|
+
variant="ghost"
|
|
99
|
+
size="icon"
|
|
100
|
+
onClick={() => onMobileOpenChange(true)}
|
|
101
|
+
aria-label={mobileMenuLabel}
|
|
102
|
+
aria-expanded={mobileOpen}
|
|
103
|
+
aria-controls={drawerId}
|
|
104
|
+
className={mobileMenuButtonClass}
|
|
105
|
+
>
|
|
106
|
+
{mobileMenuIcon(mobileOpen)}
|
|
107
|
+
</Button>
|
|
108
|
+
{mobileTitle ? (
|
|
109
|
+
<h1 className="pointer-events-none absolute inset-x-14 top-[env(safe-area-inset-top,0px)] bottom-0 flex items-center justify-center truncate text-center text-base font-semibold leading-none tracking-tight text-foreground text-balance">
|
|
110
|
+
{mobileTitle}
|
|
111
|
+
</h1>
|
|
112
|
+
) : null}
|
|
113
|
+
<div className="ml-auto h-11 w-11 shrink-0" aria-hidden="true" />
|
|
114
|
+
</header>
|
|
115
|
+
|
|
116
|
+
<AnimatePresence initial={false}>
|
|
117
|
+
{mobileOpen && !isDesktop ? (
|
|
118
|
+
<motion.button
|
|
119
|
+
type="button"
|
|
120
|
+
aria-label={closeLabel}
|
|
121
|
+
className="fixed inset-0 z-40 bg-backdrop/80 md:hidden"
|
|
122
|
+
onClick={close}
|
|
123
|
+
initial={{ opacity: 0 }}
|
|
124
|
+
animate={{ opacity: 1 }}
|
|
125
|
+
exit={{ opacity: 0 }}
|
|
126
|
+
transition={{ duration: motionTiming.sidebar, ease: motionEase }}
|
|
127
|
+
/>
|
|
128
|
+
) : null}
|
|
129
|
+
</AnimatePresence>
|
|
130
|
+
|
|
131
|
+
<motion.aside
|
|
132
|
+
id={drawerId}
|
|
133
|
+
data-sidebar-layout={layout}
|
|
134
|
+
{...asideProps}
|
|
135
|
+
inert={!isDesktop && !mobileOpen}
|
|
136
|
+
onKeyDown={closeOnEscape}
|
|
137
|
+
className={cn(
|
|
138
|
+
"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",
|
|
139
|
+
panelHidden
|
|
140
|
+
? "w-[var(--app-shell-rail-width)]"
|
|
141
|
+
: "w-[min(var(--app-shell-sidebar-width),calc(100vw-1.5rem))]",
|
|
142
|
+
collapsed || panelHidden
|
|
143
|
+
? "md:w-[var(--app-shell-rail-width)]"
|
|
144
|
+
: "md:w-[var(--app-shell-sidebar-width)]",
|
|
145
|
+
"md:transition-[width] md:[transition-duration:280ms] md:[transition-timing-function:cubic-bezier(0.22,1,0.36,1)]",
|
|
146
|
+
className,
|
|
147
|
+
)}
|
|
148
|
+
initial={false}
|
|
149
|
+
animate={{ x: isDesktop || mobileOpen ? 0 : "-100%" }}
|
|
150
|
+
transition={{ duration: motionTiming.sidebar, ease: motionEase }}
|
|
151
|
+
drag={!isDesktop && mobileOpen ? "x" : false}
|
|
152
|
+
dragDirectionLock
|
|
153
|
+
dragConstraints={{ left: 0, right: 0 }}
|
|
154
|
+
dragElastic={{ left: 0.45, right: 0.08 }}
|
|
155
|
+
onDragEnd={handleDrawerDragEnd}
|
|
156
|
+
>
|
|
157
|
+
{rail}
|
|
158
|
+
{panelHidden ? null : panel}
|
|
159
|
+
</motion.aside>
|
|
160
|
+
</>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
2
|
+
import { Button } from "../../src/ui/button";
|
|
3
|
+
import {
|
|
4
|
+
DropdownMenu,
|
|
5
|
+
DropdownMenuContent,
|
|
6
|
+
DropdownMenuLabel,
|
|
7
|
+
DropdownMenuSeparator,
|
|
8
|
+
DropdownMenuTrigger,
|
|
9
|
+
} from "../../src/ui/dropdown-menu";
|
|
10
|
+
import { cn } from "../../src/utils/cn";
|
|
11
|
+
import { accountEmailClass, railTileClass, railTileStateClass } from "./app-shell-classes";
|
|
12
|
+
|
|
13
|
+
type ContentProps = ComponentPropsWithoutRef<typeof DropdownMenuContent>;
|
|
14
|
+
|
|
15
|
+
export type SidebarAccountMenuProps = {
|
|
16
|
+
/** Trigger `title`, `aria-label` and the menu label. */
|
|
17
|
+
name: string;
|
|
18
|
+
/** When set, the label shows the name and email on two lines. */
|
|
19
|
+
email?: string;
|
|
20
|
+
/** Always use the two-line label layout, even without `email`. Default `false`. */
|
|
21
|
+
stackedLabel?: boolean;
|
|
22
|
+
/** Avatar image. Without it the avatar shows up to two initials of `name`. */
|
|
23
|
+
imageUrl?: string;
|
|
24
|
+
/** Default `"center"`. */
|
|
25
|
+
align?: ContentProps["align"];
|
|
26
|
+
/** Default `"right"`. */
|
|
27
|
+
side?: ContentProps["side"];
|
|
28
|
+
/** Default `8`. */
|
|
29
|
+
sideOffset?: number;
|
|
30
|
+
collisionPadding?: ContentProps["collisionPadding"];
|
|
31
|
+
/** Merged onto the menu content after `w-56`. */
|
|
32
|
+
contentClassName?: string;
|
|
33
|
+
/** Menu items after the label and separator. */
|
|
34
|
+
children?: ReactNode;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function AccountAvatar({ src, name, className }: { src?: string; name: string; className: string }) {
|
|
38
|
+
if (src) {
|
|
39
|
+
return (
|
|
40
|
+
<img
|
|
41
|
+
src={src}
|
|
42
|
+
alt=""
|
|
43
|
+
className={cn(className, "rounded-full object-cover outline outline-1 -outline-offset-1 outline-separator")}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const initials = name
|
|
48
|
+
.split(/\s+/)
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.slice(0, 2)
|
|
51
|
+
.map((part) => part[0]?.toUpperCase() ?? "")
|
|
52
|
+
.join("");
|
|
53
|
+
return (
|
|
54
|
+
<span
|
|
55
|
+
className={cn(
|
|
56
|
+
"flex items-center justify-center rounded-full bg-surface-tertiary text-[10px] font-medium text-foreground outline outline-1 -outline-offset-1 outline-separator",
|
|
57
|
+
className,
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
{initials || "?"}
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Account menu on a rail tile: avatar trigger, identity label, then the app's items. */
|
|
66
|
+
export function SidebarAccountMenu({
|
|
67
|
+
name,
|
|
68
|
+
email,
|
|
69
|
+
stackedLabel = false,
|
|
70
|
+
imageUrl,
|
|
71
|
+
align = "center",
|
|
72
|
+
side = "right",
|
|
73
|
+
sideOffset = 8,
|
|
74
|
+
collisionPadding,
|
|
75
|
+
contentClassName,
|
|
76
|
+
children,
|
|
77
|
+
}: SidebarAccountMenuProps) {
|
|
78
|
+
return (
|
|
79
|
+
<DropdownMenu>
|
|
80
|
+
<DropdownMenuTrigger asChild>
|
|
81
|
+
<Button
|
|
82
|
+
type="button"
|
|
83
|
+
variant="ghost"
|
|
84
|
+
title={name}
|
|
85
|
+
aria-label={`Account menu for ${name}`}
|
|
86
|
+
className={cn(railTileClass, "h-11 w-11 p-0", railTileStateClass(false))}
|
|
87
|
+
>
|
|
88
|
+
<AccountAvatar src={imageUrl} name={name} className="h-7 w-7" />
|
|
89
|
+
</Button>
|
|
90
|
+
</DropdownMenuTrigger>
|
|
91
|
+
<DropdownMenuContent
|
|
92
|
+
align={align}
|
|
93
|
+
side={side}
|
|
94
|
+
sideOffset={sideOffset}
|
|
95
|
+
collisionPadding={collisionPadding}
|
|
96
|
+
className={cn("w-56", contentClassName)}
|
|
97
|
+
>
|
|
98
|
+
<DropdownMenuLabel className="flex items-center gap-2 font-normal normal-case tracking-normal">
|
|
99
|
+
<AccountAvatar src={imageUrl} name={name} className="h-6 w-6 shrink-0" />
|
|
100
|
+
{stackedLabel || email ? (
|
|
101
|
+
<span className="min-w-0">
|
|
102
|
+
<span className="block truncate text-sm font-medium text-foreground">{name}</span>
|
|
103
|
+
{email ? <span className={accountEmailClass}>{email}</span> : null}
|
|
104
|
+
</span>
|
|
105
|
+
) : (
|
|
106
|
+
<span className="truncate text-sm font-medium text-foreground">{name}</span>
|
|
107
|
+
)}
|
|
108
|
+
</DropdownMenuLabel>
|
|
109
|
+
<DropdownMenuSeparator />
|
|
110
|
+
{children}
|
|
111
|
+
</DropdownMenuContent>
|
|
112
|
+
</DropdownMenu>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type SidebarAccountMenuPlaceholderProps = { className?: string };
|
|
117
|
+
|
|
118
|
+
/** Tile-sized placeholder while the account loads. */
|
|
119
|
+
export function SidebarAccountMenuPlaceholder({ className }: SidebarAccountMenuPlaceholderProps) {
|
|
120
|
+
return <div className={cn("size-11 rounded-lg bg-surface-tertiary", className)} />;
|
|
121
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "../../src/ui/tooltip";
|
|
3
|
+
|
|
4
|
+
export type SidebarIconTooltipProps = {
|
|
5
|
+
label: string;
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
children: ReactElement;
|
|
8
|
+
/** Default `"right"`. */
|
|
9
|
+
side?: "top" | "right" | "bottom" | "left";
|
|
10
|
+
/** Default `8`. */
|
|
11
|
+
sideOffset?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// radix tooltip for icon-only sidebar rail — skipped when labels are visible
|
|
15
|
+
export function SidebarIconTooltip({
|
|
16
|
+
label,
|
|
17
|
+
enabled,
|
|
18
|
+
children,
|
|
19
|
+
side = "right",
|
|
20
|
+
sideOffset = 8,
|
|
21
|
+
}: SidebarIconTooltipProps) {
|
|
22
|
+
if (!enabled) {
|
|
23
|
+
return children;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Tooltip>
|
|
28
|
+
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
|
29
|
+
<TooltipContent side={side} sideOffset={sideOffset}>
|
|
30
|
+
{label}
|
|
31
|
+
</TooltipContent>
|
|
32
|
+
</Tooltip>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { IconX } from "@tabler/icons-react";
|
|
3
|
+
import { Button } from "../../src/ui/button";
|
|
4
|
+
import { cn } from "../../src/utils/cn";
|
|
5
|
+
import { panelCloseButtonClass } from "./app-shell-classes";
|
|
6
|
+
import { SidebarHeader } from "./SidebarHeader";
|
|
7
|
+
|
|
8
|
+
export type SidebarPanelProps = {
|
|
9
|
+
title: string;
|
|
10
|
+
/** Rendered in the header, before the close button. */
|
|
11
|
+
headerTrailing?: ReactNode;
|
|
12
|
+
/** When set, a close button follows `headerTrailing`. Pass it on the mobile drawer only. */
|
|
13
|
+
onClose?: () => void;
|
|
14
|
+
/** Close button label. Default `"Close navigation"`. */
|
|
15
|
+
closeLabel?: string;
|
|
16
|
+
/** Merged onto the close button. */
|
|
17
|
+
closeButtonClassName?: string;
|
|
18
|
+
/** Desktop: hides the panel (`md:hidden`) and leaves the rail. */
|
|
19
|
+
collapsed?: boolean;
|
|
20
|
+
className?: string;
|
|
21
|
+
/** Panel body: nav, footer and so on. */
|
|
22
|
+
children?: ReactNode;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Sidebar panel column beside the rail: a `SidebarHeader` and the app's body. */
|
|
26
|
+
export function SidebarPanel({
|
|
27
|
+
title,
|
|
28
|
+
headerTrailing,
|
|
29
|
+
onClose,
|
|
30
|
+
closeLabel = "Close navigation",
|
|
31
|
+
closeButtonClassName,
|
|
32
|
+
collapsed = false,
|
|
33
|
+
className,
|
|
34
|
+
children,
|
|
35
|
+
}: SidebarPanelProps) {
|
|
36
|
+
const trailing =
|
|
37
|
+
headerTrailing || onClose ? (
|
|
38
|
+
<>
|
|
39
|
+
{headerTrailing}
|
|
40
|
+
{onClose ? (
|
|
41
|
+
<Button
|
|
42
|
+
type="button"
|
|
43
|
+
variant="ghost"
|
|
44
|
+
size="icon-sm"
|
|
45
|
+
onClick={onClose}
|
|
46
|
+
aria-label={closeLabel}
|
|
47
|
+
className={cn(panelCloseButtonClass, closeButtonClassName)}
|
|
48
|
+
>
|
|
49
|
+
<IconX className="h-4 w-4" />
|
|
50
|
+
</Button>
|
|
51
|
+
) : null}
|
|
52
|
+
</>
|
|
53
|
+
) : null;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
className={cn(
|
|
58
|
+
// No border-r: the main content card floats beside this panel, so a
|
|
59
|
+
// right edge line reads as a hard divider.
|
|
60
|
+
"relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background",
|
|
61
|
+
collapsed && "md:hidden",
|
|
62
|
+
className,
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
<div className="px-2 pt-3">
|
|
66
|
+
<SidebarHeader title={title} trailing={trailing} />
|
|
67
|
+
</div>
|
|
68
|
+
{children}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
2
|
+
import { Slot, Slottable } from "@radix-ui/react-slot";
|
|
3
|
+
import { Button } from "../../src/ui/button";
|
|
4
|
+
import { cn } from "../../src/utils/cn";
|
|
5
|
+
import { railTileClass, railTileStateClass } from "./app-shell-classes";
|
|
6
|
+
import { SidebarIconTooltip } from "./SidebarIconTooltip";
|
|
7
|
+
|
|
8
|
+
export type SidebarRailProps = Omit<ComponentPropsWithoutRef<"div">, "children" | "className"> & {
|
|
9
|
+
/** Pinned at the top, above the scroll body. */
|
|
10
|
+
top?: ReactNode;
|
|
11
|
+
/** Scroll body. */
|
|
12
|
+
children?: ReactNode;
|
|
13
|
+
/** Pinned at the bottom. */
|
|
14
|
+
footer?: ReactNode;
|
|
15
|
+
/** Adds a right border between the rail and the panel. Default `false`. */
|
|
16
|
+
showDivider?: boolean;
|
|
17
|
+
/** Render the body as its single child element (for example a tabs list), with the body classes merged. */
|
|
18
|
+
bodyAsChild?: boolean;
|
|
19
|
+
className?: string;
|
|
20
|
+
topClassName?: string;
|
|
21
|
+
bodyClassName?: string;
|
|
22
|
+
footerClassName?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Icon rail column. Width reads `--app-shell-rail-width`; override with `className`. Other props go to the root `<div>`. */
|
|
26
|
+
export function SidebarRail({
|
|
27
|
+
top,
|
|
28
|
+
children,
|
|
29
|
+
footer,
|
|
30
|
+
showDivider = false,
|
|
31
|
+
bodyAsChild = false,
|
|
32
|
+
className,
|
|
33
|
+
topClassName,
|
|
34
|
+
bodyClassName,
|
|
35
|
+
footerClassName,
|
|
36
|
+
...props
|
|
37
|
+
}: SidebarRailProps) {
|
|
38
|
+
const Body = bodyAsChild ? Slot : "div";
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
{...props}
|
|
42
|
+
className={cn(
|
|
43
|
+
"flex h-full w-[var(--app-shell-rail-width)] shrink-0 flex-col items-center bg-background",
|
|
44
|
+
showDivider && "border-r border-separator",
|
|
45
|
+
className,
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
{top == null ? null : (
|
|
49
|
+
<div className={cn("flex w-full flex-col items-center gap-1.5 px-0 pt-3", topClassName)}>
|
|
50
|
+
{top}
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
<Body
|
|
54
|
+
className={cn(
|
|
55
|
+
"flex w-full flex-1 flex-col items-center gap-1.5 overflow-y-auto py-2",
|
|
56
|
+
bodyClassName,
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
{children}
|
|
60
|
+
</Body>
|
|
61
|
+
{footer == null ? null : (
|
|
62
|
+
<div className={cn("flex w-full flex-col items-center gap-1.5 py-3", footerClassName)}>
|
|
63
|
+
{footer}
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type RailTileProps = Omit<ComponentPropsWithoutRef<"button">, "children"> & {
|
|
71
|
+
/** `aria-label`, and the tooltip unless `tooltip` is set. */
|
|
72
|
+
label: string;
|
|
73
|
+
/** Active state. With `asChild`, also sets `aria-current="page"`. */
|
|
74
|
+
active?: boolean;
|
|
75
|
+
/** Tooltip text. Default `label`. */
|
|
76
|
+
tooltip?: string;
|
|
77
|
+
/** Default `true`. */
|
|
78
|
+
showTooltip?: boolean;
|
|
79
|
+
/** Rendered after the children, inside the tile (for example a count with `railBadgeClass`). */
|
|
80
|
+
badge?: ReactNode;
|
|
81
|
+
/** Render the single child element (for example a router link) as the tile. */
|
|
82
|
+
asChild?: boolean;
|
|
83
|
+
children: ReactNode;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Rail tile with a right-side tooltip. Renders a ghost `Button`, or its child with `asChild`. */
|
|
87
|
+
export function RailTile({
|
|
88
|
+
label,
|
|
89
|
+
active = false,
|
|
90
|
+
tooltip,
|
|
91
|
+
showTooltip = true,
|
|
92
|
+
badge,
|
|
93
|
+
asChild = false,
|
|
94
|
+
className,
|
|
95
|
+
children,
|
|
96
|
+
...props
|
|
97
|
+
}: RailTileProps) {
|
|
98
|
+
return (
|
|
99
|
+
<SidebarIconTooltip label={tooltip ?? label} enabled={showTooltip}>
|
|
100
|
+
{asChild ? (
|
|
101
|
+
<Slot
|
|
102
|
+
{...props}
|
|
103
|
+
aria-label={label}
|
|
104
|
+
aria-current={active ? "page" : undefined}
|
|
105
|
+
className={cn(railTileClass, "group", railTileStateClass(active), className)}
|
|
106
|
+
>
|
|
107
|
+
<Slottable>{children}</Slottable>
|
|
108
|
+
{badge}
|
|
109
|
+
</Slot>
|
|
110
|
+
) : (
|
|
111
|
+
<Button
|
|
112
|
+
type="button"
|
|
113
|
+
variant="ghost"
|
|
114
|
+
{...props}
|
|
115
|
+
aria-label={label}
|
|
116
|
+
className={cn(railTileClass, "group h-11 w-11 p-0", railTileStateClass(active), className)}
|
|
117
|
+
>
|
|
118
|
+
{children}
|
|
119
|
+
{badge}
|
|
120
|
+
</Button>
|
|
121
|
+
)}
|
|
122
|
+
</SidebarIconTooltip>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type RailDividerProps = { className?: string };
|
|
127
|
+
|
|
128
|
+
export function RailDivider({ className }: RailDividerProps) {
|
|
129
|
+
return <div className={cn("h-px w-8 bg-separator", className)} aria-hidden />;
|
|
130
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { IconAlertTriangle, IconLoader2 } from "@tabler/icons-react";
|
|
2
|
+
import { Button } from "../../src/ui/button";
|
|
3
|
+
import {
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogDescription,
|
|
7
|
+
DialogFooter,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
} from "../../src/ui/dialog";
|
|
11
|
+
import { cn } from "../../src/utils/cn";
|
|
12
|
+
import { signOutCancelClass, signOutDescriptionClass } from "./app-shell-classes";
|
|
13
|
+
|
|
14
|
+
export type SignOutDialogProps = {
|
|
15
|
+
open: boolean;
|
|
16
|
+
/** Not called with `false` while `pending`, so the dialog stays open during sign-out. */
|
|
17
|
+
onOpenChange: (open: boolean) => void;
|
|
18
|
+
/** Disables both buttons and shows "Signing out...". */
|
|
19
|
+
pending: boolean;
|
|
20
|
+
onConfirm: () => void | Promise<void>;
|
|
21
|
+
/** Second line under the question. */
|
|
22
|
+
description?: string;
|
|
23
|
+
/** Merged onto the dialog content after `sm:max-w-sm`. */
|
|
24
|
+
contentClassName?: string;
|
|
25
|
+
/** Dialog title and confirm button label. Default `"Sign out"`. */
|
|
26
|
+
title?: string;
|
|
27
|
+
/** Default `"Sign out of your account?"`. */
|
|
28
|
+
question?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Sign-out confirmation. The app owns the state and the sign-out call. */
|
|
32
|
+
export function SignOutDialog({
|
|
33
|
+
open,
|
|
34
|
+
onOpenChange,
|
|
35
|
+
pending,
|
|
36
|
+
onConfirm,
|
|
37
|
+
description,
|
|
38
|
+
contentClassName,
|
|
39
|
+
title = "Sign out",
|
|
40
|
+
question = "Sign out of your account?",
|
|
41
|
+
}: SignOutDialogProps) {
|
|
42
|
+
return (
|
|
43
|
+
<Dialog
|
|
44
|
+
open={open}
|
|
45
|
+
onOpenChange={(next) => {
|
|
46
|
+
if (!next && pending) return;
|
|
47
|
+
onOpenChange(next);
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
<DialogContent className={cn("sm:max-w-sm", contentClassName)}>
|
|
51
|
+
<DialogHeader>
|
|
52
|
+
<DialogTitle className="text-foreground">{title}</DialogTitle>
|
|
53
|
+
<DialogDescription className="sr-only">
|
|
54
|
+
Confirm signing out of your account
|
|
55
|
+
</DialogDescription>
|
|
56
|
+
</DialogHeader>
|
|
57
|
+
<div className="flex items-start gap-3 py-4">
|
|
58
|
+
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-danger/10">
|
|
59
|
+
<IconAlertTriangle size={20} className="text-danger" />
|
|
60
|
+
</div>
|
|
61
|
+
<div>
|
|
62
|
+
<p className="text-foreground">{question}</p>
|
|
63
|
+
{description ? <p className={signOutDescriptionClass}>{description}</p> : null}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
<DialogFooter>
|
|
67
|
+
<Button
|
|
68
|
+
variant="ghost"
|
|
69
|
+
onClick={() => onOpenChange(false)}
|
|
70
|
+
disabled={pending}
|
|
71
|
+
className={signOutCancelClass}
|
|
72
|
+
>
|
|
73
|
+
Cancel
|
|
74
|
+
</Button>
|
|
75
|
+
<Button
|
|
76
|
+
onClick={() => void onConfirm()}
|
|
77
|
+
disabled={pending}
|
|
78
|
+
className="bg-danger text-danger-foreground"
|
|
79
|
+
>
|
|
80
|
+
{pending ? (
|
|
81
|
+
<>
|
|
82
|
+
<IconLoader2 size={16} className="animate-spin" />
|
|
83
|
+
Signing out...
|
|
84
|
+
</>
|
|
85
|
+
) : (
|
|
86
|
+
title
|
|
87
|
+
)}
|
|
88
|
+
</Button>
|
|
89
|
+
</DialogFooter>
|
|
90
|
+
</DialogContent>
|
|
91
|
+
</Dialog>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { cn } from "../../src/utils/cn";
|
|
2
|
+
|
|
3
|
+
// Class strings for the app shell, copied from existing app markup so an app
|
|
4
|
+
// that moves to these components renders the same classes. The bare muted and
|
|
5
|
+
// accent colour names match that markup and are allow-listed for check:tokens
|
|
6
|
+
// on the marked lines only.
|
|
7
|
+
|
|
8
|
+
/** Rail tile: size-11, rounded-lg, press scale. */
|
|
9
|
+
export const railTileClass =
|
|
10
|
+
"relative flex size-11 items-center justify-center rounded-lg border active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring";
|
|
11
|
+
|
|
12
|
+
const railTileActiveClass = "border-transparent bg-surface-tertiary text-foreground";
|
|
13
|
+
const railTileIdleClass = "border-transparent text-muted opacity-75 hover:bg-surface-tertiary/50 hover:opacity-100 hover:text-foreground"; // check-tokens-allow: text-muted
|
|
14
|
+
|
|
15
|
+
export function railTileStateClass(active: boolean): string {
|
|
16
|
+
return active ? railTileActiveClass : railTileIdleClass;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Count badge pinned to the bottom-right of a rail tile. */
|
|
20
|
+
export const railBadgeClass = "absolute -bottom-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-accent px-1 text-[10px] font-semibold leading-none text-accent-foreground"; // check-tokens-allow: accent
|
|
21
|
+
|
|
22
|
+
/** Static section label, sentence case, padded to match nav rows. */
|
|
23
|
+
export const sidebarSectionLabelClass =
|
|
24
|
+
"sidebar-section-label px-3 py-1 text-[11px] font-medium tracking-[-0.01em]";
|
|
25
|
+
|
|
26
|
+
/** Nav link text: muted until active. */
|
|
27
|
+
export function sidebarNavLinkTextClass(isActive: boolean): string {
|
|
28
|
+
return cn(isActive ? "text-foreground" : "text-muted hover:text-foreground"); // check-tokens-allow: text-muted
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Padding for main sidebar nav rows. */
|
|
32
|
+
export function sidebarNavRowClass(isIconOnly: boolean): string {
|
|
33
|
+
return cn(isIconOnly ? "justify-center px-0 py-1.5" : "gap-3 px-3 py-1.5");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const mobileMenuButtonClass = "-ml-1 h-11 w-11 shrink-0 rounded-lg text-muted hover:bg-surface-tertiary/50 hover:text-foreground"; // check-tokens-allow: text-muted
|
|
37
|
+
|
|
38
|
+
export const panelCloseButtonClass = "rounded-lg text-muted hover:bg-surface-tertiary/50 hover:text-foreground"; // check-tokens-allow: text-muted
|
|
39
|
+
|
|
40
|
+
export const accountEmailClass = "block truncate text-xs font-normal text-muted"; // check-tokens-allow: text-muted
|
|
41
|
+
|
|
42
|
+
export const signOutDescriptionClass = "mt-1 text-sm text-muted"; // check-tokens-allow: text-muted
|
|
43
|
+
|
|
44
|
+
export const signOutCancelClass = "text-muted"; // check-tokens-allow: text-muted
|
package/shell/src/index.ts
CHANGED
|
@@ -10,3 +10,28 @@ export type {
|
|
|
10
10
|
SessionsHoverCardProps,
|
|
11
11
|
SessionsHoverCardSurface,
|
|
12
12
|
} from "./SessionsHoverCard.js";
|
|
13
|
+
export { AppShell } from "./AppShell.js";
|
|
14
|
+
export type { AppShellProps } from "./AppShell.js";
|
|
15
|
+
export { AppSidebar } from "./AppSidebar.js";
|
|
16
|
+
export type { AppSidebarProps } from "./AppSidebar.js";
|
|
17
|
+
export { SidebarRail, RailTile, RailDivider } from "./SidebarRail.js";
|
|
18
|
+
export type { SidebarRailProps, RailTileProps, RailDividerProps } from "./SidebarRail.js";
|
|
19
|
+
export { SidebarIconTooltip } from "./SidebarIconTooltip.js";
|
|
20
|
+
export type { SidebarIconTooltipProps } from "./SidebarIconTooltip.js";
|
|
21
|
+
export { SidebarPanel } from "./SidebarPanel.js";
|
|
22
|
+
export type { SidebarPanelProps } from "./SidebarPanel.js";
|
|
23
|
+
export { SidebarAccountMenu, SidebarAccountMenuPlaceholder } from "./SidebarAccountMenu.js";
|
|
24
|
+
export type {
|
|
25
|
+
SidebarAccountMenuProps,
|
|
26
|
+
SidebarAccountMenuPlaceholderProps,
|
|
27
|
+
} from "./SidebarAccountMenu.js";
|
|
28
|
+
export { SignOutDialog } from "./SignOutDialog.js";
|
|
29
|
+
export type { SignOutDialogProps } from "./SignOutDialog.js";
|
|
30
|
+
export {
|
|
31
|
+
railBadgeClass,
|
|
32
|
+
railTileClass,
|
|
33
|
+
railTileStateClass,
|
|
34
|
+
sidebarNavLinkTextClass,
|
|
35
|
+
sidebarNavRowClass,
|
|
36
|
+
sidebarSectionLabelClass,
|
|
37
|
+
} from "./app-shell-classes.js";
|
package/src/index.ts
CHANGED
|
@@ -120,6 +120,34 @@ export type {
|
|
|
120
120
|
SessionsHoverCardSurface,
|
|
121
121
|
} from "../shell/src/SessionsHoverCard";
|
|
122
122
|
|
|
123
|
+
// App shell: sidebar and main page layout. Widths come from the
|
|
124
|
+
// `--app-shell-*` CSS variables that `AppShell` sets.
|
|
125
|
+
export { AppShell } from "../shell/src/AppShell";
|
|
126
|
+
export type { AppShellProps } from "../shell/src/AppShell";
|
|
127
|
+
export { AppSidebar } from "../shell/src/AppSidebar";
|
|
128
|
+
export type { AppSidebarProps } from "../shell/src/AppSidebar";
|
|
129
|
+
export { SidebarRail, RailTile, RailDivider } from "../shell/src/SidebarRail";
|
|
130
|
+
export type { SidebarRailProps, RailTileProps, RailDividerProps } from "../shell/src/SidebarRail";
|
|
131
|
+
export { SidebarIconTooltip } from "../shell/src/SidebarIconTooltip";
|
|
132
|
+
export type { SidebarIconTooltipProps } from "../shell/src/SidebarIconTooltip";
|
|
133
|
+
export { SidebarPanel } from "../shell/src/SidebarPanel";
|
|
134
|
+
export type { SidebarPanelProps } from "../shell/src/SidebarPanel";
|
|
135
|
+
export { SidebarAccountMenu, SidebarAccountMenuPlaceholder } from "../shell/src/SidebarAccountMenu";
|
|
136
|
+
export type {
|
|
137
|
+
SidebarAccountMenuProps,
|
|
138
|
+
SidebarAccountMenuPlaceholderProps,
|
|
139
|
+
} from "../shell/src/SidebarAccountMenu";
|
|
140
|
+
export { SignOutDialog } from "../shell/src/SignOutDialog";
|
|
141
|
+
export type { SignOutDialogProps } from "../shell/src/SignOutDialog";
|
|
142
|
+
export {
|
|
143
|
+
railBadgeClass,
|
|
144
|
+
railTileClass,
|
|
145
|
+
railTileStateClass,
|
|
146
|
+
sidebarNavLinkTextClass,
|
|
147
|
+
sidebarNavRowClass,
|
|
148
|
+
sidebarSectionLabelClass,
|
|
149
|
+
} from "../shell/src/app-shell-classes";
|
|
150
|
+
|
|
123
151
|
// App helpers: confirm skip, update watcher, settings chrome, stable auth.
|
|
124
152
|
export * from "../app/src/index";
|
|
125
153
|
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { ComponentType, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/** The `ViewTransition` props used here. Typed locally so consumers on `@types/react` without `ViewTransition` still typecheck. */
|
|
5
|
+
type ViewTransitionLikeProps = {
|
|
6
|
+
children?: ReactNode;
|
|
7
|
+
default?: string;
|
|
8
|
+
enter?: string;
|
|
9
|
+
exit?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const ViewTransition: ComponentType<ViewTransitionLikeProps> = Reflect.get(React, "ViewTransition");
|
|
2
13
|
|
|
3
14
|
export interface PanelTransitionProps {
|
|
4
15
|
children?: ReactNode;
|
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
import { act, createRef } from "react";
|
|
3
|
-
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import { SessionsHoverCard } from "./SessionsHoverCard";
|
|
6
|
-
|
|
7
|
-
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
8
|
-
|
|
9
|
-
let root: Root | null = null;
|
|
10
|
-
let host: HTMLElement | null = null;
|
|
11
|
-
|
|
12
|
-
async function mount(node: React.ReactNode) {
|
|
13
|
-
host = document.createElement("div");
|
|
14
|
-
document.body.appendChild(host);
|
|
15
|
-
root = createRoot(host);
|
|
16
|
-
await act(async () => {
|
|
17
|
-
root!.render(node);
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
afterEach(async () => {
|
|
22
|
-
await act(async () => root?.unmount());
|
|
23
|
-
host?.remove();
|
|
24
|
-
document.body.innerHTML = "";
|
|
25
|
-
root = null;
|
|
26
|
-
host = null;
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe("SessionsHoverCard", () => {
|
|
30
|
-
it("renders header, body and footer slots when open", async () => {
|
|
31
|
-
await mount(
|
|
32
|
-
<SessionsHoverCard open header="Head" body="Body text" footer="Foot">
|
|
33
|
-
<button type="button">Row</button>
|
|
34
|
-
</SessionsHoverCard>,
|
|
35
|
-
);
|
|
36
|
-
const text = document.body.textContent ?? "";
|
|
37
|
-
expect(text).toContain("Row");
|
|
38
|
-
expect(text).toContain("Head");
|
|
39
|
-
expect(text).toContain("Body text");
|
|
40
|
-
expect(text).toContain("Foot");
|
|
41
|
-
expect(document.body.innerHTML).toContain("w-64");
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("does not call the body render prop or loadContent while closed", async () => {
|
|
45
|
-
const body = vi.fn(() => "x");
|
|
46
|
-
const loadContent = vi.fn(() => "y");
|
|
47
|
-
await mount(
|
|
48
|
-
<>
|
|
49
|
-
<SessionsHoverCard open={false} body={body}>
|
|
50
|
-
<button type="button">A</button>
|
|
51
|
-
</SessionsHoverCard>
|
|
52
|
-
<SessionsHoverCard open={false} loadContent={loadContent}>
|
|
53
|
-
<button type="button">B</button>
|
|
54
|
-
</SessionsHoverCard>
|
|
55
|
-
</>,
|
|
56
|
-
);
|
|
57
|
-
expect(body).not.toHaveBeenCalled();
|
|
58
|
-
expect(loadContent).not.toHaveBeenCalled();
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("calls the render prop with open true and loads content once when open", async () => {
|
|
62
|
-
const body = vi.fn((ctx: { open: boolean }) => `open=${ctx.open}`);
|
|
63
|
-
const loadContent = vi.fn(async () => "Loaded preview");
|
|
64
|
-
await mount(
|
|
65
|
-
<>
|
|
66
|
-
<SessionsHoverCard open body={body}>
|
|
67
|
-
<button type="button">A</button>
|
|
68
|
-
</SessionsHoverCard>
|
|
69
|
-
<SessionsHoverCard open loadContent={loadContent} loadingFallback="Loading">
|
|
70
|
-
<button type="button">B</button>
|
|
71
|
-
</SessionsHoverCard>
|
|
72
|
-
</>,
|
|
73
|
-
);
|
|
74
|
-
expect(body).toHaveBeenCalledWith({ open: true });
|
|
75
|
-
expect(document.body.textContent).toContain("open=true");
|
|
76
|
-
expect(loadContent).toHaveBeenCalledTimes(1);
|
|
77
|
-
expect(document.body.textContent).toContain("Loaded preview");
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("portals the content to document.body by default", async () => {
|
|
81
|
-
await mount(
|
|
82
|
-
<div id="clip" style={{ overflow: "hidden" }}>
|
|
83
|
-
<SessionsHoverCard open body="Body text">
|
|
84
|
-
<button type="button">Row</button>
|
|
85
|
-
</SessionsHoverCard>
|
|
86
|
-
</div>,
|
|
87
|
-
);
|
|
88
|
-
const content = document.querySelector("[data-radix-popper-content-wrapper]");
|
|
89
|
-
expect(content).not.toBeNull();
|
|
90
|
-
expect(document.getElementById("clip")!.contains(content)).toBe(false);
|
|
91
|
-
expect(document.body.textContent).toContain("Body text");
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("keeps the content in place with portal={false}", async () => {
|
|
95
|
-
await mount(
|
|
96
|
-
<div id="clip" style={{ overflow: "hidden" }}>
|
|
97
|
-
<SessionsHoverCard open portal={false} body="Body text">
|
|
98
|
-
<button type="button">Row</button>
|
|
99
|
-
</SessionsHoverCard>
|
|
100
|
-
</div>,
|
|
101
|
-
);
|
|
102
|
-
const content = document.querySelector("[data-radix-popper-content-wrapper]");
|
|
103
|
-
expect(document.getElementById("clip")!.contains(content)).toBe(true);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("swaps the surface without keeping the default one", async () => {
|
|
107
|
-
await mount(
|
|
108
|
-
<>
|
|
109
|
-
<SessionsHoverCard open surface="glass" body="G" className="p-5">
|
|
110
|
-
<button type="button">A</button>
|
|
111
|
-
</SessionsHoverCard>
|
|
112
|
-
</>,
|
|
113
|
-
);
|
|
114
|
-
const card = document.querySelector("[data-state=open][data-side]") as HTMLElement;
|
|
115
|
-
expect(card.className).toContain("glass-panel-strong");
|
|
116
|
-
expect(card.className).toContain("rounded-surface");
|
|
117
|
-
expect(card.className).not.toContain("bg-overlay");
|
|
118
|
-
expect(card.className).not.toContain("rounded-lg");
|
|
119
|
-
expect(card.className).toContain("p-5");
|
|
120
|
-
expect(card.className).not.toContain("p-3");
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("uses the floating overlay surface by default", async () => {
|
|
124
|
-
await mount(
|
|
125
|
-
<SessionsHoverCard open body="B">
|
|
126
|
-
<button type="button">A</button>
|
|
127
|
-
</SessionsHoverCard>,
|
|
128
|
-
);
|
|
129
|
-
const card = document.querySelector("[data-state=open][data-side]") as HTMLElement;
|
|
130
|
-
expect(card.className).toContain("bg-overlay");
|
|
131
|
-
expect(card.className).toContain("rounded-lg");
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it("forwards the ref and props to the child trigger and keeps its handlers", async () => {
|
|
135
|
-
const ref = createRef<HTMLButtonElement>();
|
|
136
|
-
const onClick = vi.fn();
|
|
137
|
-
await mount(
|
|
138
|
-
<SessionsHoverCard ref={ref as never} data-testid="row" body="B">
|
|
139
|
-
<button type="button" onClick={onClick}>
|
|
140
|
-
Row
|
|
141
|
-
</button>
|
|
142
|
-
</SessionsHoverCard>,
|
|
143
|
-
);
|
|
144
|
-
const button = document.querySelector("button") as HTMLButtonElement;
|
|
145
|
-
expect(ref.current).toBe(button);
|
|
146
|
-
expect(button.getAttribute("data-testid")).toBe("row");
|
|
147
|
-
await act(async () => button.click());
|
|
148
|
-
expect(onClick).toHaveBeenCalledTimes(1);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("renders its own trigger element with asChild={false}", async () => {
|
|
152
|
-
await mount(
|
|
153
|
-
<SessionsHoverCard asChild={false} body="B">
|
|
154
|
-
<span>Row</span>
|
|
155
|
-
</SessionsHoverCard>,
|
|
156
|
-
);
|
|
157
|
-
const trigger = document.querySelector("[data-state]") as HTMLElement;
|
|
158
|
-
expect(trigger.tagName).toBe("A");
|
|
159
|
-
expect(trigger.textContent).toBe("Row");
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
describe("focus and pointer", () => {
|
|
163
|
-
function setFocusVisible(el: HTMLElement, value: boolean) {
|
|
164
|
-
const orig = el.matches.bind(el);
|
|
165
|
-
el.matches = (sel: string) => (sel === ":focus-visible" ? value : orig(sel));
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
it("does not open on pointer-driven focus after a context menu", async () => {
|
|
169
|
-
const onOpenChange = vi.fn();
|
|
170
|
-
await mount(
|
|
171
|
-
<SessionsHoverCard openDelay={0} header="Head" onOpenChange={onOpenChange}>
|
|
172
|
-
<button type="button">Row</button>
|
|
173
|
-
</SessionsHoverCard>,
|
|
174
|
-
);
|
|
175
|
-
const row = document.querySelector("button")!;
|
|
176
|
-
setFocusVisible(row, false);
|
|
177
|
-
await act(async () => {
|
|
178
|
-
row.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
|
179
|
-
row.dispatchEvent(new FocusEvent("focus", { bubbles: false }));
|
|
180
|
-
row.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
|
181
|
-
});
|
|
182
|
-
await act(async () => new Promise((r) => setTimeout(r, 20)));
|
|
183
|
-
expect(onOpenChange).not.toHaveBeenCalledWith(true);
|
|
184
|
-
expect(document.body.textContent).not.toContain("Head");
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it("opens on keyboard focus-visible", async () => {
|
|
188
|
-
const onOpenChange = vi.fn();
|
|
189
|
-
await mount(
|
|
190
|
-
<SessionsHoverCard openDelay={0} header="Head" onOpenChange={onOpenChange}>
|
|
191
|
-
<button type="button">Row</button>
|
|
192
|
-
</SessionsHoverCard>,
|
|
193
|
-
);
|
|
194
|
-
const row = document.querySelector("button")!;
|
|
195
|
-
setFocusVisible(row, true);
|
|
196
|
-
await act(async () => {
|
|
197
|
-
row.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
|
198
|
-
});
|
|
199
|
-
await act(async () => new Promise((r) => setTimeout(r, 20)));
|
|
200
|
-
expect(onOpenChange).toHaveBeenCalledWith(true);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
// React derives onPointerLeave from native pointerout (with a null or outside
|
|
204
|
-
// relatedTarget). A native pointerleave event does not reach the handler.
|
|
205
|
-
function leave(el: HTMLElement) {
|
|
206
|
-
return act(async () => {
|
|
207
|
-
el.dispatchEvent(new PointerEvent("pointerout", { pointerType: "mouse", bubbles: true, relatedTarget: null }));
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
it("closes on pointer leave when stuck open by non-focus-visible focus", async () => {
|
|
212
|
-
const onOpenChange = vi.fn();
|
|
213
|
-
await mount(
|
|
214
|
-
<SessionsHoverCard defaultOpen header="Head" onOpenChange={onOpenChange}>
|
|
215
|
-
<button type="button">Row</button>
|
|
216
|
-
</SessionsHoverCard>,
|
|
217
|
-
);
|
|
218
|
-
const row = document.querySelector("button")!;
|
|
219
|
-
setFocusVisible(row, false);
|
|
220
|
-
row.focus();
|
|
221
|
-
await leave(row);
|
|
222
|
-
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
223
|
-
await act(async () => new Promise((r) => setTimeout(r, 20)));
|
|
224
|
-
expect(document.body.textContent).not.toContain("Head");
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it("stays open on pointer leave with keyboard focus-visible", async () => {
|
|
228
|
-
const onOpenChange = vi.fn();
|
|
229
|
-
await mount(
|
|
230
|
-
<SessionsHoverCard defaultOpen header="Head" onOpenChange={onOpenChange}>
|
|
231
|
-
<button type="button">Row</button>
|
|
232
|
-
</SessionsHoverCard>,
|
|
233
|
-
);
|
|
234
|
-
const row = document.querySelector("button")!;
|
|
235
|
-
setFocusVisible(row, true);
|
|
236
|
-
row.focus();
|
|
237
|
-
await leave(row);
|
|
238
|
-
await act(async () => new Promise((r) => setTimeout(r, 20)));
|
|
239
|
-
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
|
240
|
-
expect(document.body.textContent).toContain("Head");
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it("does not force-close on pointer leave without focus (Radix owns hover close)", async () => {
|
|
244
|
-
const onOpenChange = vi.fn();
|
|
245
|
-
await mount(
|
|
246
|
-
<SessionsHoverCard defaultOpen closeDelay={1000} header="Head" onOpenChange={onOpenChange}>
|
|
247
|
-
<button type="button">Row</button>
|
|
248
|
-
</SessionsHoverCard>,
|
|
249
|
-
);
|
|
250
|
-
const row = document.querySelector("button")!;
|
|
251
|
-
await leave(row);
|
|
252
|
-
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
|
253
|
-
expect(document.body.textContent).toContain("Head");
|
|
254
|
-
});
|
|
255
|
-
});
|
|
256
|
-
});
|