@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.
Files changed (42) hide show
  1. package/CONSUMER_SETUP.md +55 -36
  2. package/README.md +25 -9
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.js +1 -1
  5. package/package.json +6 -1
  6. package/scripts/init.mjs +200 -0
  7. package/src/assets/agent-neopto-dark.svg +9 -0
  8. package/src/assets/agent-neopto.svg +9 -0
  9. package/src/components/Autocomplete.tsx +279 -0
  10. package/src/components/Avatar.tsx +83 -0
  11. package/src/components/AvatarGroup.tsx +53 -0
  12. package/src/components/Button.tsx +51 -0
  13. package/src/components/Chat/AnimatedBgCircle.tsx +51 -0
  14. package/src/components/Chat/AnimatedBgRectangle.tsx +55 -0
  15. package/src/components/Chat/ChatButton.tsx +132 -0
  16. package/src/components/Chat/index.ts +5 -0
  17. package/src/components/Chip.tsx +38 -0
  18. package/src/components/Counter.tsx +69 -0
  19. package/src/components/Icon.tsx +48 -0
  20. package/src/components/IconButton.tsx +89 -0
  21. package/src/components/Input.tsx +29 -0
  22. package/src/components/Modal.tsx +83 -0
  23. package/src/components/Search.tsx +244 -0
  24. package/src/components/Skeleton.tsx +29 -0
  25. package/src/components/Typo.tsx +93 -0
  26. package/src/index.ts +31 -0
  27. package/src/stories/Autocomplete.stories.tsx +41 -0
  28. package/src/stories/Avatar.stories.tsx +38 -0
  29. package/src/stories/AvatarGroup.stories.tsx +46 -0
  30. package/src/stories/Button.stories.tsx +103 -0
  31. package/src/stories/ChatButton.stories.tsx +94 -0
  32. package/src/stories/Chip.stories.tsx +36 -0
  33. package/src/stories/Counter.stories.tsx +35 -0
  34. package/src/stories/Icon.stories.tsx +34 -0
  35. package/src/stories/IconButton.stories.tsx +116 -0
  36. package/src/stories/Input.stories.tsx +38 -0
  37. package/src/stories/Search.stories.tsx +228 -0
  38. package/src/stories/Skeleton.stories.tsx +43 -0
  39. package/src/stories/Typo.stories.tsx +66 -0
  40. package/src/styles/library.css +35 -0
  41. package/src/styles/tailwind.css +36 -0
  42. 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,5 @@
1
+ export { default as ChatButton } from "./ChatButton";
2
+ export type { ChatButtonProps } from "./ChatButton";
3
+ export { default as AnimatedBgCircle } from "./AnimatedBgCircle";
4
+ export { default as AnimatedBgRectangle } from "./AnimatedBgRectangle";
5
+
@@ -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
+ }