@vvedantb/ui 0.4.3 → 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 +86 -2
- 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/composer/input-area/FadePresence.tsx +30 -12
- package/src/index.ts +31 -0
- package/src/motion/panel-transition.tsx +38 -0
- package/src/motion/presets.ts +18 -0
- package/src/ui/_menu-parts.tsx +59 -0
- package/src/ui/button.tsx +28 -3
- package/src/ui/card.tsx +13 -1
- package/src/ui/command.tsx +46 -6
- package/src/ui/context-menu.tsx +40 -11
- package/src/ui/dropdown-menu.tsx +63 -15
- package/src/ui/overlay-a11y.ts +19 -0
- package/src/ui/popover.tsx +12 -2
- package/src/ui/select-field.tsx +58 -0
- 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. |
|
|
@@ -214,6 +214,90 @@ import { ConfirmDialog, SlideOverPanel, ToolbarSearchInput } from "@vvedantb/ui"
|
|
|
214
214
|
- `ToolbarSearchInput`: a compact `h-8` search field for toolbars, with a search icon and a clear button. The clear button stays mounted and is hidden while the value is blank. Clearing calls `onChange("")` and does not move focus. Defaults: `placeholder="Search..."`, `label="Search"` (the `aria-label`), `clearLabel="Clear search"`. The ref goes to the `<input>`.
|
|
215
215
|
- `ConfirmDialog` `size`: `"default"` keeps the alert dialog. `"sm"` renders a compact, dismissible dialog (outside click, Escape and the close button close it) with small Cancel and confirm buttons. Cancel calls `onCancel` and then `onOpenChange(false)`. Confirm calls `onConfirm` only, so the caller closes it.
|
|
216
216
|
|
|
217
|
+
## Buttons, menus and fields (0.4.4)
|
|
218
|
+
|
|
219
|
+
All of these are opt-in. Without the new props, components render as before.
|
|
220
|
+
|
|
221
|
+
```tsx
|
|
222
|
+
import {
|
|
223
|
+
Button, CardHeader, CommandDialog, DropdownMenuContent, DropdownMenuItem,
|
|
224
|
+
FadePresence, PanelTransition, PopoverContent, SelectField, SelectItem,
|
|
225
|
+
fadeUpSpring, restoreModalScrollLock,
|
|
226
|
+
} from "@vvedantb/ui";
|
|
227
|
+
|
|
228
|
+
<Button variant="flat" loading={saving} endContent={<IconArrowRight />}>Save</Button>
|
|
229
|
+
|
|
230
|
+
<DropdownMenuContent fitViewport suppressAltKey restoreScrollLockOnClose>
|
|
231
|
+
<DropdownMenuItem startContent={<IconFile />} endContent={<Kbd>⌘O</Kbd>}>Open</DropdownMenuItem>
|
|
232
|
+
</DropdownMenuContent>
|
|
233
|
+
|
|
234
|
+
<SelectField id="tone" label="Tone" placeholder="Pick one" value={tone} onValueChange={setTone}>
|
|
235
|
+
<SelectItem value="calm">Calm</SelectItem>
|
|
236
|
+
</SelectField>
|
|
237
|
+
|
|
238
|
+
<FadePresence show={visible} variants={fadeUpSpring}>…</FadePresence>
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
- `Button`: variants `flat`, `success` and `warning` (each is `ghost` plus an override). `loading` disables the button, sets `data-loading="true"` and shows a spinner in place of `startContent`; without `loading` there is no `data-loading` attribute. `endContent` renders after the children. Both are ignored with `asChild` (`loading` still disables).
|
|
242
|
+
- `DropdownMenuContent`: `fitViewport` caps the height on small screens and scrolls. It reads the optional `--vv-height` CSS variable, which the app may set to the visual viewport height (falls back to `70dvh`). `suppressAltKey` stops a bare Alt press from moving focus. `restoreScrollLockOnClose` calls `restoreModalScrollLock` on close (now and on the next tick). `ContextMenuContent` takes `suppressAltKey` too.
|
|
243
|
+
- `DropdownMenuItem` `endContent` and `ContextMenuItem` `startContent`, `description` and `endContent`: the label moves into a growing column (`min-w-0 flex-1`), the description sits under it and `endContent` sits at the end. `DropdownMenuSubTrigger` and `ContextMenuSubTrigger` take `startContent`.
|
|
244
|
+
- `PopoverContent` `fitViewport`: grows from the trigger (`--radix-popper-transform-origin`), defaults `collisionPadding` to 8 (your value wins) and, on small screens, caps width to `100vw - 1.5rem` and height as above.
|
|
245
|
+
- `CommandDialog`: `title` and `description` add a visually hidden dialog header. `className` merges onto the dialog content. `shouldFilter` goes to `Command`. `listLayout` is `"pinned"` (default, fixed list height) or `"auto"` (the list sizes itself and items keep your styling).
|
|
246
|
+
- `restoreModalScrollLock()`: clears body `pointer-events`, and, when no dialog or alert dialog is open, the body and root `overflow` and body `padding-right` left by a modal menu.
|
|
247
|
+
- `CardHeader`: `flex flex-col space-y-1.5 p-6`.
|
|
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
|
+
- 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
|
+
|
|
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
|
+
|
|
217
301
|
## Toaster
|
|
218
302
|
|
|
219
303
|
```tsx
|
|
@@ -255,7 +339,7 @@ Helpers: `createToasterClassNames`, `mergeToastClassNames`, `mergeToastOptions`,
|
|
|
255
339
|
|
|
256
340
|
## Colour names in components
|
|
257
341
|
|
|
258
|
-
Components use `text-muted-foreground`, `primary` and `--color-*` theme variables. They no longer use `text-muted`, `*-accent` or raw `var(--muted)` reads, which clash with shadcn names. `tokens.css` still defines `--color-muted` and `--color-accent`. `pnpm --filter @vvedantb/ui check:tokens` fails if a component brings the old names back (it also runs in `build`, `test` and `verify`).
|
|
342
|
+
Components use `text-muted-foreground`, `primary` and `--color-*` theme variables. They no longer use `text-muted`, `*-accent` or raw `var(--muted)` reads, which clash with shadcn names. Exceptions that match existing app markup on purpose: the `InputArea` ghost text, the `Button` `flat` variant and the `CommandDialog` `listLayout="auto"` group headings use `text-muted`. `tokens.css` still defines `--color-muted` and `--color-accent`. `pnpm --filter @vvedantb/ui check:tokens` fails if a component brings the old names back (it also runs in `build`, `test` and `verify`).
|
|
259
343
|
|
|
260
344
|
## Using an existing shadcn RGB theme
|
|
261
345
|
|
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
|
+
}
|