@vvedantb/ui 0.4.4 → 0.5.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 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,109 @@ 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, 0.5.1)
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
+
301
+ ### Resizable panel (0.5.1)
302
+
303
+ Pass `resizable` to `AppShell`. Widths are panel px, rail excluded. Without it nothing changes.
304
+
305
+ ```tsx
306
+ <AppShell
307
+ resizable={{ storageKey: "sidebar-width", defaultWidth: 256, minWidth: 200, maxWidth: 480 }}
308
+ sidebar={<AppSidebar /* as above */ />}
309
+ >
310
+ {page}
311
+ </AppShell>
312
+ ```
313
+
314
+ - `resizable`: `storageKey`, `defaultWidth`, `minWidth`, `maxWidth`, `label` (default `"Resize sidebar"`). The shell reads the stored width (a JSON number in `localStorage`) on mount, ignores invalid values, clamps it to the bounds and writes each change. Server and hydration renders use `defaultWidth`. `--app-shell-sidebar-width` becomes `calc(<railWidth> + <width>px)` and `sidebarWidth` is ignored. While resizing, the main margin and `<aside>` width transitions are off.
315
+ - `AppSidebar` adds the handle to the `<aside>` right edge on desktop while the panel shows (not `collapsed`, not `panelHidden`). The mobile drawer has none.
316
+ - `SidebarResizeHandle` (`className`): the handle, for custom layouts. Place it in a positioned parent. Dragging past the bounds rubber-bands and springs back on release. ArrowLeft and ArrowRight step 16px. Double-click resets to `defaultWidth`. It renders nothing outside a resizable `AppShell`.
317
+ - `useAppShellResize()`: `{ width, setWidth, isResizing, minWidth, maxWidth, defaultWidth }`, or `null` outside a resizable `AppShell`. `setWidth` takes a number or an updater.
318
+
319
+ ```tsx
320
+ function PanelWidth() {
321
+ const resize = useAppShellResize();
322
+ return resize ? <span>{Math.round(resize.width)}px</span> : null;
323
+ }
324
+ ```
325
+
326
+ ### Rail tabs (0.5.1)
327
+
328
+ `SidebarRail tabs` renders the body as a Radix tabs list (arrow-key roving focus, same classes). Wrap the sidebar in a Tabs root with `orientation="vertical"` (the package `Tabs` works). `RailTile tabValue` renders a tabs trigger: tile classes, the children, the label as sr-only text instead of `aria-label`, then the badge. `active` drives the state classes and other props (for example `aria-expanded`) go to the trigger. `asChild` is ignored with `tabValue`. Without `tabs` and `tabValue` the rail renders as before.
329
+
330
+ ```tsx
331
+ <Tabs value={tab} onValueChange={setTab} orientation="vertical">
332
+ <AppSidebar
333
+ /* … */
334
+ rail={
335
+ <SidebarRail tabs>
336
+ <RailTile tabValue="files" label="Files" active={tab === "files"} aria-expanded={tab === "files" ? !collapsed : undefined}>
337
+ <IconFiles size={22} />
338
+ </RailTile>
339
+ <RailTile tabValue="search" label="Search" active={tab === "search"}>
340
+ <IconSearch size={22} />
341
+ </RailTile>
342
+ </SidebarRail>
343
+ }
344
+ panel={
345
+ <>
346
+ <TabsContent value="files">{files}</TabsContent>
347
+ <TabsContent value="search">{search}</TabsContent>
348
+ </>
349
+ }
350
+ />
351
+ </Tabs>
352
+ ```
353
+
251
354
  ## Toaster
252
355
 
253
356
  ```tsx
package/app/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # App helpers (`@vvedantb/ui`)
2
+
3
+ Shared web React helpers that were duplicated across apps. Source-exported as the `./app` subpath of `@vvedantb/ui`.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @vvedantb/ui
9
+ ```
10
+
11
+ ## Peers
12
+
13
+ Required: `react` and `react-dom` (>=19). Optional peers of `@vvedantb/ui`, needed only for the app helpers: `@clerk/clerk-react` (`useStableAuth`), `@tanstack/react-hotkeys` and `zod`.
14
+
15
+ `sonner` is not a dependency. The update watcher takes the toast API as an option. The app source uses a local class join, which the root does not export (the root `cn` is the clsx and tailwind-merge one). Tailwind must scan `node_modules/@vvedantb/ui/app/src/**/*.{ts,tsx}` so the class names are generated, and the app needs the `@vvedantb/ui/tokens` names (`bg-surface-card`, `divide-separator`, `text-muted-foreground` and so on).
16
+
17
+ ```css
18
+ @source "../node_modules/@vvedantb/ui/app/src/**/*.{ts,tsx}";
19
+ ```
20
+
21
+ ## Confirm skip (Alt-click)
22
+
23
+ ```tsx
24
+ import { ConfirmSkipHint, skipConfirmTitle, useRequestConfirm } from "@vvedantb/ui";
25
+
26
+ const { request } = useRequestConfirm();
27
+
28
+ <DropdownMenuItem
29
+ title={skipConfirmTitle("Delete")}
30
+ onSelect={(event) => request(() => setOpen(true), () => remove(), event)}
31
+ >
32
+ Delete <ConfirmSkipHint />
33
+ </DropdownMenuItem>
34
+ ```
35
+
36
+ - `useRequestConfirm` does not subscribe to Alt. It reads `getAltHeld()` when the request happens, so the menu owner does not re-render when Alt goes down. It returns `{ request }` only.
37
+ - `ConfirmSkipHint` stays mounted and gains a `hidden` class when Alt is not held, so an open Radix menu keeps a stable child tree.
38
+ - The Alt store resets when the last subscriber leaves, clears on blur and tab hide, and cancels a bare Alt keydown only inside `[role='menu']` or `[data-radix-menu-content]`.
39
+ - Also exported: `useAltHeld`, `getAltHeld`, `createAltHeldStore`, `bindToWindow`, `shouldSkipConfirm`, `requestConfirm`.
40
+
41
+ ## Update available watcher
42
+
43
+ ```ts
44
+ import { toast } from "sonner";
45
+ import { startUpdateAvailableWatcher } from "@vvedantb/ui";
46
+
47
+ useEffect(
48
+ () =>
49
+ startUpdateAvailableWatcher({
50
+ origin: window.location.origin,
51
+ toast,
52
+ closeConvex: () => void convex.close(),
53
+ enabled: !import.meta.env.DEV,
54
+ }),
55
+ [],
56
+ );
57
+ ```
58
+
59
+ Options: `origin`, `toast`, `closeConvex?`, `pollMs?` (default 60000), `toastId?` (default `app-update-available`), `enabled?` (default true), `reload?` (default `window.location.reload`). On a changed asset fingerprint it shows a sticky "Update available" toast. Refresh calls `closeConvex` (errors ignored), then reloads. `fingerprintFromHtml` and `fetchDeployFingerprint` are exported too. The fingerprint looks for Vite `/assets/*.js` URLs in the index HTML.
60
+
61
+ ## Settings chrome
62
+
63
+ ```tsx
64
+ import { SettingsPage, SettingsSection, SettingsToggleRow } from "@vvedantb/ui";
65
+
66
+ <SettingsPage
67
+ title="Notifications"
68
+ layout={({ title, headerRight, toolbar, tabs, fillHeight, children }) => (
69
+ <PageContainer title={title} rightSection={headerRight} toolbar={toolbar} tabs={tabs} noScroll={fillHeight}>
70
+ {children}
71
+ </PageContainer>
72
+ )}
73
+ >
74
+ <SettingsSection title="Email" bodyVariant="list">
75
+ <SettingsToggleRow title="Weekly digest" action={<Switch />} />
76
+ </SettingsSection>
77
+ </SettingsPage>
78
+ ```
79
+
80
+ Exports: `SettingsSection`, `SettingsField`, `SettingsStack`, `SettingsToggleRow`, `SettingsEmptyState` (icon is `ComponentType<{ size?: number; className?: string }>`), `SettingsPage`. `SettingsField` `layout`: `"stacked"` (default) puts the label above the control. `"horizontal"` renders one row with the label and description on the left and the control on the right. Without `layout`, `SettingsPage` renders a plain `div` with a heading and the optional slots.
81
+
82
+ ## useStableAuth
83
+
84
+ ```ts
85
+ import { useStableAuth } from "@vvedantb/ui"; // 2000 ms debounce, wraps Clerk useAuth
86
+ import { createUseStableAuth } from "@vvedantb/ui";
87
+
88
+ const useAuth = createUseStableAuth(useClerkAuth, { debounceMs: 3000 });
89
+ ```
90
+
91
+ After a signed-in session, a drop to signed-out reports `isLoaded: false` for the debounce window so Convex does not cascade. State is module-level. `resetStableAuthSession()` clears it and is for tests only.
92
+
93
+ ## Develop
94
+
95
+ ```sh
96
+ pnpm --filter @vvedantb/ui typecheck
97
+ pnpm --filter @vvedantb/ui test
98
+ ```
99
+
100
+ Source is in `app/src` and tests in `app/tests`. Publish steps are in the root `README.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
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",
@@ -52,6 +52,15 @@
52
52
  "sideEffects": [
53
53
  "./src/ui/sonner.css"
54
54
  ],
55
+ "scripts": {
56
+ "check:tokens": "node tokens/scripts/check-component-tokens.mjs",
57
+ "build": "node tokens/scripts/check-exports.mjs && node tokens/scripts/check-component-tokens.mjs && tsc -p tsconfig.json",
58
+ "typecheck": "tsc -p tsconfig.json && tsc -p tokens/tsconfig.json",
59
+ "test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs",
60
+ "verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
61
+ "analyze": "node tokens/verify/analyze.mjs",
62
+ "prepack": "node tokens/scripts/check-exports.mjs"
63
+ },
55
64
  "peerDependencies": {
56
65
  "@clerk/clerk-react": ">=5",
57
66
  "@radix-ui/react-accordion": "^1.2.12",
@@ -162,13 +171,5 @@
162
171
  "zod": {
163
172
  "optional": true
164
173
  }
165
- },
166
- "scripts": {
167
- "check:tokens": "node tokens/scripts/check-component-tokens.mjs",
168
- "build": "node tokens/scripts/check-exports.mjs && node tokens/scripts/check-component-tokens.mjs && tsc -p tsconfig.json",
169
- "typecheck": "tsc -p tsconfig.json && tsc -p tokens/tsconfig.json",
170
- "test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs",
171
- "verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
172
- "analyze": "node tokens/verify/analyze.mjs"
173
174
  }
174
- }
175
+ }
@@ -0,0 +1,99 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { cn } from "../../src/utils/cn";
3
+ import {
4
+ SidebarResizeProvider,
5
+ useSidebarResizeState,
6
+ type SidebarResizeOptions,
7
+ } from "./SidebarResize";
8
+
9
+ export type AppShellProps = {
10
+ /** Rendered between the skip link and `<main>`, usually an `AppSidebar`. */
11
+ sidebar?: ReactNode;
12
+ children?: ReactNode;
13
+ /**
14
+ * `true` when only the rail shows on desktop (sidebar collapsed, or the
15
+ * current section has no panel). Main then offsets by the rail width
16
+ * instead of the sidebar width.
17
+ */
18
+ panelHidden?: boolean;
19
+ /** Sets `--app-shell-rail-width`. Default `"4rem"`. */
20
+ railWidth?: string;
21
+ /** Sets `--app-shell-sidebar-width` (rail plus panel). Default `"20rem"`. Ignored with `resizable`. */
22
+ sidebarWidth?: string;
23
+ /**
24
+ * Opt-in resizable panel. The shell owns the persisted panel width, sets
25
+ * `--app-shell-sidebar-width` to `calc(<railWidth> + <width>px)` and shares
26
+ * the state with `AppSidebar`, `SidebarResizeHandle` and `useAppShellResize`.
27
+ */
28
+ resizable?: SidebarResizeOptions;
29
+ /** Sets `--app-shell-mobile-header-height`. Default `"calc(3.5rem + env(safe-area-inset-top, 0px))"`. */
30
+ mobileHeaderHeight?: string;
31
+ /** Default `"Skip to main content"`. */
32
+ skipLinkLabel?: string;
33
+ /** `id` of `<main>` and the skip link target. Default `"main-content"`. */
34
+ mainId?: string;
35
+ className?: string;
36
+ mainClassName?: string;
37
+ /** Merged onto the content card inside `<main>`. */
38
+ contentClassName?: string;
39
+ };
40
+
41
+ type ShellVars = CSSProperties & Record<`--app-shell-${string}`, string>;
42
+
43
+ /** Full-height app frame: skip link, a sidebar slot and the main content card. */
44
+ export function AppShell({
45
+ sidebar,
46
+ children,
47
+ panelHidden = false,
48
+ railWidth = "4rem",
49
+ sidebarWidth = "20rem",
50
+ resizable,
51
+ mobileHeaderHeight = "calc(3.5rem + env(safe-area-inset-top, 0px))",
52
+ skipLinkLabel = "Skip to main content",
53
+ mainId = "main-content",
54
+ className,
55
+ mainClassName,
56
+ contentClassName,
57
+ }: AppShellProps) {
58
+ const resize = useSidebarResizeState(resizable);
59
+ const style: ShellVars = {
60
+ "--app-shell-rail-width": railWidth,
61
+ "--app-shell-sidebar-width": resize ? `calc(${railWidth} + ${resize.width}px)` : sidebarWidth,
62
+ "--app-shell-mobile-header-height": mobileHeaderHeight,
63
+ };
64
+ return (
65
+ <SidebarResizeProvider value={resize}>
66
+ <div className={cn("relative h-dvh overflow-hidden bg-background", className)} style={style}>
67
+ <a
68
+ href={`#${mainId}`}
69
+ 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"
70
+ >
71
+ {skipLinkLabel}
72
+ </a>
73
+ {sidebar}
74
+ <main
75
+ id={mainId}
76
+ tabIndex={-1}
77
+ className={cn(
78
+ "relative z-10 flex h-full min-h-0 outline-none md:p-2",
79
+ panelHidden
80
+ ? "md:ml-[var(--app-shell-rail-width)]"
81
+ : "md:ml-[var(--app-shell-sidebar-width)]",
82
+ !resize?.isResizing &&
83
+ "md:transition-[margin-left] md:[transition-duration:280ms] md:[transition-timing-function:cubic-bezier(0.22,1,0.36,1)]",
84
+ mainClassName,
85
+ )}
86
+ >
87
+ <div
88
+ className={cn(
89
+ "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",
90
+ contentClassName,
91
+ )}
92
+ >
93
+ {children}
94
+ </div>
95
+ </main>
96
+ </div>
97
+ </SidebarResizeProvider>
98
+ );
99
+ }
@@ -0,0 +1,168 @@
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
+ import { SidebarResizeHandle, useSidebarResizeContext } from "./SidebarResize";
9
+
10
+ export type AppSidebarProps = {
11
+ /** Icon rail, usually a `SidebarRail`. */
12
+ rail: ReactNode;
13
+ /** Panel beside the rail, usually a `SidebarPanel`. Not rendered while `panelHidden`. */
14
+ panel?: ReactNode;
15
+ /** Desktop: show only the rail. The panel stays mounted (hide it with `SidebarPanel collapsed`). */
16
+ collapsed?: boolean;
17
+ /** The current section has no panel: the sidebar is rail-wide on every viewport. */
18
+ panelHidden?: boolean;
19
+ /** From the app's desktop media query (for example `(min-width: 768px)`). */
20
+ isDesktop: boolean;
21
+ /** Mobile drawer state. Owned by the app (which also closes it on route change). */
22
+ mobileOpen: boolean;
23
+ onMobileOpenChange: (open: boolean) => void;
24
+ /** `id` of the drawer, used by the menu button `aria-controls`. Default from `useId`. */
25
+ id?: string;
26
+ /** Extra `data-*` attributes for the `<aside>`, for example a view-transition target. */
27
+ asideProps?: Record<`data-${string}`, string | undefined>;
28
+ /** Merged onto the `<aside>`. */
29
+ className?: string;
30
+ /** Centred in the mobile header when truthy. */
31
+ mobileTitle?: ReactNode;
32
+ /** Menu button icon. Default a 20px menu icon. */
33
+ mobileMenuIcon?: (open: boolean) => ReactNode;
34
+ /** Default `"Open navigation menu"`. */
35
+ mobileMenuLabel?: string;
36
+ /** Backdrop label. Default `"Close navigation"`. */
37
+ closeLabel?: string;
38
+ };
39
+
40
+ const defaultMobileMenuIcon = () => <IconMenu2 size={20} />;
41
+
42
+ /**
43
+ * Sidebar chrome: the mobile header, the drawer backdrop and the `<aside>`
44
+ * (fixed on desktop, a swipe-to-close drawer below it). Reads the
45
+ * `--app-shell-*` width variables set by `AppShell`. Inside a resizable
46
+ * `AppShell` it adds the resize handle while the panel shows on desktop.
47
+ */
48
+ export function AppSidebar({
49
+ rail,
50
+ panel,
51
+ collapsed = false,
52
+ panelHidden = false,
53
+ isDesktop,
54
+ mobileOpen,
55
+ onMobileOpenChange,
56
+ id,
57
+ asideProps,
58
+ className,
59
+ mobileTitle,
60
+ mobileMenuIcon = defaultMobileMenuIcon,
61
+ mobileMenuLabel = "Open navigation menu",
62
+ closeLabel = "Close navigation",
63
+ }: AppSidebarProps) {
64
+ const autoId = useId();
65
+ const drawerId = id ?? autoId;
66
+ const layout = isDesktop ? "desktop" : "drawer";
67
+ const resize = useSidebarResizeContext();
68
+ const showResizeHandle = resize !== null && isDesktop && !collapsed && !panelHidden;
69
+
70
+ // Reaching the desktop breakpoint closes the drawer, so it does not reopen
71
+ // on the way back down. Runs on breakpoint change only.
72
+ useEffect(() => {
73
+ if (isDesktop && mobileOpen) onMobileOpenChange(false);
74
+ }, [isDesktop]);
75
+
76
+ const close = () => onMobileOpenChange(false);
77
+
78
+ const handleDrawerDragEnd = (
79
+ _event: MouseEvent | TouchEvent | PointerEvent,
80
+ info: PanInfo,
81
+ ) => {
82
+ if (isDesktop || !mobileOpen) return;
83
+ if (info.offset.x < -72 || info.velocity.x < -500) {
84
+ close();
85
+ }
86
+ };
87
+
88
+ const closeOnEscape = (event: KeyboardEvent) => {
89
+ if (event.key === "Escape" && !isDesktop && mobileOpen) {
90
+ close();
91
+ }
92
+ };
93
+
94
+ return (
95
+ <>
96
+ <header
97
+ onKeyDown={closeOnEscape}
98
+ 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"
99
+ >
100
+ <Button
101
+ type="button"
102
+ variant="ghost"
103
+ size="icon"
104
+ onClick={() => onMobileOpenChange(true)}
105
+ aria-label={mobileMenuLabel}
106
+ aria-expanded={mobileOpen}
107
+ aria-controls={drawerId}
108
+ className={mobileMenuButtonClass}
109
+ >
110
+ {mobileMenuIcon(mobileOpen)}
111
+ </Button>
112
+ {mobileTitle ? (
113
+ <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">
114
+ {mobileTitle}
115
+ </h1>
116
+ ) : null}
117
+ <div className="ml-auto h-11 w-11 shrink-0" aria-hidden="true" />
118
+ </header>
119
+
120
+ <AnimatePresence initial={false}>
121
+ {mobileOpen && !isDesktop ? (
122
+ <motion.button
123
+ type="button"
124
+ aria-label={closeLabel}
125
+ className="fixed inset-0 z-40 bg-backdrop/80 md:hidden"
126
+ onClick={close}
127
+ initial={{ opacity: 0 }}
128
+ animate={{ opacity: 1 }}
129
+ exit={{ opacity: 0 }}
130
+ transition={{ duration: motionTiming.sidebar, ease: motionEase }}
131
+ />
132
+ ) : null}
133
+ </AnimatePresence>
134
+
135
+ <motion.aside
136
+ id={drawerId}
137
+ data-sidebar-layout={layout}
138
+ {...asideProps}
139
+ inert={!isDesktop && !mobileOpen}
140
+ onKeyDown={closeOnEscape}
141
+ className={cn(
142
+ "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",
143
+ panelHidden
144
+ ? "w-[var(--app-shell-rail-width)]"
145
+ : "w-[min(var(--app-shell-sidebar-width),calc(100vw-1.5rem))]",
146
+ collapsed || panelHidden
147
+ ? "md:w-[var(--app-shell-rail-width)]"
148
+ : "md:w-[var(--app-shell-sidebar-width)]",
149
+ !resize?.isResizing &&
150
+ "md:transition-[width] md:[transition-duration:280ms] md:[transition-timing-function:cubic-bezier(0.22,1,0.36,1)]",
151
+ className,
152
+ )}
153
+ initial={false}
154
+ animate={{ x: isDesktop || mobileOpen ? 0 : "-100%" }}
155
+ transition={{ duration: motionTiming.sidebar, ease: motionEase }}
156
+ drag={!isDesktop && mobileOpen ? "x" : false}
157
+ dragDirectionLock
158
+ dragConstraints={{ left: 0, right: 0 }}
159
+ dragElastic={{ left: 0.45, right: 0.08 }}
160
+ onDragEnd={handleDrawerDragEnd}
161
+ >
162
+ {rail}
163
+ {panelHidden ? null : panel}
164
+ {showResizeHandle ? <SidebarResizeHandle /> : null}
165
+ </motion.aside>
166
+ </>
167
+ );
168
+ }
@@ -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
+ }