@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 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.4.4",
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
@@ -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 { ViewTransition, type ReactNode } from "react";
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
- });