@viliha/vui-ui 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VILIHA PTE. LTD.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@viliha/vui-ui",
3
+ "version": "1.0.0",
4
+ "description": "Vui UI — a clean, token-driven React admin/CRM component library built on Tailwind CSS v4, shadcn-style patterns, and Radix Icons. Ships as TypeScript source (Just-in-Time), compiled by the consuming app.",
5
+ "license": "MIT",
6
+ "author": "Suman Bonakurthi",
7
+ "homepage": "https://github.com/myviliha/vui-starter/tree/main/packages/ui",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/myviliha/vui-starter.git",
11
+ "directory": "packages/ui"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/myviliha/vui-starter/issues"
15
+ },
16
+ "keywords": [
17
+ "react",
18
+ "tailwind",
19
+ "tailwindcss",
20
+ "shadcn",
21
+ "radix",
22
+ "admin",
23
+ "dashboard",
24
+ "crm",
25
+ "ui",
26
+ "components",
27
+ "design-system"
28
+ ],
29
+ "sideEffects": [
30
+ "**/*.css"
31
+ ],
32
+ "files": [
33
+ "src",
34
+ "!src/**/*.test.ts",
35
+ "!src/**/*.test.tsx"
36
+ ],
37
+ "exports": {
38
+ "./theme.css": "./src/theme.css",
39
+ "./utils": "./src/utils.ts",
40
+ "./*": "./src/*.tsx"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "peerDependencies": {
46
+ "react": "^18 || ^19",
47
+ "react-dom": "^18 || ^19"
48
+ },
49
+ "dependencies": {
50
+ "@radix-ui/react-icons": "^1.3.2",
51
+ "clsx": "^2.1.1",
52
+ "recharts": "^3.9.2",
53
+ "tailwind-merge": "^3.6.0"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^22.15.3",
57
+ "@types/react": "19.2.2",
58
+ "@types/react-dom": "19.2.2",
59
+ "eslint": "^9.39.1",
60
+ "react": "^19.2.0",
61
+ "react-dom": "^19.2.0",
62
+ "typescript": "5.9.2",
63
+ "vitest": "^4.1.10",
64
+ "@repo/eslint-config": "0.0.0",
65
+ "@repo/typescript-config": "0.0.0"
66
+ },
67
+ "scripts": {
68
+ "lint": "eslint . --max-warnings 0",
69
+ "generate:component": "turbo gen react-component",
70
+ "check-types": "tsc --noEmit",
71
+ "test": "vitest run"
72
+ }
73
+ }
package/src/avatar.tsx ADDED
@@ -0,0 +1,33 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "./utils";
4
+
5
+ export const Avatar = React.forwardRef<
6
+ HTMLDivElement,
7
+ React.HTMLAttributes<HTMLDivElement>
8
+ >(({ className, ...props }, ref) => (
9
+ <div
10
+ ref={ref}
11
+ className={cn(
12
+ "relative flex size-8 shrink-0 select-none items-center justify-center overflow-hidden rounded-md",
13
+ className,
14
+ )}
15
+ {...props}
16
+ />
17
+ ));
18
+ Avatar.displayName = "Avatar";
19
+
20
+ export const AvatarFallback = React.forwardRef<
21
+ HTMLDivElement,
22
+ React.HTMLAttributes<HTMLDivElement>
23
+ >(({ className, ...props }, ref) => (
24
+ <div
25
+ ref={ref}
26
+ className={cn(
27
+ "flex size-full items-center justify-center bg-muted font-medium text-muted-foreground",
28
+ className,
29
+ )}
30
+ {...props}
31
+ />
32
+ ));
33
+ AvatarFallback.displayName = "AvatarFallback";
package/src/badge.tsx ADDED
@@ -0,0 +1,42 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "./utils";
4
+
5
+ type BadgeVariant =
6
+ | "default"
7
+ | "secondary"
8
+ | "outline"
9
+ | "muted"
10
+ | "success"
11
+ | "warning"
12
+ | "destructive";
13
+
14
+ const VARIANTS: Record<BadgeVariant, string> = {
15
+ default: "border-transparent bg-primary text-primary-foreground",
16
+ secondary: "border-transparent bg-secondary text-secondary-foreground",
17
+ outline: "border-border text-foreground",
18
+ muted: "border-transparent bg-muted text-muted-foreground",
19
+ success:
20
+ "border-transparent bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400",
21
+ warning:
22
+ "border-transparent bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
23
+ destructive:
24
+ "border-transparent bg-destructive/10 text-destructive dark:bg-destructive/20",
25
+ };
26
+
27
+ export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
28
+ variant?: BadgeVariant;
29
+ }
30
+
31
+ export function Badge({ className, variant = "default", ...props }: BadgeProps) {
32
+ return (
33
+ <span
34
+ className={cn(
35
+ "inline-flex items-center gap-1 rounded-md border px-2 py-0.5 font-normal",
36
+ VARIANTS[variant],
37
+ className,
38
+ )}
39
+ {...props}
40
+ />
41
+ );
42
+ }
package/src/button.tsx ADDED
@@ -0,0 +1,70 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "./utils";
4
+
5
+ type ButtonVariant =
6
+ | "default"
7
+ | "primary"
8
+ | "secondary"
9
+ | "outline"
10
+ | "ghost"
11
+ | "link"
12
+ | "destructive";
13
+ type ButtonSize = "default" | "sm" | "lg" | "icon";
14
+
15
+ const BASE =
16
+ "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0";
17
+
18
+ // Standard button: white background + border; on hover only the icon activates
19
+ // (muted → foreground). Shared by default/secondary/outline for one consistent look.
20
+ const NEUTRAL =
21
+ "border border-border bg-background text-foreground [&_svg]:text-muted-foreground hover:[&_svg]:text-foreground";
22
+
23
+ const VARIANTS: Record<ButtonVariant, string> = {
24
+ default: NEUTRAL,
25
+ primary:
26
+ "border border-transparent bg-[var(--button-primary)] text-[var(--button-primary-foreground)] shadow-[var(--button-shadow)] hover:bg-[var(--button-primary-hover)] [&_svg]:border-white/30",
27
+ secondary: NEUTRAL,
28
+ outline: NEUTRAL,
29
+ ghost: "hover:bg-accent hover:text-accent-foreground",
30
+ link: "text-primary underline-offset-4 hover:underline",
31
+ destructive:
32
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
33
+ };
34
+
35
+ const SIZES: Record<ButtonSize, string> = {
36
+ default: "h-8 px-3",
37
+ sm: "h-7 rounded-sm px-2.5",
38
+ lg: "h-9 px-4",
39
+ icon: "size-8",
40
+ };
41
+
42
+ export function buttonVariants({
43
+ variant = "default",
44
+ size = "default",
45
+ className,
46
+ }: {
47
+ variant?: ButtonVariant;
48
+ size?: ButtonSize;
49
+ className?: string;
50
+ } = {}): string {
51
+ return cn(BASE, VARIANTS[variant], SIZES[size], className);
52
+ }
53
+
54
+ export interface ButtonProps
55
+ extends React.ButtonHTMLAttributes<HTMLButtonElement> {
56
+ variant?: ButtonVariant;
57
+ size?: ButtonSize;
58
+ }
59
+
60
+ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
61
+ ({ className, variant, size, type = "button", ...props }, ref) => (
62
+ <button
63
+ ref={ref}
64
+ type={type}
65
+ className={buttonVariants({ variant, size, className })}
66
+ {...props}
67
+ />
68
+ ),
69
+ );
70
+ Button.displayName = "Button";
package/src/card.tsx ADDED
@@ -0,0 +1,77 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "./utils";
4
+
5
+ export const Card = React.forwardRef<
6
+ HTMLDivElement,
7
+ React.HTMLAttributes<HTMLDivElement>
8
+ >(({ className, ...props }, ref) => (
9
+ <div
10
+ ref={ref}
11
+ className={cn(
12
+ "rounded-lg border border-border bg-card text-card-foreground",
13
+ className,
14
+ )}
15
+ {...props}
16
+ />
17
+ ));
18
+ Card.displayName = "Card";
19
+
20
+ export const CardHeader = React.forwardRef<
21
+ HTMLDivElement,
22
+ React.HTMLAttributes<HTMLDivElement>
23
+ >(({ className, ...props }, ref) => (
24
+ <div
25
+ ref={ref}
26
+ className={cn("flex flex-col gap-1 px-5 pb-3 pt-5", className)}
27
+ {...props}
28
+ />
29
+ ));
30
+ CardHeader.displayName = "CardHeader";
31
+
32
+ export const CardTitle = React.forwardRef<
33
+ HTMLHeadingElement,
34
+ React.HTMLAttributes<HTMLHeadingElement>
35
+ >(({ className, ...props }, ref) => (
36
+ <h3
37
+ ref={ref}
38
+ className={cn(
39
+ "font-semibold leading-none tracking-tight",
40
+ className,
41
+ )}
42
+ {...props}
43
+ />
44
+ ));
45
+ CardTitle.displayName = "CardTitle";
46
+
47
+ export const CardDescription = React.forwardRef<
48
+ HTMLParagraphElement,
49
+ React.HTMLAttributes<HTMLParagraphElement>
50
+ >(({ className, ...props }, ref) => (
51
+ <p
52
+ ref={ref}
53
+ className={cn("text-muted-foreground", className)}
54
+ {...props}
55
+ />
56
+ ));
57
+ CardDescription.displayName = "CardDescription";
58
+
59
+ export const CardContent = React.forwardRef<
60
+ HTMLDivElement,
61
+ React.HTMLAttributes<HTMLDivElement>
62
+ >(({ className, ...props }, ref) => (
63
+ <div ref={ref} className={cn("px-5 pb-5", className)} {...props} />
64
+ ));
65
+ CardContent.displayName = "CardContent";
66
+
67
+ export const CardFooter = React.forwardRef<
68
+ HTMLDivElement,
69
+ React.HTMLAttributes<HTMLDivElement>
70
+ >(({ className, ...props }, ref) => (
71
+ <div
72
+ ref={ref}
73
+ className={cn("flex items-center px-5 pb-5", className)}
74
+ {...props}
75
+ />
76
+ ));
77
+ CardFooter.displayName = "CardFooter";
package/src/chart.tsx ADDED
@@ -0,0 +1,144 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { ResponsiveContainer, Tooltip, Legend } from "recharts";
5
+
6
+ import { cn } from "./utils";
7
+
8
+ /**
9
+ * Themed Recharts wrapper for the Vui design system.
10
+ *
11
+ * ponytail: a compact token-driven wrapper, not the full shadcn chart.tsx —
12
+ * covers container + tooltip + legend for area/bar/line/pie demos. Swap in the
13
+ * upstream shadcn `chart` component if you need indicator variants / nameKey.
14
+ *
15
+ * Each `config` entry maps a data key to a label + color. The container exposes
16
+ * every color as a `--color-<key>` CSS variable so series can reference
17
+ * `stroke="var(--color-revenue)"` and stay in sync with the theme tokens
18
+ * (`--chart-1 … --chart-5`).
19
+ */
20
+ export type ChartConfig = Record<string, { label: string; color: string }>;
21
+
22
+ const ChartConfigContext = React.createContext<ChartConfig>({});
23
+
24
+ export function ChartContainer({
25
+ config,
26
+ className,
27
+ children,
28
+ }: {
29
+ config: ChartConfig;
30
+ className?: string;
31
+ /** A single Recharts chart element (AreaChart, BarChart, PieChart, …). */
32
+ children: React.ReactElement;
33
+ }) {
34
+ const cssVars = Object.fromEntries(
35
+ Object.entries(config).map(([key, v]) => [`--color-${key}`, v.color]),
36
+ ) as React.CSSProperties;
37
+
38
+ return (
39
+ <ChartConfigContext.Provider value={config}>
40
+ <div
41
+ style={cssVars}
42
+ className={cn(
43
+ "aspect-video w-full text-xs [&_.recharts-cartesian-grid_line]:stroke-border/60 [&_.recharts-text]:fill-muted-foreground",
44
+ className,
45
+ )}
46
+ >
47
+ <ResponsiveContainer width="100%" height="100%">
48
+ {children}
49
+ </ResponsiveContainer>
50
+ </div>
51
+ </ChartConfigContext.Provider>
52
+ );
53
+ }
54
+
55
+ type TooltipEntry = {
56
+ name?: string;
57
+ value?: number | string;
58
+ color?: string;
59
+ dataKey?: string | number;
60
+ };
61
+
62
+ /** Styled tooltip. Pass as `content={<ChartTooltipContent />}` to a `<Tooltip>`. */
63
+ export function ChartTooltipContent({
64
+ active,
65
+ payload,
66
+ label,
67
+ }: {
68
+ active?: boolean;
69
+ payload?: TooltipEntry[];
70
+ label?: string | number;
71
+ }) {
72
+ const config = React.useContext(ChartConfigContext);
73
+ if (!active || !payload?.length) return null;
74
+
75
+ return (
76
+ <div className="min-w-32 rounded-md border border-border bg-popover px-2.5 py-2 text-popover-foreground shadow-md">
77
+ {label != null && (
78
+ <p className="mb-1.5 font-medium">{String(label)}</p>
79
+ )}
80
+ <div className="space-y-1">
81
+ {payload.map((item, i) => {
82
+ // Recharts fills `name` with the series `name` (defaults to dataKey)
83
+ // for cartesian charts, and the slice's nameKey for pies.
84
+ const key = String(item.name ?? item.dataKey ?? i);
85
+ const conf = config[key];
86
+ return (
87
+ <div
88
+ key={key}
89
+ className="flex items-center justify-between gap-4 text-muted-foreground"
90
+ >
91
+ <span className="flex items-center gap-1.5">
92
+ <span
93
+ className="size-2.5 shrink-0 rounded-[3px]"
94
+ style={{ background: item.color ?? conf?.color }}
95
+ />
96
+ {conf?.label ?? item.name ?? key}
97
+ </span>
98
+ <span className="font-mono font-medium text-foreground">
99
+ {typeof item.value === "number"
100
+ ? item.value.toLocaleString()
101
+ : item.value}
102
+ </span>
103
+ </div>
104
+ );
105
+ })}
106
+ </div>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ type LegendEntry = { value?: string; color?: string; dataKey?: string | number };
112
+
113
+ /** Styled legend. Pass as `content={<ChartLegendContent />}` to a `<Legend>`. */
114
+ export function ChartLegendContent({ payload }: { payload?: LegendEntry[] }) {
115
+ const config = React.useContext(ChartConfigContext);
116
+ if (!payload?.length) return null;
117
+
118
+ return (
119
+ <div className="flex flex-wrap items-center justify-center gap-4 pt-3">
120
+ {payload.map((item, i) => {
121
+ // Legend payload uses `value` for the label (series name / pie slice
122
+ // name); a pie gives every entry the same `dataKey` ("value"), so key
123
+ // off `value` (with index as a guaranteed-unique fallback).
124
+ const key = String(item.value ?? item.dataKey ?? i);
125
+ const conf = config[key];
126
+ return (
127
+ <span
128
+ key={`${key}-${i}`}
129
+ className="flex items-center gap-1.5 text-muted-foreground"
130
+ >
131
+ <span
132
+ className="size-2.5 shrink-0 rounded-[3px]"
133
+ style={{ background: item.color ?? conf?.color }}
134
+ />
135
+ {conf?.label ?? item.value ?? key}
136
+ </span>
137
+ );
138
+ })}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ /** Re-export the raw Recharts `Tooltip`/`Legend` so consumers get them from one place. */
144
+ export { Tooltip as ChartTooltip, Legend as ChartLegend };
@@ -0,0 +1,20 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "./utils";
4
+
5
+ export const Checkbox = React.forwardRef<
6
+ HTMLInputElement,
7
+ React.ComponentProps<"input">
8
+ >(({ className, ...props }, ref) => (
9
+ <input
10
+ ref={ref}
11
+ type="checkbox"
12
+ className={cn(
13
+ "size-4 shrink-0 cursor-pointer rounded border-input accent-[var(--button-primary)]",
14
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
15
+ className,
16
+ )}
17
+ {...props}
18
+ />
19
+ ));
20
+ Checkbox.displayName = "Checkbox";
package/src/code.tsx ADDED
@@ -0,0 +1,11 @@
1
+ import { type JSX } from "react";
2
+
3
+ export function Code({
4
+ children,
5
+ className,
6
+ }: {
7
+ children: React.ReactNode;
8
+ className?: string;
9
+ }): JSX.Element {
10
+ return <code className={className}>{children}</code>;
11
+ }
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ import { cn } from "./utils";
4
+ import { Button } from "./button";
5
+ import {
6
+ Dialog,
7
+ DialogBody,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from "./dialog";
12
+
13
+ export interface ConfirmDialogProps {
14
+ open: boolean;
15
+ title: string;
16
+ description?: React.ReactNode;
17
+ confirmLabel?: string;
18
+ cancelLabel?: string;
19
+ /** Style the confirm button as a destructive (red) action. */
20
+ destructive?: boolean;
21
+ onConfirm: () => void;
22
+ onCancel: () => void;
23
+ }
24
+
25
+ /** Centered modal confirmation (e.g. before deleting). Backdrop dims the page;
26
+ Escape / backdrop click / Cancel dismiss it. */
27
+ export function ConfirmDialog({
28
+ open,
29
+ title,
30
+ description,
31
+ confirmLabel = "Confirm",
32
+ cancelLabel = "Cancel",
33
+ destructive,
34
+ onConfirm,
35
+ onCancel,
36
+ }: ConfirmDialogProps) {
37
+ // Built on the shared Dialog primitive so it stays in lockstep with the
38
+ // header / body / footer standard.
39
+ return (
40
+ <Dialog open={open} onClose={onCancel} label={title} className="max-w-sm">
41
+ <DialogHeader>
42
+ <DialogTitle>{title}</DialogTitle>
43
+ </DialogHeader>
44
+ {description && (
45
+ <DialogBody className="text-muted-foreground">{description}</DialogBody>
46
+ )}
47
+ <DialogFooter>
48
+ <Button onClick={onCancel}>{cancelLabel}</Button>
49
+ <Button
50
+ variant={destructive ? "destructive" : "primary"}
51
+ onClick={onConfirm}
52
+ className={cn(destructive && "[&_svg]:border-white/30")}
53
+ >
54
+ {confirmLabel}
55
+ </Button>
56
+ </DialogFooter>
57
+ </Dialog>
58
+ );
59
+ }
package/src/dialog.tsx ADDED
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { cn } from "./utils";
6
+
7
+ /**
8
+ * Sectioned modal dialog — the theme standard. It provides the shell (centered
9
+ * panel, dimmed backdrop, entrance animation, Escape / backdrop-click to close)
10
+ * and three bordered placeholders — `DialogHeader`, `DialogBody`, `DialogFooter`
11
+ * — so a new dialog only needs to pass content into each. See `ConfirmDialog`
12
+ * for a ready-made example built on top of these.
13
+ *
14
+ * <Dialog open={open} onClose={close} label="Invite">
15
+ * <DialogHeader><DialogTitle>Invite teammate</DialogTitle></DialogHeader>
16
+ * <DialogBody>…form…</DialogBody>
17
+ * <DialogFooter><Button onClick={close}>Cancel</Button>…</DialogFooter>
18
+ * </Dialog>
19
+ */
20
+ export function Dialog({
21
+ open,
22
+ onClose,
23
+ label,
24
+ className,
25
+ children,
26
+ /** Allow Escape / backdrop click to dismiss (default true). */
27
+ dismissible = true,
28
+ }: {
29
+ open: boolean;
30
+ onClose: () => void;
31
+ /** Accessible name for the dialog (aria-label). */
32
+ label?: string;
33
+ className?: string;
34
+ children: React.ReactNode;
35
+ dismissible?: boolean;
36
+ }) {
37
+ React.useEffect(() => {
38
+ if (!open || !dismissible) return;
39
+ const onKey = (e: KeyboardEvent) => {
40
+ if (e.key === "Escape") onClose();
41
+ };
42
+ document.addEventListener("keydown", onKey);
43
+ return () => document.removeEventListener("keydown", onKey);
44
+ }, [open, dismissible, onClose]);
45
+
46
+ if (!open) return null;
47
+
48
+ return (
49
+ <div
50
+ className="vui-overlay-in fixed inset-0 z-[70] flex items-center justify-center bg-foreground/25 p-4"
51
+ onClick={dismissible ? onClose : undefined}
52
+ >
53
+ <div
54
+ role="dialog"
55
+ aria-modal="true"
56
+ aria-label={label}
57
+ onClick={(e) => e.stopPropagation()}
58
+ style={{ ["--vui-pop-origin" as string]: "center" }}
59
+ className={cn(
60
+ "vui-pop-in w-full max-w-md overflow-hidden rounded-lg border border-border bg-background shadow-xl",
61
+ className,
62
+ )}
63
+ >
64
+ {children}
65
+ </div>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ /** Bordered header section. */
71
+ export function DialogHeader({
72
+ className,
73
+ children,
74
+ }: {
75
+ className?: string;
76
+ children: React.ReactNode;
77
+ }) {
78
+ return (
79
+ <div className={cn("border-b border-border px-5 py-3", className)}>
80
+ {children}
81
+ </div>
82
+ );
83
+ }
84
+
85
+ export function DialogTitle({
86
+ className,
87
+ children,
88
+ }: {
89
+ className?: string;
90
+ children: React.ReactNode;
91
+ }) {
92
+ return (
93
+ <h2 className={cn("text-base font-semibold tracking-tight", className)}>
94
+ {children}
95
+ </h2>
96
+ );
97
+ }
98
+
99
+ /** Scrollable content section (capped so long content scrolls, not the page). */
100
+ export function DialogBody({
101
+ className,
102
+ children,
103
+ }: {
104
+ className?: string;
105
+ children: React.ReactNode;
106
+ }) {
107
+ return (
108
+ <div
109
+ className={cn(
110
+ "max-h-[70vh] overflow-y-auto px-5 py-4 text-sm leading-relaxed",
111
+ className,
112
+ )}
113
+ >
114
+ {children}
115
+ </div>
116
+ );
117
+ }
118
+
119
+ /** Bordered footer section — right-aligned actions by default. */
120
+ export function DialogFooter({
121
+ className,
122
+ children,
123
+ }: {
124
+ className?: string;
125
+ children: React.ReactNode;
126
+ }) {
127
+ return (
128
+ <div
129
+ className={cn(
130
+ "flex items-center justify-end gap-2 border-t border-border px-5 py-3",
131
+ className,
132
+ )}
133
+ >
134
+ {children}
135
+ </div>
136
+ );
137
+ }