@mnee-ui/ui 0.0.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 +92 -0
- package/components/ui/alert.tsx +84 -0
- package/components/ui/badge.tsx +38 -0
- package/components/ui/banner.tsx +73 -0
- package/components/ui/button.tsx +59 -0
- package/components/ui/card.tsx +156 -0
- package/components/ui/code-block.tsx +108 -0
- package/components/ui/drawer.tsx +164 -0
- package/components/ui/icons.tsx +96 -0
- package/components/ui/index.ts +14 -0
- package/components/ui/input.tsx +129 -0
- package/components/ui/mnee-ui.css +35 -0
- package/components/ui/modal.tsx +134 -0
- package/components/ui/table.tsx +185 -0
- package/components/ui/toast.tsx +136 -0
- package/package.json +49 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useRef } from "react";
|
|
4
|
+
import { X } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
export type DrawerWidth = "sm" | "md" | "lg" | "xl";
|
|
8
|
+
export type DrawerSide = "left" | "right";
|
|
9
|
+
|
|
10
|
+
export interface DrawerProps {
|
|
11
|
+
isOpen: boolean;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
title?: string;
|
|
14
|
+
children?: React.ReactNode;
|
|
15
|
+
width?: DrawerWidth;
|
|
16
|
+
/** Which edge the drawer slides in from */
|
|
17
|
+
side?: DrawerSide;
|
|
18
|
+
/** Footer content — rendered in a sticky bar at the bottom */
|
|
19
|
+
footer?: React.ReactNode;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const widthStyles: Record<DrawerWidth, string> = {
|
|
24
|
+
sm: "w-80",
|
|
25
|
+
md: "w-[480px]",
|
|
26
|
+
lg: "w-[600px]",
|
|
27
|
+
xl: "w-[800px]",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const sideStyles: Record<DrawerSide, { position: string; translate: string }> = {
|
|
31
|
+
right: { position: "right-0", translate: "translate-x-full" },
|
|
32
|
+
left: { position: "left-0", translate: "-translate-x-full" },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function Drawer({
|
|
36
|
+
isOpen, onClose, title, children, footer,
|
|
37
|
+
width = "md", side = "right", className,
|
|
38
|
+
}: DrawerProps) {
|
|
39
|
+
const headingId = useId();
|
|
40
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
41
|
+
const previousFocusRef = useRef<HTMLElement | null>(null);
|
|
42
|
+
|
|
43
|
+
// Close on Escape key
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!isOpen) return;
|
|
46
|
+
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
47
|
+
document.addEventListener("keydown", handler);
|
|
48
|
+
return () => document.removeEventListener("keydown", handler);
|
|
49
|
+
}, [isOpen, onClose]);
|
|
50
|
+
|
|
51
|
+
// Capture focus before open; move focus into panel on open; restore on close
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (isOpen) {
|
|
54
|
+
previousFocusRef.current = document.activeElement as HTMLElement;
|
|
55
|
+
requestAnimationFrame(() => panelRef.current?.focus());
|
|
56
|
+
} else {
|
|
57
|
+
previousFocusRef.current?.focus();
|
|
58
|
+
}
|
|
59
|
+
}, [isOpen]);
|
|
60
|
+
|
|
61
|
+
// Body scroll lock with scrollbar-width compensation to prevent layout shift
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (isOpen) {
|
|
64
|
+
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
|
65
|
+
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
|
66
|
+
document.body.style.overflow = "hidden";
|
|
67
|
+
} else {
|
|
68
|
+
document.body.style.overflow = "";
|
|
69
|
+
document.body.style.paddingRight = "";
|
|
70
|
+
}
|
|
71
|
+
return () => {
|
|
72
|
+
document.body.style.overflow = "";
|
|
73
|
+
document.body.style.paddingRight = "";
|
|
74
|
+
};
|
|
75
|
+
}, [isOpen]);
|
|
76
|
+
|
|
77
|
+
const { position, translate } = sideStyles[side];
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
{/* Backdrop */}
|
|
82
|
+
<div
|
|
83
|
+
aria-hidden="true"
|
|
84
|
+
onClick={onClose}
|
|
85
|
+
className={cn(
|
|
86
|
+
"fixed inset-0 z-40 bg-black/50 transition-opacity duration-300",
|
|
87
|
+
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
|
88
|
+
)}
|
|
89
|
+
/>
|
|
90
|
+
|
|
91
|
+
{/* Panel */}
|
|
92
|
+
<div
|
|
93
|
+
ref={panelRef}
|
|
94
|
+
role="dialog"
|
|
95
|
+
aria-modal="true"
|
|
96
|
+
aria-labelledby={title ? headingId : undefined}
|
|
97
|
+
tabIndex={-1}
|
|
98
|
+
className={cn(
|
|
99
|
+
"fixed top-0 z-50 h-full bg-white shadow-2xl flex flex-col outline-none",
|
|
100
|
+
"transition-transform duration-300 ease-in-out",
|
|
101
|
+
position,
|
|
102
|
+
widthStyles[width],
|
|
103
|
+
isOpen ? "translate-x-0" : translate,
|
|
104
|
+
className
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
{/* Header */}
|
|
108
|
+
<DrawerHeader>
|
|
109
|
+
{title && (
|
|
110
|
+
<h2 id={headingId} className="text-lg font-semibold text-gray-900 flex-1">{title}</h2>
|
|
111
|
+
)}
|
|
112
|
+
<button
|
|
113
|
+
onClick={onClose}
|
|
114
|
+
aria-label="Close drawer"
|
|
115
|
+
className="text-gray-400 hover:text-gray-600 transition-colors ml-auto"
|
|
116
|
+
>
|
|
117
|
+
<X size={20} />
|
|
118
|
+
</button>
|
|
119
|
+
</DrawerHeader>
|
|
120
|
+
|
|
121
|
+
{/* Body */}
|
|
122
|
+
<DrawerBody>{children}</DrawerBody>
|
|
123
|
+
|
|
124
|
+
{/* Footer */}
|
|
125
|
+
{footer && <DrawerFooter>{footer}</DrawerFooter>}
|
|
126
|
+
</div>
|
|
127
|
+
</>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Sub-components (also usable standalone for custom drawer layouts) ─────────
|
|
132
|
+
|
|
133
|
+
export function DrawerHeader({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
134
|
+
return (
|
|
135
|
+
<div
|
|
136
|
+
className={cn("flex items-center gap-3 px-6 py-4 border-b border-gray-200 shrink-0", className)}
|
|
137
|
+
{...props}
|
|
138
|
+
>
|
|
139
|
+
{children}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function DrawerBody({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
className={cn("flex-1 overflow-y-auto px-6 py-5", className)}
|
|
148
|
+
{...props}
|
|
149
|
+
>
|
|
150
|
+
{children}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function DrawerFooter({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
156
|
+
return (
|
|
157
|
+
<div
|
|
158
|
+
className={cn("shrink-0 px-6 py-4 border-t border-gray-200 bg-white", className)}
|
|
159
|
+
{...props}
|
|
160
|
+
>
|
|
161
|
+
{children}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type LucideIcon, type LucideProps } from "lucide-react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import type { SVGProps } from "react";
|
|
4
|
+
|
|
5
|
+
// ─── Icon wrapper ──────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export type IconSize = "xs" | "sm" | "md" | "lg" | "xl";
|
|
8
|
+
|
|
9
|
+
const sizeMap: Record<IconSize, number> = {
|
|
10
|
+
xs: 12,
|
|
11
|
+
sm: 14,
|
|
12
|
+
md: 16,
|
|
13
|
+
lg: 20,
|
|
14
|
+
xl: 24,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export interface IconProps extends Omit<LucideProps, "size"> {
|
|
18
|
+
/** Any Lucide icon component, e.g. import { Wallet } from "lucide-react" */
|
|
19
|
+
icon: LucideIcon;
|
|
20
|
+
/** Design-system size token. Defaults to "md" (16px). */
|
|
21
|
+
size?: IconSize;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function Icon({ icon: LucideComponent, size = "md", className, ...props }: IconProps) {
|
|
25
|
+
return (
|
|
26
|
+
<LucideComponent
|
|
27
|
+
size={sizeMap[size]}
|
|
28
|
+
className={cn("shrink-0", className)}
|
|
29
|
+
{...props}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Brand icon ───────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export function MneeIcon(props: SVGProps<SVGSVGElement>) {
|
|
37
|
+
return (
|
|
38
|
+
<svg
|
|
39
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
40
|
+
width="1em"
|
|
41
|
+
height="1em"
|
|
42
|
+
fill="none"
|
|
43
|
+
viewBox="0 0 797 797"
|
|
44
|
+
{...props}
|
|
45
|
+
>
|
|
46
|
+
<g filter="url(#MneeIcon_a)">
|
|
47
|
+
<path
|
|
48
|
+
fill="url(#MneeIcon_b)"
|
|
49
|
+
d="M148.438 398.438c0-138.072 111.928-250 250-250 138.071 0 250 111.928 250 250 0 138.071-111.929 250-250 250-138.072 0-250-111.929-250-250"
|
|
50
|
+
shapeRendering="crispEdges"
|
|
51
|
+
/>
|
|
52
|
+
<path
|
|
53
|
+
fill="#05121F"
|
|
54
|
+
d="M148.438 398.438c0-138.072 111.928-250 250-250 138.071 0 250 111.928 250 250 0 138.071-111.929 250-250 250-138.072 0-250-111.929-250-250"
|
|
55
|
+
shapeRendering="crispEdges"
|
|
56
|
+
/>
|
|
57
|
+
<path
|
|
58
|
+
fill="url(#MneeIcon_c)"
|
|
59
|
+
d="M164.062 398.438c0-129.442 104.934-234.376 234.376-234.376 129.441 0 234.374 104.934 234.374 234.376 0 129.441-104.933 234.374-234.374 234.374-129.442 0-234.376-104.933-234.376-234.374"
|
|
60
|
+
/>
|
|
61
|
+
<path
|
|
62
|
+
fill="url(#MneeIcon_d)"
|
|
63
|
+
fillRule="evenodd"
|
|
64
|
+
d="m482.765 413.641-.381-88.048a17.06 17.06 0 0 0-5.028-12.031c-3.206-3.205-7.505-5.027-12.043-5.027-4.539 0-8.838 1.822-12.043 5.027s-5.029 7.5-5.029 12.038v145.674c0 13.255-5.267 25.861-14.637 35.227-9.371 9.369-21.982 14.634-35.237 14.634-13.257 0-25.87-5.265-35.238-14.634-9.371-9.366-14.638-21.972-14.638-35.227V325.6c0-4.538-1.823-8.834-5.028-12.038-3.206-3.205-7.505-5.027-12.043-5.027-4.539 0-8.838 1.822-12.043 5.027a17.06 17.06 0 0 0-5.026 12.031l-.383 88.048c-1.37 11.023-6.398 21.223-14.255 29.082-9.37 9.367-21.982 14.634-35.24 14.634-13.256 0-25.869-5.267-35.237-14.634-7.86-7.859-12.888-18.064-14.258-29.09l-.405-24.216-45.026-.008h-21.109l.168-3.435c3.049-62.067 28.993-120.38 72.935-164.313 47.048-47.036 110.287-73.223 176.823-73.223 66.538 0 129.774 26.187 176.822 73.223 30.786 30.78 53.107 69.091 64.523 111.106l1.121 4.13h-34.039l-.709-2.312c-10.404-33.867-29.041-64.69-54.092-89.735-40.88-40.868-95.814-63.618-153.627-63.618s-112.749 22.75-153.63 63.618c-30.057 30.051-50.697 68.188-59.218 109.832l-.397 1.94h62.284v50.872c0 4.536 1.823 8.836 5.028 12.04s7.505 5.028 12.043 5.028 8.838-1.824 12.043-5.028 5.029-7.501 5.029-12.04v-81.893c0-13.252 5.267-25.861 14.637-35.227 9.368-9.369 21.981-14.635 35.238-14.635 13.258 0 25.866 5.266 35.237 14.635 9.371 9.365 14.638 21.975 14.638 35.227v145.674c0 4.538 1.823 8.836 5.028 12.041s7.505 5.024 12.044 5.024c4.537 0 8.837-1.82 12.042-5.024 3.205-3.205 5.027-7.503 5.027-12.041V325.601c0-13.252 5.267-25.861 14.638-35.227 9.369-9.369 21.981-14.635 35.239-14.635s25.868 5.266 35.237 14.635c9.371 9.365 14.638 21.975 14.638 35.227v81.893c0 4.536 1.823 8.836 5.027 12.04s7.504 5.028 12.042 5.028 8.838-1.824 12.044-5.028c3.205-3.204 5.028-7.501 5.028-12.04v-50.872h62.279l-.004-.025h33.355l.435 2.765a252 252 0 0 1 3.044 39.074c0 66.52-26.198 129.742-73.245 176.778-47.049 47.036-110.286 73.224-176.822 73.224-66.538 0-129.776-26.188-176.824-73.224-43.53-43.519-69.423-101.186-72.845-162.651l-.193-3.457h32.838l.192 3.071c3.302 52.872 25.745 102.398 63.205 139.849 40.878 40.87 95.815 63.616 153.628 63.616s112.747-22.746 153.627-63.616c40.881-40.869 63.635-95.789 63.635-153.587q.001-3.73-.125-7.458l-.053-1.561h-33.291l-.408 24.213c-1.37 11.026-6.398 21.231-14.258 29.09-9.37 9.367-21.981 14.634-35.239 14.634-13.256 0-25.87-5.267-35.238-14.634-7.857-7.856-12.887-18.056-14.257-29.082"
|
|
65
|
+
clipRule="evenodd"
|
|
66
|
+
/>
|
|
67
|
+
</g>
|
|
68
|
+
<defs>
|
|
69
|
+
<linearGradient id="MneeIcon_b" x1={148.438} x2={648.438} y1={148.438} y2={648.438} gradientUnits="userSpaceOnUse">
|
|
70
|
+
<stop stopColor="#FF6900" />
|
|
71
|
+
<stop offset={0.5} stopColor="#FE9A00" />
|
|
72
|
+
<stop offset={1} stopColor="#FDC700" />
|
|
73
|
+
</linearGradient>
|
|
74
|
+
<linearGradient id="MneeIcon_c" x1={450.422} x2={558.798} y1={592.492} y2={90.982} gradientUnits="userSpaceOnUse">
|
|
75
|
+
<stop stopColor="#05121F" />
|
|
76
|
+
<stop offset={1} stopColor="#05121F" />
|
|
77
|
+
</linearGradient>
|
|
78
|
+
<linearGradient id="MneeIcon_d" x1={253.85} x2={537.261} y1={576.276} y2={231.407} gradientUnits="userSpaceOnUse">
|
|
79
|
+
<stop stopColor="#E88C1F" />
|
|
80
|
+
<stop offset={1} stopColor="#FFDC46" />
|
|
81
|
+
</linearGradient>
|
|
82
|
+
<filter id="MneeIcon_a" width={796.875} height={796.875} x={0} y={0} colorInterpolationFilters="sRGB" filterUnits="userSpaceOnUse">
|
|
83
|
+
<feFlood floodOpacity={0} result="BackgroundImageFix" />
|
|
84
|
+
<feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
|
|
85
|
+
<feMorphology in="SourceAlpha" radius={46.875} result="effect1_dropShadow_3676_2343" />
|
|
86
|
+
<feOffset />
|
|
87
|
+
<feGaussianBlur stdDeviation={97.656} />
|
|
88
|
+
<feComposite in2="hardAlpha" operator="out" />
|
|
89
|
+
<feColorMatrix values="0 0 0 0 1 0 0 0 0 0.410735 0 0 0 0 0 0 0 0 0.3 0" />
|
|
90
|
+
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow_3676_2343" />
|
|
91
|
+
<feBlend in="SourceGraphic" in2="effect1_dropShadow_3676_2343" result="shape" />
|
|
92
|
+
</filter>
|
|
93
|
+
</defs>
|
|
94
|
+
</svg>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from "./button";
|
|
2
|
+
export * from "./badge";
|
|
3
|
+
export { Card, CardContainer } from "./card";
|
|
4
|
+
export type { CardProps, CardContainerProps } from "./card";
|
|
5
|
+
export * from "./input";
|
|
6
|
+
export * from "./toast";
|
|
7
|
+
export * from "./icons";
|
|
8
|
+
export * from "./banner";
|
|
9
|
+
export * from "./table";
|
|
10
|
+
export * from "./drawer";
|
|
11
|
+
export * from "./modal";
|
|
12
|
+
export * from "./alert";
|
|
13
|
+
export * from "./code-block";
|
|
14
|
+
export type { LucideIcon, LucideProps } from "lucide-react";
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils";
|
|
2
|
+
|
|
3
|
+
export type InputSize = "sm" | "md" | "lg";
|
|
4
|
+
|
|
5
|
+
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {
|
|
6
|
+
label?: string;
|
|
7
|
+
hint?: string;
|
|
8
|
+
error?: string;
|
|
9
|
+
size?: InputSize;
|
|
10
|
+
prefix?: string;
|
|
11
|
+
suffix?: string;
|
|
12
|
+
leadingIcon?: React.ReactNode;
|
|
13
|
+
trailingIcon?: React.ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const sizeStyles: Record<InputSize, string> = {
|
|
17
|
+
sm: "rounded-md text-xs",
|
|
18
|
+
md: "rounded-lg text-sm",
|
|
19
|
+
lg: "rounded-lg text-base",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const inputPaddingStyles: Record<InputSize, string> = {
|
|
23
|
+
sm: "py-1.5 px-2.5",
|
|
24
|
+
md: "py-2 px-3",
|
|
25
|
+
lg: "py-2.5 px-4",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const addonPaddingStyles: Record<InputSize, string> = {
|
|
29
|
+
sm: "px-2",
|
|
30
|
+
md: "px-2.5",
|
|
31
|
+
lg: "px-3",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const iconSizeStyles: Record<InputSize, string> = {
|
|
35
|
+
sm: "[&>svg]:size-3.5",
|
|
36
|
+
md: "[&>svg]:size-4",
|
|
37
|
+
lg: "[&>svg]:size-5",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function Input({
|
|
41
|
+
label,
|
|
42
|
+
hint,
|
|
43
|
+
error,
|
|
44
|
+
size = "md",
|
|
45
|
+
className,
|
|
46
|
+
id,
|
|
47
|
+
disabled,
|
|
48
|
+
required,
|
|
49
|
+
prefix,
|
|
50
|
+
suffix,
|
|
51
|
+
leadingIcon,
|
|
52
|
+
trailingIcon,
|
|
53
|
+
...props
|
|
54
|
+
}: InputProps) {
|
|
55
|
+
const inputId = id ?? (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined);
|
|
56
|
+
const hasLeading = leadingIcon != null || prefix != null;
|
|
57
|
+
const hasTrailing = trailingIcon != null || suffix != null;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className={cn("w-full flex flex-col gap-1", className)}>
|
|
61
|
+
{label && (
|
|
62
|
+
<label
|
|
63
|
+
htmlFor={inputId}
|
|
64
|
+
className="text-sm font-medium text-gray-900"
|
|
65
|
+
>
|
|
66
|
+
{label}
|
|
67
|
+
{required && (
|
|
68
|
+
<span aria-hidden="true" className="text-error ml-0.5">*</span>
|
|
69
|
+
)}
|
|
70
|
+
</label>
|
|
71
|
+
)}
|
|
72
|
+
<div
|
|
73
|
+
className={cn(
|
|
74
|
+
"flex items-center w-full border bg-white transition-colors",
|
|
75
|
+
"focus-within:ring-2 focus-within:ring-brand/50",
|
|
76
|
+
"has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50",
|
|
77
|
+
error
|
|
78
|
+
? "border-error focus-within:border-error focus-within:ring-error/30"
|
|
79
|
+
: "border-gray-300 focus-within:border-brand",
|
|
80
|
+
sizeStyles[size],
|
|
81
|
+
)}
|
|
82
|
+
>
|
|
83
|
+
{leadingIcon && (
|
|
84
|
+
<span className={cn("flex items-center pointer-events-none text-gray-400", addonPaddingStyles[size], iconSizeStyles[size])}>
|
|
85
|
+
{leadingIcon}
|
|
86
|
+
</span>
|
|
87
|
+
)}
|
|
88
|
+
{prefix && (
|
|
89
|
+
<span className={cn("flex items-center pointer-events-none text-gray-400 select-none", addonPaddingStyles[size])}>
|
|
90
|
+
{prefix}
|
|
91
|
+
</span>
|
|
92
|
+
)}
|
|
93
|
+
<input
|
|
94
|
+
id={inputId}
|
|
95
|
+
disabled={disabled}
|
|
96
|
+
required={required}
|
|
97
|
+
aria-invalid={!!error}
|
|
98
|
+
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
|
|
99
|
+
className={cn(
|
|
100
|
+
"flex-1 min-w-0 bg-transparent outline-none placeholder:text-gray-400",
|
|
101
|
+
inputPaddingStyles[size],
|
|
102
|
+
hasLeading && "pl-1.5",
|
|
103
|
+
hasTrailing && "pr-1.5",
|
|
104
|
+
)}
|
|
105
|
+
{...props}
|
|
106
|
+
/>
|
|
107
|
+
{suffix && (
|
|
108
|
+
<span className={cn("flex items-center pointer-events-none text-gray-400 select-none", addonPaddingStyles[size])}>
|
|
109
|
+
{suffix}
|
|
110
|
+
</span>
|
|
111
|
+
)}
|
|
112
|
+
{trailingIcon && (
|
|
113
|
+
<span className={cn("flex items-center pointer-events-none text-gray-400", addonPaddingStyles[size], iconSizeStyles[size])}>
|
|
114
|
+
{trailingIcon}
|
|
115
|
+
</span>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
{error ? (
|
|
119
|
+
<p id={`${inputId}-error`} className="text-xs text-error">
|
|
120
|
+
{error}
|
|
121
|
+
</p>
|
|
122
|
+
) : hint ? (
|
|
123
|
+
<p id={`${inputId}-hint`} className="text-xs text-muted">
|
|
124
|
+
{hint}
|
|
125
|
+
</p>
|
|
126
|
+
) : null}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
@theme {
|
|
2
|
+
/* Brand — amber */
|
|
3
|
+
--color-brand: #D97706;
|
|
4
|
+
--color-brand-dark: #B45309;
|
|
5
|
+
|
|
6
|
+
/* Success */
|
|
7
|
+
--color-success: #15803D;
|
|
8
|
+
--color-success-bg: #DCFCE7;
|
|
9
|
+
--color-success-fg: #14532D;
|
|
10
|
+
|
|
11
|
+
/* Warning */
|
|
12
|
+
--color-warning: #D97706;
|
|
13
|
+
--color-warning-bg: #FEF3C7;
|
|
14
|
+
--color-warning-fg: #92400E;
|
|
15
|
+
|
|
16
|
+
/* Error */
|
|
17
|
+
--color-error: #B91C1C;
|
|
18
|
+
--color-error-bg: #FEE2E2;
|
|
19
|
+
--color-error-fg: #991B1B;
|
|
20
|
+
|
|
21
|
+
/* Info */
|
|
22
|
+
--color-info: #1D4ED8;
|
|
23
|
+
--color-info-bg: #DBEAFE;
|
|
24
|
+
--color-info-fg: #1E3A8A;
|
|
25
|
+
|
|
26
|
+
/* Surface */
|
|
27
|
+
--color-surface: #ffffff;
|
|
28
|
+
--color-surface-border: #E5E5E5;
|
|
29
|
+
--color-muted: #6B7280;
|
|
30
|
+
--color-muted-bg: #F9FAFB;
|
|
31
|
+
|
|
32
|
+
/* Typography */
|
|
33
|
+
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
|
|
34
|
+
--font-mono: "Geist Mono", ui-monospace, "SFMono-Regular", monospace;
|
|
35
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { X } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
export type ModalSize = "sm" | "md" | "lg";
|
|
8
|
+
|
|
9
|
+
export interface ModalProps {
|
|
10
|
+
isOpen: boolean;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
title?: string;
|
|
13
|
+
children?: React.ReactNode;
|
|
14
|
+
size?: ModalSize;
|
|
15
|
+
/** Footer content — rendered in a sticky bar at the bottom */
|
|
16
|
+
footer?: React.ReactNode;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sizeStyles: Record<ModalSize, string> = {
|
|
21
|
+
sm: "w-[400px]",
|
|
22
|
+
md: "w-[520px]",
|
|
23
|
+
lg: "w-[640px]",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function Modal({ isOpen, onClose, title, children, footer, size = "sm", className }: ModalProps) {
|
|
27
|
+
// Close on Escape key
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!isOpen) return;
|
|
30
|
+
const handler = (e: KeyboardEvent) => {
|
|
31
|
+
if (e.key === "Escape") onClose();
|
|
32
|
+
};
|
|
33
|
+
document.addEventListener("keydown", handler);
|
|
34
|
+
return () => document.removeEventListener("keydown", handler);
|
|
35
|
+
}, [isOpen, onClose]);
|
|
36
|
+
|
|
37
|
+
// Prevent body scroll while open
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
document.body.style.overflow = isOpen ? "hidden" : "";
|
|
40
|
+
return () => { document.body.style.overflow = ""; };
|
|
41
|
+
}, [isOpen]);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
{/* Backdrop */}
|
|
46
|
+
<div
|
|
47
|
+
aria-hidden="true"
|
|
48
|
+
onClick={onClose}
|
|
49
|
+
className={cn(
|
|
50
|
+
"fixed inset-0 z-40 bg-black/50 transition-opacity duration-200",
|
|
51
|
+
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
|
52
|
+
)}
|
|
53
|
+
/>
|
|
54
|
+
|
|
55
|
+
{/* Centering container — pointer-events-none so clicks reach the backdrop */}
|
|
56
|
+
<div
|
|
57
|
+
className={cn(
|
|
58
|
+
"fixed inset-0 z-50 flex items-center justify-center p-4",
|
|
59
|
+
"pointer-events-none"
|
|
60
|
+
)}
|
|
61
|
+
>
|
|
62
|
+
{/* Panel */}
|
|
63
|
+
<div
|
|
64
|
+
role="dialog"
|
|
65
|
+
aria-modal="true"
|
|
66
|
+
aria-label={title}
|
|
67
|
+
onClick={(e) => e.stopPropagation()}
|
|
68
|
+
className={cn(
|
|
69
|
+
"bg-white rounded-lg shadow-2xl flex flex-col max-h-[90vh] pointer-events-auto",
|
|
70
|
+
"transition-all duration-200 ease-out",
|
|
71
|
+
sizeStyles[size],
|
|
72
|
+
isOpen ? "opacity-100 scale-100" : "opacity-0 scale-95",
|
|
73
|
+
className
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
{/* Header */}
|
|
77
|
+
<ModalHeader>
|
|
78
|
+
{title && (
|
|
79
|
+
<h2 className="text-lg font-semibold text-gray-900 flex-1">{title}</h2>
|
|
80
|
+
)}
|
|
81
|
+
<button
|
|
82
|
+
onClick={onClose}
|
|
83
|
+
aria-label="Close modal"
|
|
84
|
+
className="text-gray-400 hover:text-gray-600 transition-colors ml-auto"
|
|
85
|
+
>
|
|
86
|
+
<X size={20} />
|
|
87
|
+
</button>
|
|
88
|
+
</ModalHeader>
|
|
89
|
+
|
|
90
|
+
{/* Body */}
|
|
91
|
+
<ModalBody>{children}</ModalBody>
|
|
92
|
+
|
|
93
|
+
{/* Footer */}
|
|
94
|
+
{footer && <ModalFooter>{footer}</ModalFooter>}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Sub-components (also usable standalone for custom modal layouts) ─────────
|
|
102
|
+
|
|
103
|
+
export function ModalHeader({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
104
|
+
return (
|
|
105
|
+
<div
|
|
106
|
+
className={cn("flex items-center gap-3 px-6 py-4 border-b border-gray-200 shrink-0", className)}
|
|
107
|
+
{...props}
|
|
108
|
+
>
|
|
109
|
+
{children}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function ModalBody({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
className={cn("flex-1 overflow-y-auto px-6 py-5", className)}
|
|
118
|
+
{...props}
|
|
119
|
+
>
|
|
120
|
+
{children}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function ModalFooter({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
className={cn("shrink-0 px-6 py-4 border-t border-gray-200 bg-white rounded-b-lg", className)}
|
|
129
|
+
{...props}
|
|
130
|
+
>
|
|
131
|
+
{children}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|