@neoptocom/neopto-ui 0.4.0 → 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/CONSUMER_SETUP.md +55 -36
- package/README.md +25 -9
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +6 -1
- package/scripts/init.mjs +200 -0
- package/src/assets/agent-neopto-dark.svg +9 -0
- package/src/assets/agent-neopto.svg +9 -0
- package/src/components/Autocomplete.tsx +279 -0
- package/src/components/Avatar.tsx +83 -0
- package/src/components/AvatarGroup.tsx +53 -0
- package/src/components/Button.tsx +51 -0
- package/src/components/Chat/AnimatedBgCircle.tsx +51 -0
- package/src/components/Chat/AnimatedBgRectangle.tsx +55 -0
- package/src/components/Chat/ChatButton.tsx +132 -0
- package/src/components/Chat/index.ts +5 -0
- package/src/components/Chip.tsx +38 -0
- package/src/components/Counter.tsx +69 -0
- package/src/components/Icon.tsx +48 -0
- package/src/components/IconButton.tsx +89 -0
- package/src/components/Input.tsx +29 -0
- package/src/components/Modal.tsx +83 -0
- package/src/components/Search.tsx +244 -0
- package/src/components/Skeleton.tsx +29 -0
- package/src/components/Typo.tsx +93 -0
- package/src/index.ts +31 -0
- package/src/stories/Autocomplete.stories.tsx +41 -0
- package/src/stories/Avatar.stories.tsx +38 -0
- package/src/stories/AvatarGroup.stories.tsx +46 -0
- package/src/stories/Button.stories.tsx +103 -0
- package/src/stories/ChatButton.stories.tsx +94 -0
- package/src/stories/Chip.stories.tsx +36 -0
- package/src/stories/Counter.stories.tsx +35 -0
- package/src/stories/Icon.stories.tsx +34 -0
- package/src/stories/IconButton.stories.tsx +116 -0
- package/src/stories/Input.stories.tsx +38 -0
- package/src/stories/Search.stories.tsx +228 -0
- package/src/stories/Skeleton.stories.tsx +43 -0
- package/src/stories/Typo.stories.tsx +66 -0
- package/src/styles/library.css +35 -0
- package/src/styles/tailwind.css +36 -0
- package/src/styles/tokens.css +72 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
|
|
3
|
+
interface AnimatedBgRectangleProps {
|
|
4
|
+
colors: string[];
|
|
5
|
+
delay?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const AnimatedBgRectangle = ({ colors, delay = 0 }: AnimatedBgRectangleProps) => {
|
|
9
|
+
const uniqueId = useMemo(() => Math.random().toString(36).substr(2, 9), []);
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<svg
|
|
13
|
+
viewBox="0 0 64 64"
|
|
14
|
+
fill="none"
|
|
15
|
+
className="h-full transition-all duration-500 ease-in-out w-full"
|
|
16
|
+
preserveAspectRatio="none"
|
|
17
|
+
>
|
|
18
|
+
<style>
|
|
19
|
+
{`
|
|
20
|
+
@keyframes colorCycle-${uniqueId} {
|
|
21
|
+
0% { fill: ${colors[0]}; }
|
|
22
|
+
25% { fill: ${colors[1]}; }
|
|
23
|
+
50% { fill: ${colors[2]}; }
|
|
24
|
+
75% { fill: ${colors[3]}; }
|
|
25
|
+
100% { fill: ${colors[0]}; }
|
|
26
|
+
}
|
|
27
|
+
@keyframes fadeIn-${uniqueId} {
|
|
28
|
+
0% { opacity: 0; }
|
|
29
|
+
100% { opacity: 1; }
|
|
30
|
+
}
|
|
31
|
+
.animated-circle-${uniqueId} {
|
|
32
|
+
animation:
|
|
33
|
+
fadeIn-${uniqueId} 1.5s ease-in-out 1.5s forwards,
|
|
34
|
+
colorCycle-${uniqueId} 4s infinite linear ${delay}s;
|
|
35
|
+
opacity: 0;
|
|
36
|
+
transition: all 0.5s ease-in-out;
|
|
37
|
+
}
|
|
38
|
+
`}
|
|
39
|
+
</style>
|
|
40
|
+
<g style={{ mixBlendMode: "overlay" }} filter="url(#filter0_f_241_319)">
|
|
41
|
+
<rect x="10" y="12" width="44" height="40" className={`animated-circle-${uniqueId}`} />
|
|
42
|
+
</g>
|
|
43
|
+
<defs>
|
|
44
|
+
<filter id="filter0_f_241_319" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
|
|
45
|
+
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
|
46
|
+
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
|
47
|
+
<feGaussianBlur stdDeviation="4" result="effect1_foregroundBlur_241_319" />
|
|
48
|
+
</filter>
|
|
49
|
+
</defs>
|
|
50
|
+
</svg>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export default AnimatedBgRectangle;
|
|
55
|
+
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import AnimatedBgCircle from "./AnimatedBgCircle";
|
|
3
|
+
import AnimatedBgRectangle from "./AnimatedBgRectangle";
|
|
4
|
+
import Typo from "../Typo";
|
|
5
|
+
|
|
6
|
+
export interface ChatButtonProps {
|
|
7
|
+
/** Callback when chat button is clicked */
|
|
8
|
+
onOpenChat: () => void;
|
|
9
|
+
/** Whether there's a new notification */
|
|
10
|
+
hasNotification?: boolean;
|
|
11
|
+
/** The notification message to display */
|
|
12
|
+
notificationMessage?: string;
|
|
13
|
+
/** Logo source for the chat agent */
|
|
14
|
+
logoSrc?: string;
|
|
15
|
+
/** Alt text for the logo */
|
|
16
|
+
logoAlt?: string;
|
|
17
|
+
/** Custom animation colors */
|
|
18
|
+
animationColors?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ChatButton = ({
|
|
22
|
+
onOpenChat,
|
|
23
|
+
hasNotification = false,
|
|
24
|
+
notificationMessage = "",
|
|
25
|
+
logoSrc,
|
|
26
|
+
logoAlt = "Chat Agent",
|
|
27
|
+
animationColors = ["#7DADB9", "#3864F5", "#55468D", "#479A8D"],
|
|
28
|
+
}: ChatButtonProps) => {
|
|
29
|
+
const [showText, setShowText] = useState(false);
|
|
30
|
+
const [delayedHasNotification, setDelayedHasNotification] = useState(false);
|
|
31
|
+
const [isMounted, setIsMounted] = useState(false);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const timer = setTimeout(() => setIsMounted(true), 250);
|
|
35
|
+
return () => clearTimeout(timer);
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (hasNotification) {
|
|
40
|
+
const textTimer = setTimeout(() => setShowText(true), 500);
|
|
41
|
+
setDelayedHasNotification(true);
|
|
42
|
+
return () => clearTimeout(textTimer);
|
|
43
|
+
} else {
|
|
44
|
+
const textHideTimer = setTimeout(() => setShowText(false), 250);
|
|
45
|
+
const backgroundTimer = setTimeout(
|
|
46
|
+
() => setDelayedHasNotification(false),
|
|
47
|
+
350
|
|
48
|
+
);
|
|
49
|
+
return () => {
|
|
50
|
+
clearTimeout(textHideTimer);
|
|
51
|
+
clearTimeout(backgroundTimer);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}, [hasNotification]);
|
|
55
|
+
|
|
56
|
+
const circleAnimations = [
|
|
57
|
+
{ delay: 0, style: "left-0 top-0" },
|
|
58
|
+
{ delay: 0.75, style: "right-0 top-0" },
|
|
59
|
+
{ delay: 1, style: "bottom-0 left-0" },
|
|
60
|
+
{ delay: 0.25, style: "bottom-0 right-0" },
|
|
61
|
+
];
|
|
62
|
+
const rectAnimations = [
|
|
63
|
+
{ delay: 0, style: "left-0 top-0" },
|
|
64
|
+
{ delay: 0.75, style: "right-0 top-0" },
|
|
65
|
+
{ delay: 1, style: "bottom-0 left-0" },
|
|
66
|
+
{ delay: 0.25, style: "bottom-0 right-0" },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
className={`flex justify-end items-center fixed bottom-3 right-8 z-40 h-24 transition-all duration-600 ease-in-out ${
|
|
72
|
+
delayedHasNotification ? "w-[432px]" : "w-24"
|
|
73
|
+
} ${isMounted ? "opacity-100" : "opacity-0"}`}
|
|
74
|
+
>
|
|
75
|
+
{circleAnimations.map((circle, index) => (
|
|
76
|
+
<div
|
|
77
|
+
key={`circle-${index}`}
|
|
78
|
+
className={`absolute ${circle.style} h-20 min-w-20 transition-all duration-600 ease-in-out w-20 overflow-visible flex justify-between`}
|
|
79
|
+
>
|
|
80
|
+
<AnimatedBgCircle
|
|
81
|
+
colors={animationColors}
|
|
82
|
+
delay={circle.delay}
|
|
83
|
+
stretch={delayedHasNotification}
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
))}
|
|
87
|
+
{rectAnimations.map((rect, index) => (
|
|
88
|
+
<div
|
|
89
|
+
key={`rect-${index}`}
|
|
90
|
+
className={`absolute ${rect.style} h-20 transition-all duration-600 ease-in-out ${
|
|
91
|
+
delayedHasNotification
|
|
92
|
+
? "w-[324px] opacity-100 px-0"
|
|
93
|
+
: "w-20 px-10 opacity-0"
|
|
94
|
+
} overflow-visible flex justify-between`}
|
|
95
|
+
>
|
|
96
|
+
<AnimatedBgRectangle colors={animationColors} delay={rect.delay} />
|
|
97
|
+
</div>
|
|
98
|
+
))}
|
|
99
|
+
{isMounted && (
|
|
100
|
+
<button
|
|
101
|
+
onClick={onOpenChat}
|
|
102
|
+
className={`flex flex-row-reverse items-center gap-1.5 fixed p-3 rounded-full shadow-md cursor-pointer h-16 mr-4 transition-all duration-600 ease-in-out border-2 border-[var(--border)] ${
|
|
103
|
+
delayedHasNotification ? "w-[400px]" : "w-16"
|
|
104
|
+
}`}
|
|
105
|
+
style={{
|
|
106
|
+
background: "var(--chat-button-gradient)",
|
|
107
|
+
}}
|
|
108
|
+
aria-label={hasNotification ? notificationMessage : "Open chat"}
|
|
109
|
+
>
|
|
110
|
+
{logoSrc && (
|
|
111
|
+
<img
|
|
112
|
+
src={logoSrc}
|
|
113
|
+
alt={logoAlt}
|
|
114
|
+
className="w-10 h-10 object-contain"
|
|
115
|
+
/>
|
|
116
|
+
)}
|
|
117
|
+
<Typo
|
|
118
|
+
variant="label-lg"
|
|
119
|
+
className={`pl-2 w-80 line-clamp-2 overflow-hidden text-ellipsis text-left transition-opacity duration-300 ${
|
|
120
|
+
showText ? "opacity-100" : "opacity-0"
|
|
121
|
+
}`}
|
|
122
|
+
>
|
|
123
|
+
{notificationMessage}
|
|
124
|
+
</Typo>
|
|
125
|
+
</button>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export default ChatButton;
|
|
132
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import Icon from "./Icon";
|
|
3
|
+
|
|
4
|
+
export type ChipProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
5
|
+
variant?: "warning" | "success" | "error" | "light" | "dark";
|
|
6
|
+
icon?: string;
|
|
7
|
+
label?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default function Chip({
|
|
11
|
+
variant = "success",
|
|
12
|
+
icon,
|
|
13
|
+
className = "",
|
|
14
|
+
label,
|
|
15
|
+
...props
|
|
16
|
+
}: ChipProps) {
|
|
17
|
+
const base =
|
|
18
|
+
"inline-flex w-fit items-center justify-center gap-1 whitespace-nowrap overflow-hidden rounded-full h-6 px-2 " +
|
|
19
|
+
"text-xs font-semibold";
|
|
20
|
+
|
|
21
|
+
// Token-based backgrounds + readable text
|
|
22
|
+
const variantCls: Record<NonNullable<ChipProps["variant"]>, string> = {
|
|
23
|
+
warning: "bg-[var(--warning)] text-white",
|
|
24
|
+
success: "bg-[var(--success)] text-white",
|
|
25
|
+
error: "bg-[var(--destructive)] text-white",
|
|
26
|
+
light: "bg-[var(--muted)] text-[var(--fg)]",
|
|
27
|
+
dark: "bg-[var(--surface)] text-[var(--fg)]"
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className={[base, variantCls[variant], className].join(" ")} {...props}>
|
|
32
|
+
{icon ? <Icon name={icon} size="sm" className="mr-0.5" /> : null}
|
|
33
|
+
<span>{label}</span>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import Icon from "./Icon";
|
|
3
|
+
|
|
4
|
+
export type CounterProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> & {
|
|
5
|
+
value?: number;
|
|
6
|
+
onChange?: (value: number) => void;
|
|
7
|
+
min?: number;
|
|
8
|
+
max?: number;
|
|
9
|
+
step?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default function Counter({
|
|
13
|
+
value = 0,
|
|
14
|
+
onChange,
|
|
15
|
+
min = 0,
|
|
16
|
+
max = 99,
|
|
17
|
+
step = 1,
|
|
18
|
+
className = "",
|
|
19
|
+
...props
|
|
20
|
+
}: CounterProps) {
|
|
21
|
+
const [count, setCount] = React.useState(value);
|
|
22
|
+
|
|
23
|
+
React.useEffect(() => {
|
|
24
|
+
setCount(value);
|
|
25
|
+
}, [value]);
|
|
26
|
+
|
|
27
|
+
const handleIncrement = () => {
|
|
28
|
+
const newValue = Math.min(count + step, max);
|
|
29
|
+
setCount(newValue);
|
|
30
|
+
onChange?.(newValue);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const handleDecrement = () => {
|
|
34
|
+
const newValue = Math.max(count - step, min);
|
|
35
|
+
setCount(newValue);
|
|
36
|
+
onChange?.(newValue);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const base =
|
|
40
|
+
"inline-flex w-fit items-center justify-center gap-2 whitespace-nowrap overflow-hidden rounded-full h-7.5 px-2 " +
|
|
41
|
+
"text-xs font-semibold bg-[var(--muted)] text-[var(--fg)]";
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className={[base, className].join(" ")} {...props}>
|
|
45
|
+
<span className="min-w-[1.5rem] text-center">{count}</span>
|
|
46
|
+
<div className="flex flex-col gap-0 -my-1">
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
onClick={handleIncrement}
|
|
50
|
+
disabled={count >= max}
|
|
51
|
+
className="hover:opacity-70 disabled:opacity-30 disabled:cursor-not-allowed transition-opacity"
|
|
52
|
+
aria-label="Increment"
|
|
53
|
+
>
|
|
54
|
+
<Icon name="keyboard_arrow_up" size="sm" />
|
|
55
|
+
</button>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={handleDecrement}
|
|
59
|
+
disabled={count <= min}
|
|
60
|
+
className="hover:opacity-70 disabled:opacity-30 disabled:cursor-not-allowed transition-opacity"
|
|
61
|
+
aria-label="Decrement"
|
|
62
|
+
>
|
|
63
|
+
<Icon name="keyboard_arrow_down" size="sm" />
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type IconProps = {
|
|
2
|
+
/** Material Symbols name (e.g., "search", "close", "more_vert") */
|
|
3
|
+
name: string;
|
|
4
|
+
className?: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
/** Visual size preset */
|
|
7
|
+
size?: "sm" | "md" | "lg";
|
|
8
|
+
/** Filled (1) vs outlined (0) glyph */
|
|
9
|
+
fill?: 0 | 1;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const sizeMap = {
|
|
13
|
+
sm: 16,
|
|
14
|
+
md: 24,
|
|
15
|
+
lg: 36,
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
export default function Icon({
|
|
19
|
+
name,
|
|
20
|
+
className = "",
|
|
21
|
+
title,
|
|
22
|
+
size = "md",
|
|
23
|
+
fill = 0
|
|
24
|
+
}: IconProps) {
|
|
25
|
+
const fontSize = sizeMap[size] ?? sizeMap.md;
|
|
26
|
+
// If user already provides a Tailwind color class, don't override color
|
|
27
|
+
const hasColorClass = /\b(?:text-|fill-|stroke-)\S+/.test(className);
|
|
28
|
+
const style: React.CSSProperties = {
|
|
29
|
+
fontVariationSettings: `'FILL' ${fill}, 'wght' 300, 'GRAD' 0, 'opsz' ${fontSize}`,
|
|
30
|
+
fontSize,
|
|
31
|
+
lineHeight: 1,
|
|
32
|
+
display: "inline-block",
|
|
33
|
+
verticalAlign: "middle",
|
|
34
|
+
...(hasColorClass ? {} : { color: "inherit" })
|
|
35
|
+
};
|
|
36
|
+
return (
|
|
37
|
+
<span
|
|
38
|
+
className={`material-symbols-rounded rounded ${className}`}
|
|
39
|
+
style={style}
|
|
40
|
+
aria-hidden={title ? undefined : true}
|
|
41
|
+
title={title}
|
|
42
|
+
>
|
|
43
|
+
{name}
|
|
44
|
+
</span>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { tv, type VariantProps } from "tailwind-variants";
|
|
3
|
+
import Icon from "./Icon";
|
|
4
|
+
|
|
5
|
+
const iconButtonStyles = tv({
|
|
6
|
+
base: [
|
|
7
|
+
"flex items-center justify-center rounded-full flex-shrink-0",
|
|
8
|
+
"transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
|
9
|
+
"focus-visible:ring-cyan-500/40 disabled:cursor-not-allowed disabled:opacity-50"
|
|
10
|
+
].join(" "),
|
|
11
|
+
variants: {
|
|
12
|
+
variant: {
|
|
13
|
+
ghost: "bg-transparent hover:bg-[var(--muted)] active:bg-[var(--muted)]",
|
|
14
|
+
primary: "bg-cyan-500 text-white hover:bg-cyan-400 active:bg-cyan-600",
|
|
15
|
+
secondary: "border border-[var(--border)] bg-[var(--surface)] hover:bg-[var(--muted)] active:bg-[var(--muted)]"
|
|
16
|
+
},
|
|
17
|
+
size: {
|
|
18
|
+
sm: "w-8 h-8",
|
|
19
|
+
md: "w-10 h-10",
|
|
20
|
+
lg: "w-12 h-12"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
defaultVariants: {
|
|
24
|
+
variant: "ghost",
|
|
25
|
+
size: "md"
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
type IconButtonVariants = VariantProps<typeof iconButtonStyles>;
|
|
30
|
+
|
|
31
|
+
export type IconButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
|
|
32
|
+
IconButtonVariants & {
|
|
33
|
+
/** Material Symbols icon name */
|
|
34
|
+
icon: string;
|
|
35
|
+
/** Icon size (defaults to button size) */
|
|
36
|
+
iconSize?: "sm" | "md" | "lg";
|
|
37
|
+
/** Icon fill (0 = outlined, 1 = filled) */
|
|
38
|
+
iconFill?: 0 | 1;
|
|
39
|
+
/** Optional custom className for the icon */
|
|
40
|
+
iconClassName?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
|
|
44
|
+
(
|
|
45
|
+
{
|
|
46
|
+
variant,
|
|
47
|
+
size,
|
|
48
|
+
icon,
|
|
49
|
+
iconSize,
|
|
50
|
+
iconFill = 0,
|
|
51
|
+
iconClassName,
|
|
52
|
+
className,
|
|
53
|
+
...props
|
|
54
|
+
},
|
|
55
|
+
ref
|
|
56
|
+
) => {
|
|
57
|
+
// Map button size to icon size if not explicitly provided
|
|
58
|
+
const defaultIconSize = size === "sm" ? "sm" : size === "lg" ? "lg" : "md";
|
|
59
|
+
const effectiveIconSize = iconSize ?? defaultIconSize;
|
|
60
|
+
|
|
61
|
+
// Icon color based on variant (can be overridden by iconClassName)
|
|
62
|
+
const defaultIconColorClass =
|
|
63
|
+
variant === "primary"
|
|
64
|
+
? "text-white"
|
|
65
|
+
: variant === "secondary"
|
|
66
|
+
? "text-[var(--fg)]"
|
|
67
|
+
: "text-[var(--muted-fg)]";
|
|
68
|
+
|
|
69
|
+
const finalIconClassName = iconClassName ?? defaultIconColorClass;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<button
|
|
73
|
+
ref={ref}
|
|
74
|
+
className={iconButtonStyles({ variant, size, className })}
|
|
75
|
+
{...props}
|
|
76
|
+
>
|
|
77
|
+
<Icon
|
|
78
|
+
name={icon}
|
|
79
|
+
size={effectiveIconSize}
|
|
80
|
+
fill={iconFill}
|
|
81
|
+
className={finalIconClassName}
|
|
82
|
+
/>
|
|
83
|
+
</button>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
IconButton.displayName = "IconButton";
|
|
89
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
export type InputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'>;
|
|
4
|
+
|
|
5
|
+
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
6
|
+
({ className, disabled, ...props }, ref) => {
|
|
7
|
+
return (
|
|
8
|
+
<input
|
|
9
|
+
ref={ref}
|
|
10
|
+
disabled={disabled}
|
|
11
|
+
className={[
|
|
12
|
+
"w-full h-12 px-4 rounded-full border bg-transparent outline-none transition-colors",
|
|
13
|
+
"text-sm placeholder:text-[var(--muted-fg)]",
|
|
14
|
+
disabled
|
|
15
|
+
? "border-[#3F424F] text-[#3F424F] cursor-not-allowed"
|
|
16
|
+
: [
|
|
17
|
+
"text-[var(--muted-fg)]",
|
|
18
|
+
"border-[var(--muted-fg)]",
|
|
19
|
+
"hover:border-[var(--border)]",
|
|
20
|
+
"focus:border-[var(--color-brand)] focus:text-[var(--fg)]"
|
|
21
|
+
].join(" "),
|
|
22
|
+
className
|
|
23
|
+
].join(" ")}
|
|
24
|
+
{...props}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
Input.displayName = "Input";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
|
|
4
|
+
export type ModalProps = {
|
|
5
|
+
open: boolean;
|
|
6
|
+
onClose?: () => void;
|
|
7
|
+
children?: React.ReactNode;
|
|
8
|
+
title?: string;
|
|
9
|
+
/** When true, closes when the overlay is clicked */
|
|
10
|
+
closeOnOverlay?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function useIsomorphicLayoutEffect(effect: React.EffectCallback, deps?: React.DependencyList) {
|
|
14
|
+
const useEffectHook = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
|
|
15
|
+
useEffectHook(effect, deps);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Modal({
|
|
19
|
+
open,
|
|
20
|
+
onClose,
|
|
21
|
+
title,
|
|
22
|
+
closeOnOverlay = true,
|
|
23
|
+
children
|
|
24
|
+
}: ModalProps) {
|
|
25
|
+
const [mounted, setMounted] = React.useState(false);
|
|
26
|
+
|
|
27
|
+
// Prevent body scroll when open
|
|
28
|
+
useIsomorphicLayoutEffect(() => {
|
|
29
|
+
setMounted(true);
|
|
30
|
+
if (!open) return;
|
|
31
|
+
const original = document.body.style.overflow;
|
|
32
|
+
document.body.style.overflow = "hidden";
|
|
33
|
+
return () => {
|
|
34
|
+
document.body.style.overflow = original;
|
|
35
|
+
};
|
|
36
|
+
}, [open]);
|
|
37
|
+
|
|
38
|
+
React.useEffect(() => {
|
|
39
|
+
if (!open) return;
|
|
40
|
+
const onKey = (e: KeyboardEvent) => {
|
|
41
|
+
if (e.key === "Escape") onClose?.();
|
|
42
|
+
};
|
|
43
|
+
window.addEventListener("keydown", onKey);
|
|
44
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
45
|
+
}, [open, onClose]);
|
|
46
|
+
|
|
47
|
+
if (!mounted) return null;
|
|
48
|
+
if (!open) return null;
|
|
49
|
+
|
|
50
|
+
const overlay = (
|
|
51
|
+
<div
|
|
52
|
+
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
53
|
+
aria-labelledby="modal-title"
|
|
54
|
+
role="dialog"
|
|
55
|
+
aria-modal="true"
|
|
56
|
+
>
|
|
57
|
+
<div
|
|
58
|
+
className="absolute inset-0 bg-black/50"
|
|
59
|
+
onClick={() => closeOnOverlay && onClose?.()}
|
|
60
|
+
/>
|
|
61
|
+
<div className="relative z-10 w-full max-w-lg rounded-[var(--radius-lg)] bg-[var(--surface)] text-[var(--fg)] p-6 shadow-xl">
|
|
62
|
+
{title ? (
|
|
63
|
+
<h2 id="modal-title" className="mb-2 text-lg font-semibold">
|
|
64
|
+
{title}
|
|
65
|
+
</h2>
|
|
66
|
+
) : null}
|
|
67
|
+
<div>{children}</div>
|
|
68
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
69
|
+
<button
|
|
70
|
+
onClick={onClose}
|
|
71
|
+
className="btn btn-outline"
|
|
72
|
+
type="button"
|
|
73
|
+
>
|
|
74
|
+
Close
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const container = document.body;
|
|
82
|
+
return createPortal(overlay, container);
|
|
83
|
+
}
|