@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 +104 -1
- package/app/README.md +100 -0
- package/package.json +12 -11
- package/shell/src/AppShell.tsx +99 -0
- package/shell/src/AppSidebar.tsx +168 -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 +157 -0
- package/shell/src/SidebarResize.tsx +257 -0
- package/shell/src/SignOutDialog.tsx +93 -0
- package/shell/src/app-shell-classes.ts +44 -0
- package/shell/src/index.ts +31 -0
- package/src/index.ts +34 -0
- package/src/motion/panel-transition.tsx +12 -1
- package/tokens/README.md +98 -0
- package/shell/src/SessionsHoverCard.test.tsx +0 -256
|
@@ -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,157 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
2
|
+
import { Slot, Slottable } from "@radix-ui/react-slot";
|
|
3
|
+
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
4
|
+
import { Button } from "../../src/ui/button";
|
|
5
|
+
import { cn } from "../../src/utils/cn";
|
|
6
|
+
import { railTileClass, railTileStateClass } from "./app-shell-classes";
|
|
7
|
+
import { SidebarIconTooltip } from "./SidebarIconTooltip";
|
|
8
|
+
|
|
9
|
+
export type SidebarRailProps = Omit<ComponentPropsWithoutRef<"div">, "children" | "className"> & {
|
|
10
|
+
/** Pinned at the top, above the scroll body. */
|
|
11
|
+
top?: ReactNode;
|
|
12
|
+
/** Scroll body. */
|
|
13
|
+
children?: ReactNode;
|
|
14
|
+
/** Pinned at the bottom. */
|
|
15
|
+
footer?: ReactNode;
|
|
16
|
+
/** Adds a right border between the rail and the panel. Default `false`. */
|
|
17
|
+
showDivider?: boolean;
|
|
18
|
+
/** Render the body as its single child element (for example a tabs list), with the body classes merged. */
|
|
19
|
+
bodyAsChild?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Render the body as a Radix tabs list (arrow-key roving focus). Use it
|
|
22
|
+
* inside a Tabs root with `orientation="vertical"` and give each `RailTile`
|
|
23
|
+
* a `tabValue`. Default `false`.
|
|
24
|
+
*/
|
|
25
|
+
tabs?: boolean;
|
|
26
|
+
className?: string;
|
|
27
|
+
topClassName?: string;
|
|
28
|
+
bodyClassName?: string;
|
|
29
|
+
footerClassName?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Icon rail column. Width reads `--app-shell-rail-width`; override with `className`. Other props go to the root `<div>`. */
|
|
33
|
+
export function SidebarRail({
|
|
34
|
+
top,
|
|
35
|
+
children,
|
|
36
|
+
footer,
|
|
37
|
+
showDivider = false,
|
|
38
|
+
bodyAsChild = false,
|
|
39
|
+
tabs = false,
|
|
40
|
+
className,
|
|
41
|
+
topClassName,
|
|
42
|
+
bodyClassName,
|
|
43
|
+
footerClassName,
|
|
44
|
+
...props
|
|
45
|
+
}: SidebarRailProps) {
|
|
46
|
+
const Body = bodyAsChild ? Slot : "div";
|
|
47
|
+
const bodyClass = cn(
|
|
48
|
+
"flex w-full flex-1 flex-col items-center gap-1.5 overflow-y-auto py-2",
|
|
49
|
+
bodyClassName,
|
|
50
|
+
);
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
{...props}
|
|
54
|
+
className={cn(
|
|
55
|
+
"flex h-full w-[var(--app-shell-rail-width)] shrink-0 flex-col items-center bg-background",
|
|
56
|
+
showDivider && "border-r border-separator",
|
|
57
|
+
className,
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
{top == null ? null : (
|
|
61
|
+
<div className={cn("flex w-full flex-col items-center gap-1.5 px-0 pt-3", topClassName)}>
|
|
62
|
+
{top}
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
{tabs ? (
|
|
66
|
+
<TabsPrimitive.List asChild={bodyAsChild} className={bodyClass}>
|
|
67
|
+
{children}
|
|
68
|
+
</TabsPrimitive.List>
|
|
69
|
+
) : (
|
|
70
|
+
<Body className={bodyClass}>{children}</Body>
|
|
71
|
+
)}
|
|
72
|
+
{footer == null ? null : (
|
|
73
|
+
<div className={cn("flex w-full flex-col items-center gap-1.5 py-3", footerClassName)}>
|
|
74
|
+
{footer}
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type RailTileProps = Omit<ComponentPropsWithoutRef<"button">, "children"> & {
|
|
82
|
+
/** `aria-label`, and the tooltip unless `tooltip` is set. */
|
|
83
|
+
label: string;
|
|
84
|
+
/** Active state. With `asChild`, also sets `aria-current="page"`. */
|
|
85
|
+
active?: boolean;
|
|
86
|
+
/** Tooltip text. Default `label`. */
|
|
87
|
+
tooltip?: string;
|
|
88
|
+
/** Default `true`. */
|
|
89
|
+
showTooltip?: boolean;
|
|
90
|
+
/** Rendered after the children, inside the tile (for example a count with `railBadgeClass`). */
|
|
91
|
+
badge?: ReactNode;
|
|
92
|
+
/** Render the single child element (for example a router link) as the tile. */
|
|
93
|
+
asChild?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Render a Radix tabs trigger for this value (inside a `SidebarRail tabs`).
|
|
96
|
+
* The label is sr-only text instead of `aria-label`; `asChild` is ignored.
|
|
97
|
+
*/
|
|
98
|
+
tabValue?: string;
|
|
99
|
+
children: ReactNode;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Rail tile with a right-side tooltip. Renders a ghost `Button`, its child with `asChild`, or a tabs trigger with `tabValue`. */
|
|
103
|
+
export function RailTile({
|
|
104
|
+
label,
|
|
105
|
+
active = false,
|
|
106
|
+
tooltip,
|
|
107
|
+
showTooltip = true,
|
|
108
|
+
badge,
|
|
109
|
+
asChild = false,
|
|
110
|
+
tabValue,
|
|
111
|
+
className,
|
|
112
|
+
children,
|
|
113
|
+
...props
|
|
114
|
+
}: RailTileProps) {
|
|
115
|
+
return (
|
|
116
|
+
<SidebarIconTooltip label={tooltip ?? label} enabled={showTooltip}>
|
|
117
|
+
{tabValue !== undefined ? (
|
|
118
|
+
<TabsPrimitive.Trigger
|
|
119
|
+
{...props}
|
|
120
|
+
value={tabValue}
|
|
121
|
+
className={cn(railTileClass, "group relative", railTileStateClass(active), className)}
|
|
122
|
+
>
|
|
123
|
+
{children}
|
|
124
|
+
<span className="sr-only">{label}</span>
|
|
125
|
+
{badge}
|
|
126
|
+
</TabsPrimitive.Trigger>
|
|
127
|
+
) : asChild ? (
|
|
128
|
+
<Slot
|
|
129
|
+
{...props}
|
|
130
|
+
aria-label={label}
|
|
131
|
+
aria-current={active ? "page" : undefined}
|
|
132
|
+
className={cn(railTileClass, "group", railTileStateClass(active), className)}
|
|
133
|
+
>
|
|
134
|
+
<Slottable>{children}</Slottable>
|
|
135
|
+
{badge}
|
|
136
|
+
</Slot>
|
|
137
|
+
) : (
|
|
138
|
+
<Button
|
|
139
|
+
type="button"
|
|
140
|
+
variant="ghost"
|
|
141
|
+
{...props}
|
|
142
|
+
aria-label={label}
|
|
143
|
+
className={cn(railTileClass, "group h-11 w-11 p-0", railTileStateClass(active), className)}
|
|
144
|
+
>
|
|
145
|
+
{children}
|
|
146
|
+
{badge}
|
|
147
|
+
</Button>
|
|
148
|
+
)}
|
|
149
|
+
</SidebarIconTooltip>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type RailDividerProps = { className?: string };
|
|
154
|
+
|
|
155
|
+
export function RailDivider({ className }: RailDividerProps) {
|
|
156
|
+
return <div className={cn("h-px w-8 bg-separator", className)} aria-hidden />;
|
|
157
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
useSyncExternalStore,
|
|
8
|
+
type KeyboardEvent,
|
|
9
|
+
type PointerEvent,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { animate } from "motion";
|
|
12
|
+
import { cn } from "../../src/utils/cn";
|
|
13
|
+
|
|
14
|
+
/** Opt-in resizable sidebar panel. Widths are panel px, rail excluded. */
|
|
15
|
+
export type SidebarResizeOptions = {
|
|
16
|
+
/** `localStorage` key for the panel width (a JSON number). */
|
|
17
|
+
storageKey: string;
|
|
18
|
+
defaultWidth: number;
|
|
19
|
+
minWidth: number;
|
|
20
|
+
maxWidth: number;
|
|
21
|
+
/** Handle `aria-label`. Default `"Resize sidebar"`. */
|
|
22
|
+
label?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type WidthUpdate = number | ((width: number) => number);
|
|
26
|
+
type Animation = ReturnType<typeof animate>;
|
|
27
|
+
|
|
28
|
+
type SidebarResizeState = {
|
|
29
|
+
width: number;
|
|
30
|
+
setWidth: (update: WidthUpdate) => void;
|
|
31
|
+
isResizing: boolean;
|
|
32
|
+
minWidth: number;
|
|
33
|
+
maxWidth: number;
|
|
34
|
+
defaultWidth: number;
|
|
35
|
+
label: string;
|
|
36
|
+
/** Latest width, readable between renders (pointer moves can outpace them). */
|
|
37
|
+
currentWidth: () => number;
|
|
38
|
+
stopAnimation: () => void;
|
|
39
|
+
startResizing: () => void;
|
|
40
|
+
settleToClamp: (velocity: number) => void;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const SidebarResizeContext = createContext<SidebarResizeState | null>(null);
|
|
44
|
+
export const SidebarResizeProvider = SidebarResizeContext.Provider;
|
|
45
|
+
|
|
46
|
+
const KEYBOARD_STEP = 16;
|
|
47
|
+
|
|
48
|
+
function rubberband(overshoot: number, dimension: number, constant = 0.55): number {
|
|
49
|
+
return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Sample = { x: number; t: number };
|
|
53
|
+
|
|
54
|
+
function releaseVelocity(samples: Sample[]): number {
|
|
55
|
+
if (samples.length < 2) return 0;
|
|
56
|
+
const newest = samples[samples.length - 1];
|
|
57
|
+
const oldest = samples[0];
|
|
58
|
+
if (!newest || !oldest) return 0;
|
|
59
|
+
const dt = newest.t - oldest.t;
|
|
60
|
+
if (dt <= 0) return 0;
|
|
61
|
+
return ((newest.x - oldest.x) / dt) * 1000;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readStoredWidth(storageKey: string | undefined): number | null {
|
|
65
|
+
if (storageKey === undefined || typeof window === "undefined") return null;
|
|
66
|
+
try {
|
|
67
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
68
|
+
if (raw === null || raw.trim() === "") return null;
|
|
69
|
+
// A JSON number parses the same with Number(); anything else is NaN.
|
|
70
|
+
const value = Number(raw);
|
|
71
|
+
return Number.isFinite(value) ? value : null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeStoredWidth(storageKey: string, width: number) {
|
|
78
|
+
try {
|
|
79
|
+
window.localStorage.setItem(storageKey, JSON.stringify(width));
|
|
80
|
+
} catch {
|
|
81
|
+
// Storage full or blocked: keep the in-memory width.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const subscribeNever = () => () => {};
|
|
86
|
+
const serverSnapshot = () => null;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Width and resize state for `AppShell`. Returns `null` without options. The
|
|
90
|
+
* stored width is read through `useSyncExternalStore` (the server snapshot is
|
|
91
|
+
* `null`, so SSR and hydration render `defaultWidth`); later writes go to
|
|
92
|
+
* local state and storage.
|
|
93
|
+
*/
|
|
94
|
+
export function useSidebarResizeState(
|
|
95
|
+
options: SidebarResizeOptions | undefined,
|
|
96
|
+
): SidebarResizeState | null {
|
|
97
|
+
const storageKey = options?.storageKey;
|
|
98
|
+
const stored = useSyncExternalStore(subscribeNever, () => readStoredWidth(storageKey), serverSnapshot);
|
|
99
|
+
const [override, setOverride] = useState<number | null>(null);
|
|
100
|
+
const [isResizing, setIsResizing] = useState(false);
|
|
101
|
+
const widthRef = useRef<number | null>(null);
|
|
102
|
+
const animRef = useRef<Animation | null>(null);
|
|
103
|
+
|
|
104
|
+
useEffect(() => () => animRef.current?.stop(), []);
|
|
105
|
+
|
|
106
|
+
if (!options) return null;
|
|
107
|
+
const { defaultWidth, minWidth, maxWidth } = options;
|
|
108
|
+
const clampWidth = (width: number) => Math.min(maxWidth, Math.max(minWidth, width));
|
|
109
|
+
const width = override ?? (stored === null ? defaultWidth : clampWidth(stored));
|
|
110
|
+
const currentWidth = () => widthRef.current ?? width;
|
|
111
|
+
|
|
112
|
+
const setWidth = (update: WidthUpdate) => {
|
|
113
|
+
const next = typeof update === "function" ? update(currentWidth()) : update;
|
|
114
|
+
widthRef.current = next;
|
|
115
|
+
setOverride(next);
|
|
116
|
+
writeStoredWidth(options.storageKey, next);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const stopAnimation = () => animRef.current?.stop();
|
|
120
|
+
|
|
121
|
+
const settleToClamp = (velocity: number) => {
|
|
122
|
+
const from = currentWidth();
|
|
123
|
+
const to = clampWidth(from);
|
|
124
|
+
setIsResizing(false);
|
|
125
|
+
if (from === to) return;
|
|
126
|
+
animRef.current?.stop();
|
|
127
|
+
animRef.current = animate(from, to, {
|
|
128
|
+
type: "spring",
|
|
129
|
+
bounce: 0.2,
|
|
130
|
+
duration: 0.35,
|
|
131
|
+
velocity,
|
|
132
|
+
onUpdate: (value) => setWidth(value),
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
width,
|
|
138
|
+
setWidth,
|
|
139
|
+
isResizing,
|
|
140
|
+
minWidth,
|
|
141
|
+
maxWidth,
|
|
142
|
+
defaultWidth,
|
|
143
|
+
label: options.label ?? "Resize sidebar",
|
|
144
|
+
currentWidth,
|
|
145
|
+
stopAnimation,
|
|
146
|
+
startResizing: () => setIsResizing(true),
|
|
147
|
+
settleToClamp,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function useSidebarResizeContext(): SidebarResizeState | null {
|
|
152
|
+
return useContext(SidebarResizeContext);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type AppShellResize = {
|
|
156
|
+
/** Panel width in px (rail excluded). Can sit past the bounds mid-drag. */
|
|
157
|
+
width: number;
|
|
158
|
+
setWidth: (update: WidthUpdate) => void;
|
|
159
|
+
isResizing: boolean;
|
|
160
|
+
minWidth: number;
|
|
161
|
+
maxWidth: number;
|
|
162
|
+
defaultWidth: number;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Panel width and resize state of the nearest resizable `AppShell`, or `null`. */
|
|
166
|
+
export function useAppShellResize(): AppShellResize | null {
|
|
167
|
+
const resize = useContext(SidebarResizeContext);
|
|
168
|
+
if (!resize) return null;
|
|
169
|
+
const { width, setWidth, isResizing, minWidth, maxWidth, defaultWidth } = resize;
|
|
170
|
+
return { width, setWidth, isResizing, minWidth, maxWidth, defaultWidth };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type SidebarResizeHandleProps = { className?: string };
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Vertical separator on the right edge of its positioned parent. Drag past the
|
|
177
|
+
* bounds rubber-bands and springs back on release; arrow keys step 16px;
|
|
178
|
+
* double-click resets. Renders nothing outside a resizable `AppShell`.
|
|
179
|
+
*/
|
|
180
|
+
export function SidebarResizeHandle({ className }: SidebarResizeHandleProps) {
|
|
181
|
+
const resize = useContext(SidebarResizeContext);
|
|
182
|
+
const samplesRef = useRef<Sample[]>([]);
|
|
183
|
+
if (!resize) return null;
|
|
184
|
+
const { width, setWidth, isResizing, minWidth, maxWidth, defaultWidth } = resize;
|
|
185
|
+
const clampWidth = (value: number) => Math.min(maxWidth, Math.max(minWidth, value));
|
|
186
|
+
const softWidth = (value: number) => {
|
|
187
|
+
if (value < minWidth) return minWidth - rubberband(minWidth - value, minWidth);
|
|
188
|
+
if (value > maxWidth) return maxWidth + rubberband(value - maxWidth, maxWidth);
|
|
189
|
+
return value;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
|
193
|
+
event.preventDefault();
|
|
194
|
+
resize.stopAnimation();
|
|
195
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
196
|
+
samplesRef.current = [{ x: resize.currentWidth(), t: performance.now() }];
|
|
197
|
+
resize.startResizing();
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
|
201
|
+
if (!isResizing) return;
|
|
202
|
+
const next = softWidth(resize.currentWidth() + event.movementX);
|
|
203
|
+
const now = performance.now();
|
|
204
|
+
const samples = samplesRef.current;
|
|
205
|
+
samples.push({ x: next, t: now });
|
|
206
|
+
while (samples.length > 5 || now - (samples[0]?.t ?? now) > 80) samples.shift();
|
|
207
|
+
setWidth(next);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const handlePointerUp = (event: PointerEvent<HTMLDivElement>) => {
|
|
211
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
212
|
+
resize.settleToClamp(releaseVelocity(samplesRef.current));
|
|
213
|
+
samplesRef.current = [];
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const handlePointerCancel = (event: PointerEvent<HTMLDivElement>) => {
|
|
217
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
218
|
+
resize.settleToClamp(0);
|
|
219
|
+
samplesRef.current = [];
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const handleDoubleClick = () => {
|
|
223
|
+
resize.stopAnimation();
|
|
224
|
+
setWidth(defaultWidth);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
228
|
+
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
resize.stopAnimation();
|
|
231
|
+
const step = event.key === "ArrowLeft" ? -KEYBOARD_STEP : KEYBOARD_STEP;
|
|
232
|
+
setWidth((current) => clampWidth(current + step));
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<div
|
|
237
|
+
role="separator"
|
|
238
|
+
aria-orientation="vertical"
|
|
239
|
+
aria-label={resize.label}
|
|
240
|
+
aria-valuenow={clampWidth(width)}
|
|
241
|
+
aria-valuemin={minWidth}
|
|
242
|
+
aria-valuemax={maxWidth}
|
|
243
|
+
tabIndex={0}
|
|
244
|
+
className={cn(
|
|
245
|
+
"absolute inset-y-0 right-0 z-50 h-full w-1.5 cursor-col-resize outline-hidden transition-[background-color] hover:bg-default focus-visible:bg-default",
|
|
246
|
+
isResizing && "bg-default",
|
|
247
|
+
className,
|
|
248
|
+
)}
|
|
249
|
+
onPointerDown={handlePointerDown}
|
|
250
|
+
onPointerMove={handlePointerMove}
|
|
251
|
+
onPointerUp={handlePointerUp}
|
|
252
|
+
onPointerCancel={handlePointerCancel}
|
|
253
|
+
onDoubleClick={handleDoubleClick}
|
|
254
|
+
onKeyDown={handleKeyDown}
|
|
255
|
+
/>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { IconAlertTriangle, IconLoader2 } from "@tabler/icons-react";
|
|
2
|
+
import { Button } from "../../src/ui/button";
|
|
3
|
+
import {
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogDescription,
|
|
7
|
+
DialogFooter,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
} from "../../src/ui/dialog";
|
|
11
|
+
import { cn } from "../../src/utils/cn";
|
|
12
|
+
import { signOutCancelClass, signOutDescriptionClass } from "./app-shell-classes";
|
|
13
|
+
|
|
14
|
+
export type SignOutDialogProps = {
|
|
15
|
+
open: boolean;
|
|
16
|
+
/** Not called with `false` while `pending`, so the dialog stays open during sign-out. */
|
|
17
|
+
onOpenChange: (open: boolean) => void;
|
|
18
|
+
/** Disables both buttons and shows "Signing out...". */
|
|
19
|
+
pending: boolean;
|
|
20
|
+
onConfirm: () => void | Promise<void>;
|
|
21
|
+
/** Second line under the question. */
|
|
22
|
+
description?: string;
|
|
23
|
+
/** Merged onto the dialog content after `sm:max-w-sm`. */
|
|
24
|
+
contentClassName?: string;
|
|
25
|
+
/** Dialog title and confirm button label. Default `"Sign out"`. */
|
|
26
|
+
title?: string;
|
|
27
|
+
/** Default `"Sign out of your account?"`. */
|
|
28
|
+
question?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Sign-out confirmation. The app owns the state and the sign-out call. */
|
|
32
|
+
export function SignOutDialog({
|
|
33
|
+
open,
|
|
34
|
+
onOpenChange,
|
|
35
|
+
pending,
|
|
36
|
+
onConfirm,
|
|
37
|
+
description,
|
|
38
|
+
contentClassName,
|
|
39
|
+
title = "Sign out",
|
|
40
|
+
question = "Sign out of your account?",
|
|
41
|
+
}: SignOutDialogProps) {
|
|
42
|
+
return (
|
|
43
|
+
<Dialog
|
|
44
|
+
open={open}
|
|
45
|
+
onOpenChange={(next) => {
|
|
46
|
+
if (!next && pending) return;
|
|
47
|
+
onOpenChange(next);
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
<DialogContent className={cn("sm:max-w-sm", contentClassName)}>
|
|
51
|
+
<DialogHeader>
|
|
52
|
+
<DialogTitle className="text-foreground">{title}</DialogTitle>
|
|
53
|
+
<DialogDescription className="sr-only">
|
|
54
|
+
Confirm signing out of your account
|
|
55
|
+
</DialogDescription>
|
|
56
|
+
</DialogHeader>
|
|
57
|
+
<div className="flex items-start gap-3 py-4">
|
|
58
|
+
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-danger/10">
|
|
59
|
+
<IconAlertTriangle size={20} className="text-danger" />
|
|
60
|
+
</div>
|
|
61
|
+
<div>
|
|
62
|
+
<p className="text-foreground">{question}</p>
|
|
63
|
+
{description ? <p className={signOutDescriptionClass}>{description}</p> : null}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
<DialogFooter>
|
|
67
|
+
<Button
|
|
68
|
+
variant="ghost"
|
|
69
|
+
onClick={() => onOpenChange(false)}
|
|
70
|
+
disabled={pending}
|
|
71
|
+
className={signOutCancelClass}
|
|
72
|
+
>
|
|
73
|
+
Cancel
|
|
74
|
+
</Button>
|
|
75
|
+
<Button
|
|
76
|
+
onClick={() => void onConfirm()}
|
|
77
|
+
disabled={pending}
|
|
78
|
+
className="bg-danger text-danger-foreground"
|
|
79
|
+
>
|
|
80
|
+
{pending ? (
|
|
81
|
+
<>
|
|
82
|
+
<IconLoader2 size={16} className="animate-spin" />
|
|
83
|
+
Signing out...
|
|
84
|
+
</>
|
|
85
|
+
) : (
|
|
86
|
+
title
|
|
87
|
+
)}
|
|
88
|
+
</Button>
|
|
89
|
+
</DialogFooter>
|
|
90
|
+
</DialogContent>
|
|
91
|
+
</Dialog>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { cn } from "../../src/utils/cn";
|
|
2
|
+
|
|
3
|
+
// Class strings for the app shell, copied from existing app markup so an app
|
|
4
|
+
// that moves to these components renders the same classes. The bare muted and
|
|
5
|
+
// accent colour names match that markup and are allow-listed for check:tokens
|
|
6
|
+
// on the marked lines only.
|
|
7
|
+
|
|
8
|
+
/** Rail tile: size-11, rounded-lg, press scale. */
|
|
9
|
+
export const railTileClass =
|
|
10
|
+
"relative flex size-11 items-center justify-center rounded-lg border active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring";
|
|
11
|
+
|
|
12
|
+
const railTileActiveClass = "border-transparent bg-surface-tertiary text-foreground";
|
|
13
|
+
const railTileIdleClass = "border-transparent text-muted opacity-75 hover:bg-surface-tertiary/50 hover:opacity-100 hover:text-foreground"; // check-tokens-allow: text-muted
|
|
14
|
+
|
|
15
|
+
export function railTileStateClass(active: boolean): string {
|
|
16
|
+
return active ? railTileActiveClass : railTileIdleClass;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Count badge pinned to the bottom-right of a rail tile. */
|
|
20
|
+
export const railBadgeClass = "absolute -bottom-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-accent px-1 text-[10px] font-semibold leading-none text-accent-foreground"; // check-tokens-allow: accent
|
|
21
|
+
|
|
22
|
+
/** Static section label, sentence case, padded to match nav rows. */
|
|
23
|
+
export const sidebarSectionLabelClass =
|
|
24
|
+
"sidebar-section-label px-3 py-1 text-[11px] font-medium tracking-[-0.01em]";
|
|
25
|
+
|
|
26
|
+
/** Nav link text: muted until active. */
|
|
27
|
+
export function sidebarNavLinkTextClass(isActive: boolean): string {
|
|
28
|
+
return cn(isActive ? "text-foreground" : "text-muted hover:text-foreground"); // check-tokens-allow: text-muted
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Padding for main sidebar nav rows. */
|
|
32
|
+
export function sidebarNavRowClass(isIconOnly: boolean): string {
|
|
33
|
+
return cn(isIconOnly ? "justify-center px-0 py-1.5" : "gap-3 px-3 py-1.5");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const mobileMenuButtonClass = "-ml-1 h-11 w-11 shrink-0 rounded-lg text-muted hover:bg-surface-tertiary/50 hover:text-foreground"; // check-tokens-allow: text-muted
|
|
37
|
+
|
|
38
|
+
export const panelCloseButtonClass = "rounded-lg text-muted hover:bg-surface-tertiary/50 hover:text-foreground"; // check-tokens-allow: text-muted
|
|
39
|
+
|
|
40
|
+
export const accountEmailClass = "block truncate text-xs font-normal text-muted"; // check-tokens-allow: text-muted
|
|
41
|
+
|
|
42
|
+
export const signOutDescriptionClass = "mt-1 text-sm text-muted"; // check-tokens-allow: text-muted
|
|
43
|
+
|
|
44
|
+
export const signOutCancelClass = "text-muted"; // check-tokens-allow: text-muted
|
package/shell/src/index.ts
CHANGED
|
@@ -10,3 +10,34 @@ 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 { SidebarResizeHandle, useAppShellResize } from "./SidebarResize.js";
|
|
20
|
+
export type {
|
|
21
|
+
AppShellResize,
|
|
22
|
+
SidebarResizeHandleProps,
|
|
23
|
+
SidebarResizeOptions,
|
|
24
|
+
} from "./SidebarResize.js";
|
|
25
|
+
export { SidebarIconTooltip } from "./SidebarIconTooltip.js";
|
|
26
|
+
export type { SidebarIconTooltipProps } from "./SidebarIconTooltip.js";
|
|
27
|
+
export { SidebarPanel } from "./SidebarPanel.js";
|
|
28
|
+
export type { SidebarPanelProps } from "./SidebarPanel.js";
|
|
29
|
+
export { SidebarAccountMenu, SidebarAccountMenuPlaceholder } from "./SidebarAccountMenu.js";
|
|
30
|
+
export type {
|
|
31
|
+
SidebarAccountMenuProps,
|
|
32
|
+
SidebarAccountMenuPlaceholderProps,
|
|
33
|
+
} from "./SidebarAccountMenu.js";
|
|
34
|
+
export { SignOutDialog } from "./SignOutDialog.js";
|
|
35
|
+
export type { SignOutDialogProps } from "./SignOutDialog.js";
|
|
36
|
+
export {
|
|
37
|
+
railBadgeClass,
|
|
38
|
+
railTileClass,
|
|
39
|
+
railTileStateClass,
|
|
40
|
+
sidebarNavLinkTextClass,
|
|
41
|
+
sidebarNavRowClass,
|
|
42
|
+
sidebarSectionLabelClass,
|
|
43
|
+
} from "./app-shell-classes.js";
|