ruler-components 0.1.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 (114) hide show
  1. package/AGENTS.md +9 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +21 -0
  4. package/README.md +45 -0
  5. package/app/docs/[slug]/page.tsx +147 -0
  6. package/app/docs/layout.tsx +26 -0
  7. package/app/docs/page.tsx +148 -0
  8. package/app/favicon.ico +0 -0
  9. package/app/globals.css +168 -0
  10. package/app/icon.png +0 -0
  11. package/app/layout.tsx +90 -0
  12. package/app/opengraph-image.tsx +10 -0
  13. package/app/page.tsx +34 -0
  14. package/app/twitter-image.tsx +10 -0
  15. package/components/docs/accordion-doc-page.tsx +352 -0
  16. package/components/docs/alert-doc-page.tsx +293 -0
  17. package/components/docs/allinput-doc-page.tsx +183 -0
  18. package/components/docs/avatar-doc-page.tsx +424 -0
  19. package/components/docs/badge-doc-page.tsx +363 -0
  20. package/components/docs/button-doc-page.tsx +406 -0
  21. package/components/docs/card-doc-page.tsx +364 -0
  22. package/components/docs/checkbox-doc-page.tsx +277 -0
  23. package/components/docs/colors-doc-page.tsx +430 -0
  24. package/components/docs/dialog-doc-page.tsx +364 -0
  25. package/components/docs/docs-footer.tsx +11 -0
  26. package/components/docs/docs-mobile-menu-bar.tsx +22 -0
  27. package/components/docs/docs-mobile-nav-context.tsx +27 -0
  28. package/components/docs/docs-page.tsx +263 -0
  29. package/components/docs/docs-search.tsx +303 -0
  30. package/components/docs/docs-sidebar.tsx +165 -0
  31. package/components/docs/docs-topbar.tsx +32 -0
  32. package/components/docs/dropdown-doc-page.tsx +522 -0
  33. package/components/docs/getting-started-doc-page.tsx +205 -0
  34. package/components/docs/label-doc-page.tsx +173 -0
  35. package/components/docs/loaders-doc-page.tsx +244 -0
  36. package/components/docs/menu-doc-page.tsx +373 -0
  37. package/components/docs/modal-doc-page.tsx +436 -0
  38. package/components/docs/next-steps.tsx +53 -0
  39. package/components/docs/pagination-doc-page.tsx +316 -0
  40. package/components/docs/progress-doc-page.tsx +387 -0
  41. package/components/docs/radio-doc-page.tsx +270 -0
  42. package/components/docs/skeleton-doc-page.tsx +230 -0
  43. package/components/docs/stepper-doc-page.tsx +309 -0
  44. package/components/docs/switch-doc-page.tsx +264 -0
  45. package/components/docs/table-doc-page.tsx +689 -0
  46. package/components/docs/tabs-doc-page.tsx +359 -0
  47. package/components/docs/theming-doc-page.tsx +243 -0
  48. package/components/docs/tooltip-doc-page.tsx +357 -0
  49. package/components/docs/usage-section.tsx +21 -0
  50. package/components/icons/discord.tsx +9 -0
  51. package/components/icons/github.tsx +9 -0
  52. package/components/icons/x.tsx +9 -0
  53. package/components/installation-section.tsx +34 -0
  54. package/components/landing/feature-highlights.tsx +105 -0
  55. package/components/landing/footer-strip.tsx +45 -0
  56. package/components/landing/hero-card.tsx +14 -0
  57. package/components/landing/hero-spotlight.tsx +126 -0
  58. package/components/landing/hero.tsx +64 -0
  59. package/components/landing/navbar.tsx +44 -0
  60. package/components/landing/quick-start-section.tsx +59 -0
  61. package/components/landing/site-footer.tsx +55 -0
  62. package/components/theme-provider.tsx +60 -0
  63. package/components/ui/accordion.tsx +174 -0
  64. package/components/ui/alert.tsx +103 -0
  65. package/components/ui/allinput.tsx +210 -0
  66. package/components/ui/avatar.tsx +179 -0
  67. package/components/ui/badge.tsx +52 -0
  68. package/components/ui/button.tsx +75 -0
  69. package/components/ui/card.tsx +105 -0
  70. package/components/ui/checkbox.tsx +130 -0
  71. package/components/ui/code-block.tsx +60 -0
  72. package/components/ui/data-table.tsx +235 -0
  73. package/components/ui/dialog.tsx +173 -0
  74. package/components/ui/dropdown.tsx +78 -0
  75. package/components/ui/feature-card.tsx +38 -0
  76. package/components/ui/input.tsx +44 -0
  77. package/components/ui/label.tsx +110 -0
  78. package/components/ui/loaders.tsx +139 -0
  79. package/components/ui/menu.tsx +118 -0
  80. package/components/ui/modal.tsx +399 -0
  81. package/components/ui/package-manager-tabs.tsx +40 -0
  82. package/components/ui/pagination.tsx +200 -0
  83. package/components/ui/progress.tsx +134 -0
  84. package/components/ui/radio.tsx +262 -0
  85. package/components/ui/skeleton.tsx +102 -0
  86. package/components/ui/steppers.tsx +309 -0
  87. package/components/ui/switch.tsx +153 -0
  88. package/components/ui/table.tsx +58 -0
  89. package/components/ui/tabs.tsx +166 -0
  90. package/components/ui/theme-toggle.tsx +60 -0
  91. package/components/ui/tooltip.tsx +238 -0
  92. package/data/color-palette.ts +35 -0
  93. package/data/docs-nav.ts +84 -0
  94. package/eslint.config.mjs +18 -0
  95. package/index.ts +8 -0
  96. package/lib/cn.ts +4 -0
  97. package/lib/color.ts +85 -0
  98. package/lib/og-image.tsx +138 -0
  99. package/lib/theme.ts +33 -0
  100. package/next.config.ts +7 -0
  101. package/package.json +30 -0
  102. package/postcss.config.mjs +7 -0
  103. package/public/dark.png +0 -0
  104. package/public/dark1.png +0 -0
  105. package/public/dark2.png +0 -0
  106. package/public/file.svg +1 -0
  107. package/public/globe.svg +1 -0
  108. package/public/light.png +0 -0
  109. package/public/light1.png +0 -0
  110. package/public/next.svg +1 -0
  111. package/public/vercel.svg +1 -0
  112. package/public/video/hero-demo.mp4 +0 -0
  113. package/public/window.svg +1 -0
  114. package/tsconfig.json +34 -0
@@ -0,0 +1,126 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
4
+ import { cn } from "@/lib/cn";
5
+
6
+ const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
7
+
8
+ function subscribeReducedMotion(onChange: () => void) {
9
+ const media = window.matchMedia(REDUCED_MOTION_QUERY);
10
+ media.addEventListener("change", onChange);
11
+ return () => media.removeEventListener("change", onChange);
12
+ }
13
+
14
+ function useReducedMotion() {
15
+ return useSyncExternalStore(
16
+ subscribeReducedMotion,
17
+ () => window.matchMedia(REDUCED_MOTION_QUERY).matches,
18
+ () => false,
19
+ );
20
+ }
21
+
22
+ function spawnSnowParticle(container: HTMLElement, x: number, y: number) {
23
+ const particle = document.createElement("span");
24
+ particle.className = "hero-particle";
25
+
26
+ const offsetX = (Math.random() - 0.5) * 16;
27
+ const offsetY = (Math.random() - 0.5) * 16;
28
+ particle.style.left = `${x + offsetX}px`;
29
+ particle.style.top = `${y + offsetY}px`;
30
+
31
+ const size = 2 + Math.random() * 2.5;
32
+ particle.style.width = `${size}px`;
33
+ particle.style.height = `${size}px`;
34
+
35
+ const dx1 = (Math.random() - 0.5) * 40;
36
+ const dy1 = 12 + Math.random() * 18;
37
+ const dx2 = dx1 + (Math.random() - 0.5) * 50;
38
+ const dy2 = dy1 + 25 + Math.random() * 35;
39
+ const duration = 1800 + Math.random() * 1600;
40
+ const peakOpacity = 0.5 + Math.random() * 0.4;
41
+
42
+ particle.style.setProperty("--dx1", `${dx1}px`);
43
+ particle.style.setProperty("--dy1", `${dy1}px`);
44
+ particle.style.setProperty("--dx2", `${dx2}px`);
45
+ particle.style.setProperty("--dy2", `${dy2}px`);
46
+ particle.style.setProperty("--dur", `${duration}ms`);
47
+ particle.style.setProperty("--peak-opacity", `${peakOpacity}`);
48
+
49
+ particle.addEventListener("animationend", () => particle.remove());
50
+ container.appendChild(particle);
51
+ }
52
+
53
+ export function HeroSpotlight({ children }: { children: ReactNode }) {
54
+ const containerRef = useRef<HTMLDivElement>(null);
55
+ const gridRef = useRef<HTMLDivElement>(null);
56
+ const lastSpawnRef = useRef(0);
57
+ const reducedMotion = useReducedMotion();
58
+
59
+ useEffect(() => {
60
+ const container = containerRef.current;
61
+ const grid = gridRef.current;
62
+ if (!container || !grid) return;
63
+
64
+ const handleMove = (event: PointerEvent) => {
65
+ const rect = container.getBoundingClientRect();
66
+ const x = event.clientX - rect.left;
67
+ const y = event.clientY - rect.top;
68
+ grid.style.setProperty("--mask-x", `${x - 280}px`);
69
+ grid.style.setProperty("--mask-y", `${y - 280}px`);
70
+
71
+ if (reducedMotion) return;
72
+ if (event.timeStamp - lastSpawnRef.current < 130) return;
73
+ lastSpawnRef.current = event.timeStamp;
74
+
75
+ spawnSnowParticle(container, x, y);
76
+ spawnSnowParticle(container, x, y);
77
+ };
78
+
79
+ const activate = () => grid.classList.add("is-active");
80
+ const deactivate = () => grid.classList.remove("is-active");
81
+
82
+ container.addEventListener("pointermove", handleMove);
83
+ container.addEventListener("pointerenter", activate);
84
+ container.addEventListener("pointerleave", deactivate);
85
+ return () => {
86
+ container.removeEventListener("pointermove", handleMove);
87
+ container.removeEventListener("pointerenter", activate);
88
+ container.removeEventListener("pointerleave", deactivate);
89
+ };
90
+ }, [reducedMotion]);
91
+
92
+ return (
93
+ <div ref={containerRef} className="relative overflow-hidden rounded-3xl bg-zinc-950">
94
+ <div
95
+ aria-hidden
96
+ className="pointer-events-none absolute inset-0 opacity-90"
97
+ style={{
98
+ backgroundImage:
99
+ "radial-gradient(60% 50% at 22% -10%, rgba(99,102,241,0.20), transparent 70%)," +
100
+ "radial-gradient(55% 45% at 100% 115%, rgba(217,119,87,0.16), transparent 70%)," +
101
+ "radial-gradient(40% 35% at 78% 8%, rgba(56,189,248,0.10), transparent 70%)",
102
+ }}
103
+ />
104
+ <div
105
+ aria-hidden
106
+ className="pointer-events-none absolute inset-0 opacity-[0.05] mix-blend-overlay"
107
+ style={{
108
+ backgroundImage:
109
+ "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")",
110
+ backgroundSize: "160px 160px",
111
+ }}
112
+ />
113
+ <div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
114
+ <div ref={gridRef} className="hero-spotlight-grid pointer-events-none absolute inset-0" />
115
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 hidden h-6 items-end justify-between px-10 pb-2 opacity-[0.15] sm:flex">
116
+ {Array.from({ length: 48 }).map((_, i) => (
117
+ <span
118
+ key={i}
119
+ className={cn("w-px bg-white", i % 4 === 0 ? "h-3.5" : "h-1.5")}
120
+ />
121
+ ))}
122
+ </div>
123
+ {children}
124
+ </div>
125
+ );
126
+ }
@@ -0,0 +1,64 @@
1
+ import Link from "next/link";
2
+ import { ArrowRight, CheckCircle2, FileCode2, SlidersHorizontal } from "lucide-react";
3
+ import { GithubIcon } from "@/components/icons/github";
4
+ import { buttonVariants } from "@/components/ui/button";
5
+ import { cn } from "@/lib/cn";
6
+
7
+ const TRUST_ITEMS = [
8
+ { icon: FileCode2, label: "TypeScript First" },
9
+ { icon: CheckCircle2, label: "Accessible (WAI-ARIA)" },
10
+ { icon: SlidersHorizontal, label: "Customizable" },
11
+ { icon: GithubIcon, label: "Open Source" },
12
+ ];
13
+
14
+ export function Hero() {
15
+ return (
16
+ <div className="mt-10 min-w-0 max-w-xl sm:mt-16">
17
+ <div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-zinc-300">
18
+ <span className="h-1.5 w-1.5 rounded-full bg-zinc-300" />
19
+ v1.0.0 is now available
20
+ </div>
21
+ <h1 className="mt-5 text-[1.85rem] font-bold leading-[1.2] tracking-tight text-zinc-300 sm:text-5xl sm:leading-[1.15]">
22
+ Build beautiful interfaces, faster with{" "}
23
+ <span className="font-brand bg-gradient-to-r from-white via-zinc-200 to-zinc-400 bg-clip-text tracking-wide text-transparent">
24
+ ruler.ui
25
+ </span>
26
+ </h1>
27
+ <p className="mt-5 text-base leading-relaxed text-zinc-400">
28
+ A modern, accessible, and customizable component library for React &amp;
29
+ TypeScript.
30
+ </p>
31
+ <div className="mt-8 flex flex-wrap items-center gap-3">
32
+ <Link
33
+ href="/docs"
34
+ className={cn(
35
+ buttonVariants({ variant: "white", size: "lg" }),
36
+ "group"
37
+ )}
38
+ >
39
+ Get Started
40
+ <ArrowRight className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" />
41
+ </Link>
42
+
43
+ <Link
44
+ href="#"
45
+ className="group inline-flex h-11 items-center justify-center gap-2 rounded-xl border border-zinc-700 bg-transparent px-6 text-base font-medium text-white transition-all duration-200 hover:-translate-y-0.5 hover:border-zinc-700 hover:bg-transparent hover:text-white"
46
+ >
47
+ <GithubIcon className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" />
48
+ View on GitHub
49
+ </Link>
50
+ </div>
51
+ <div className="mt-8 flex flex-wrap items-center gap-x-5 gap-y-2">
52
+ {TRUST_ITEMS.map((item) => (
53
+ <span
54
+ key={item.label}
55
+ className="inline-flex items-center gap-1.5 text-xs font-medium text-zinc-500"
56
+ >
57
+ <item.icon className="h-3.5 w-3.5" />
58
+ {item.label}
59
+ </span>
60
+ ))}
61
+ </div>
62
+ </div>
63
+ );
64
+ }
@@ -0,0 +1,44 @@
1
+ import Link from "next/link";
2
+ import { GithubIcon } from "@/components/icons/github";
3
+ import { ThemeToggle } from "@/components/ui/theme-toggle";
4
+
5
+ const NAV_LINKS = [
6
+ { label: "Docs", href: "/docs" },
7
+ { label: "Components", href: "/docs" },
8
+ { label: "Themes", href: "/docs/theming" },
9
+ { label: "Resources", href: "/docs" }
10
+ ];
11
+
12
+ export function Navbar() {
13
+ return (
14
+ <nav className="flex min-w-0 items-center justify-between gap-3 border-b border-white/5 pb-6 sm:gap-6">
15
+ <Link href="/" className="min-w-0 shrink">
16
+ {/* eslint-disable-next-line @next/next/no-img-element */}
17
+ <img src="/dark2.png" alt="ruler.ui" className="h-8 w-auto max-w-[9rem] object-contain sm:h-9 sm:max-w-none" />
18
+ </Link>
19
+
20
+ <div className="hidden items-center gap-1 rounded-full border border-white/10 bg-white/[0.03] p-1 md:flex">
21
+ {NAV_LINKS.map((link) => (
22
+ <Link
23
+ key={link.label}
24
+ href={link.href}
25
+ className="rounded-full px-3.5 py-1.5 text-sm font-medium text-zinc-300 transition-colors hover:bg-white/10 hover:text-white"
26
+ >
27
+ {link.label}
28
+ </Link>
29
+ ))}
30
+ </div>
31
+
32
+ <div className="flex items-center gap-3">
33
+ <ThemeToggle inverted />
34
+ <Link
35
+ href="#"
36
+ className="hidden items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-zinc-300 transition-colors hover:bg-white/10 hover:text-white sm:flex"
37
+ >
38
+ <GithubIcon className="h-4 w-4" />
39
+ GitHub
40
+ </Link>
41
+ </div>
42
+ </nav>
43
+ );
44
+ }
@@ -0,0 +1,59 @@
1
+ import Link from "next/link";
2
+ import { ArrowRight, Zap } from "lucide-react";
3
+ import { CodeBlock } from "@/components/ui/code-block";
4
+ import { buttonVariants } from "@/components/ui/button";
5
+ import { cn } from "@/lib/cn";
6
+
7
+ const USAGE_CODE = `import { Button } from 'ruler.ui'
8
+
9
+ export default function App() {
10
+ return (
11
+ <Button variant="primary">
12
+ Get Started
13
+ </Button>
14
+ )
15
+ }`;
16
+
17
+ export function QuickStartSection() {
18
+ return (
19
+ <div className="py-20 max-w-[1200px] mx-auto border-t border-zinc-100 dark:border-zinc-900">
20
+ <div className="grid gap-12 lg:grid-cols-[minmax(0,320px)_1fr] lg:items-center">
21
+ {/* Left Side */}
22
+ <div className="space-y-6">
23
+ <div className="space-y-4">
24
+ <div className="flex items-center gap-3">
25
+ <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-zinc-900 text-white dark:bg-zinc-50 dark:text-zinc-900">
26
+ <Zap className="h-5 w-5 fill-current" />
27
+ </span>
28
+ <h2 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
29
+ Quick Start
30
+ </h2>
31
+ </div>
32
+ <p className="text-[15px] leading-relaxed text-zinc-500 dark:text-zinc-400">
33
+ Start building with ruler.ui in seconds. Copy this example to see it in action.
34
+ </p>
35
+ </div>
36
+
37
+ <Link
38
+ href="/docs"
39
+ className={cn(buttonVariants({ variant: "primary", size: "lg" }), "group")}
40
+ >
41
+ Explore Components
42
+ <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
43
+ </Link>
44
+ </div>
45
+
46
+ {/* Right Side: IDE Window with Mac Dots */}
47
+ <div className="relative group">
48
+ <div className="relative overflow-hidden rounded-2xl border border-zinc-200/50 bg-zinc-950 shadow-2xl dark:border-zinc-800/60">
49
+
50
+
51
+ <div className="p-0">
52
+ <CodeBlock code={USAGE_CODE} filename="App.tsx" />
53
+ </div>
54
+ </div>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ );
59
+ }
@@ -0,0 +1,55 @@
1
+ import Link from "next/link";
2
+ import { GithubIcon } from "@/components/icons/github";
3
+ import { XIcon } from "@/components/icons/x";
4
+ import { DiscordIcon } from "@/components/icons/discord";
5
+
6
+ const FOOTER_LINKS = [
7
+ { label: "Docs", href: "/docs" },
8
+ { label: "Components", href: "/docs" },
9
+ { label: "Themes", href: "/docs/theming" },
10
+ { label: "Resources", href: "/docs" },
11
+ { label: "Playground", href: "#" },
12
+ ];
13
+
14
+ const SOCIAL_LINKS = [
15
+ { label: "GitHub", href: "#", icon: GithubIcon },
16
+ { label: "X", href: "#", icon: XIcon },
17
+ { label: "Discord", href: "#", icon: DiscordIcon },
18
+ ];
19
+
20
+ export function SiteFooter() {
21
+ return (
22
+ <footer className="flex flex-col items-center justify-between gap-6 border-t border-zinc-200 py-8 dark:border-zinc-800 sm:flex-row">
23
+ <div>
24
+ <p className="mt-1 text-xs text-zinc-400 dark:text-zinc-600">
25
+ © 2026 ruler.ui. All rights reserved.
26
+ </p>
27
+ </div>
28
+
29
+ <nav className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
30
+ {FOOTER_LINKS.map((link) => (
31
+ <Link
32
+ key={link.label}
33
+ href={link.href}
34
+ className="text-sm text-zinc-500 transition-colors hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
35
+ >
36
+ {link.label}
37
+ </Link>
38
+ ))}
39
+ </nav>
40
+
41
+ <div className="flex items-center gap-4">
42
+ {SOCIAL_LINKS.map((social) => (
43
+ <Link
44
+ key={social.label}
45
+ href={social.href}
46
+ aria-label={social.label}
47
+ className="text-zinc-400 transition-colors hover:text-zinc-900 dark:text-zinc-500 dark:hover:text-zinc-100"
48
+ >
49
+ <social.icon className="h-4 w-4" />
50
+ </Link>
51
+ ))}
52
+ </div>
53
+ </footer>
54
+ );
55
+ }
@@ -0,0 +1,60 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useState,
10
+ type ReactNode,
11
+ } from "react";
12
+ import {
13
+ applyTheme,
14
+ parseTheme,
15
+ persistTheme,
16
+ readStoredTheme,
17
+ type Theme,
18
+ } from "@/lib/theme";
19
+
20
+ type ThemeContextValue = {
21
+ theme: Theme;
22
+ setTheme: (theme: Theme) => void;
23
+ };
24
+
25
+ const ThemeContext = createContext<ThemeContextValue | null>(null);
26
+
27
+ export function ThemeProvider({
28
+ children,
29
+ defaultTheme = "light",
30
+ }: {
31
+ children: ReactNode;
32
+ defaultTheme?: Theme;
33
+ }) {
34
+ const [theme, setThemeState] = useState<Theme>(defaultTheme);
35
+
36
+ useEffect(() => {
37
+ const stored = readStoredTheme();
38
+ const resolved = stored ?? defaultTheme;
39
+ setThemeState(resolved);
40
+ applyTheme(resolved);
41
+ }, [defaultTheme]);
42
+
43
+ const setTheme = useCallback((nextTheme: Theme) => {
44
+ setThemeState(nextTheme);
45
+ persistTheme(nextTheme);
46
+ applyTheme(nextTheme);
47
+ }, []);
48
+
49
+ const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]);
50
+
51
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
52
+ }
53
+
54
+ export function useTheme() {
55
+ const context = useContext(ThemeContext);
56
+ if (!context) {
57
+ throw new Error("useTheme must be used within ThemeProvider");
58
+ }
59
+ return context;
60
+ }
@@ -0,0 +1,174 @@
1
+ "use client";
2
+
3
+ import React, { createContext, useContext, useState, useRef, useLayoutEffect, useId } from "react";
4
+ import { Plus, ChevronDown, ChevronRight } from "lucide-react";
5
+ import { cn } from "@/lib/cn";
6
+
7
+ type AccordionContextType = {
8
+ openValue: string | null;
9
+ toggle: (value: string) => void;
10
+ };
11
+
12
+ const AccordionContext = createContext<AccordionContextType | null>(null);
13
+
14
+ function useAccordionContext() {
15
+ const ctx = useContext(AccordionContext);
16
+ if (!ctx) throw new Error("Accordion components must be used inside Accordion");
17
+ return ctx;
18
+ }
19
+
20
+ const EASE = { transitionTimingFunction: "cubic-bezier(0.16,1,0.3,1)" };
21
+
22
+ export function Accordion({ children, defaultValue = null, className = "" }: { children: React.ReactNode, defaultValue?: string | null, className?: string }) {
23
+ const [openValue, setOpenValue] = useState<string | null>(defaultValue);
24
+ const toggle = (value: string) => setOpenValue((current) => (current === value ? null : value));
25
+
26
+ return (
27
+ <AccordionContext.Provider value={{ openValue, toggle }}>
28
+ <div className={className}>{children}</div>
29
+ </AccordionContext.Provider>
30
+ );
31
+ }
32
+
33
+ export function AccordionItem({ value, title, children, variant = "plus" }: { value: string, title: string, children: React.ReactNode, variant?: "plus" | "chevron" }) {
34
+ const { openValue, toggle } = useAccordionContext();
35
+ const isOpen = openValue === value;
36
+ const contentRef = useRef<HTMLDivElement>(null);
37
+ const [height, setHeight] = useState(0);
38
+ const panelId = useId();
39
+
40
+ useLayoutEffect(() => {
41
+ if (!contentRef.current) return;
42
+ setHeight(isOpen ? contentRef.current.scrollHeight : 0);
43
+ }, [isOpen]);
44
+
45
+ if (variant === "chevron") {
46
+ return (
47
+ <div className="border-b border-zinc-200 dark:border-zinc-800 last:border-0">
48
+ <button
49
+ type="button"
50
+ onClick={() => toggle(value)}
51
+ aria-expanded={isOpen}
52
+ aria-controls={panelId}
53
+ className="group flex w-full items-center justify-between rounded-lg py-5 text-left outline-none transition-colors duration-200 hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-zinc-900/15 dark:hover:bg-zinc-900/40 dark:focus-visible:ring-white/20"
54
+ >
55
+ <span
56
+ className={cn(
57
+ "text-[15px] font-medium transition-colors duration-200",
58
+ isOpen
59
+ ? "text-zinc-900 dark:text-white"
60
+ : "text-zinc-600 group-hover:text-zinc-900 dark:text-zinc-400 dark:group-hover:text-zinc-200",
61
+ )}
62
+ >
63
+ {title}
64
+ </span>
65
+ <ChevronDown
66
+ size={18}
67
+ style={EASE}
68
+ className={cn(
69
+ "shrink-0 text-zinc-400 transition-transform duration-300 group-hover:text-zinc-600 dark:group-hover:text-zinc-300",
70
+ isOpen && "rotate-180 text-zinc-900 dark:text-white",
71
+ )}
72
+ />
73
+ </button>
74
+ <div style={{ ...EASE, height }} className="overflow-hidden transition-[height] duration-300">
75
+ <div id={panelId} ref={contentRef} className="pb-5 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
76
+ {children}
77
+ </div>
78
+ </div>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ return (
84
+ <div className="border-b border-zinc-200 dark:border-zinc-800 last:border-0">
85
+ <button
86
+ type="button"
87
+ onClick={() => toggle(value)}
88
+ aria-expanded={isOpen}
89
+ aria-controls={panelId}
90
+ className="group flex w-full items-center gap-3 rounded-lg py-4 text-left outline-none transition-colors duration-200 hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-zinc-900/15 dark:hover:bg-zinc-900/40 dark:focus-visible:ring-white/20"
91
+ >
92
+ <span
93
+ style={EASE}
94
+ className={cn(
95
+ "flex h-6 w-6 shrink-0 items-center justify-center rounded-full border transition-all duration-300",
96
+ isOpen
97
+ ? "border-zinc-900 bg-zinc-900 text-white shadow-sm dark:border-zinc-100 dark:bg-zinc-100 dark:text-zinc-900"
98
+ : "border-zinc-300 text-zinc-400 group-hover:border-zinc-400 group-hover:text-zinc-500 dark:border-zinc-700 dark:group-hover:border-zinc-600",
99
+ )}
100
+ >
101
+ <Plus size={12} style={EASE} className={cn("transition-transform duration-300", isOpen && "rotate-45")} />
102
+ </span>
103
+ <span
104
+ className={cn(
105
+ "text-[15px] font-medium transition-colors duration-200",
106
+ isOpen
107
+ ? "text-zinc-900 dark:text-zinc-100"
108
+ : "text-zinc-600 group-hover:text-zinc-900 dark:text-zinc-400 dark:group-hover:text-zinc-200",
109
+ )}
110
+ >
111
+ {title}
112
+ </span>
113
+ </button>
114
+ <div style={{ ...EASE, height }} className="overflow-hidden transition-[height] duration-300">
115
+ <div id={panelId} ref={contentRef} className="pb-4 pl-9 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
116
+ {children}
117
+ </div>
118
+ </div>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ export function AccordionNested({ title, children, level = 0 }: { title: string, children?: React.ReactNode, level?: number }) {
124
+ const [isOpen, setIsOpen] = useState(false);
125
+ const contentRef = useRef<HTMLDivElement>(null);
126
+ const [height, setHeight] = useState(0);
127
+ const panelId = useId();
128
+
129
+ useLayoutEffect(() => {
130
+ if (!contentRef.current) return;
131
+ setHeight(isOpen ? contentRef.current.scrollHeight : 0);
132
+ }, [isOpen, children]);
133
+
134
+ return (
135
+ <div className="border-b border-zinc-100 dark:border-zinc-800 last:border-0">
136
+ <button
137
+ type="button"
138
+ onClick={() => setIsOpen(!isOpen)}
139
+ aria-expanded={isOpen}
140
+ aria-controls={panelId}
141
+ className="group flex w-full items-center gap-2.5 rounded-lg py-3.5 text-left outline-none transition-colors duration-200 hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-zinc-900/15 dark:hover:bg-zinc-900/40 dark:focus-visible:ring-white/20"
142
+ style={{ paddingLeft: `${level * 1.5}rem` }}
143
+ >
144
+ <ChevronRight
145
+ size={15}
146
+ style={EASE}
147
+ className={cn(
148
+ "shrink-0 text-zinc-400 transition-transform duration-300 group-hover:text-zinc-600 dark:group-hover:text-zinc-300",
149
+ isOpen && "rotate-90 text-zinc-600 dark:text-zinc-300",
150
+ )}
151
+ />
152
+ <span className="text-[15px] font-medium text-zinc-900 dark:text-zinc-100">{title}</span>
153
+ </button>
154
+ <div style={{ ...EASE, height }} className="overflow-hidden transition-[height] duration-300">
155
+ <div id={panelId} ref={contentRef} className="flex flex-col">
156
+ {children}
157
+ </div>
158
+ </div>
159
+ </div>
160
+ );
161
+ }
162
+
163
+ export function AccordionLink({ title, level = 0 }: { title: string, level?: number }) {
164
+ return (
165
+ <div className="border-b border-zinc-100 dark:border-zinc-800 last:border-0">
166
+ <div
167
+ className="cursor-pointer rounded-lg py-3.5 text-[15px] font-medium text-zinc-600 transition-colors duration-200 hover:bg-zinc-50 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-900/40 dark:hover:text-zinc-100"
168
+ style={{ paddingLeft: `${level * 1.5 + 1.5625}rem` }}
169
+ >
170
+ {title}
171
+ </div>
172
+ </div>
173
+ );
174
+ }
@@ -0,0 +1,103 @@
1
+ import React from "react";
2
+ import { CheckCircle2, TriangleAlert, Info, XCircle, X } from "lucide-react";
3
+
4
+ export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
5
+ variant?: "info" | "success" | "warning" | "error";
6
+ title?: string;
7
+ description?: string;
8
+ dismissible?: boolean;
9
+ onDismiss?: () => void;
10
+ action?: React.ReactNode;
11
+ }
12
+
13
+ const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
14
+ ({ className = "", variant = "info", title, description, dismissible, onDismiss, action, children, ...props }, ref) => {
15
+ const configs = {
16
+ info: {
17
+ bar: "bg-[#0070BA]",
18
+ icon: (
19
+ <Info className="h-5 w-5 text-[#0070BA] shrink-0" />
20
+ ),
21
+ },
22
+
23
+ success: {
24
+ bar: "bg-[#28A745]",
25
+ icon: (
26
+ <CheckCircle2 className="h-5 w-5 text-[#28A745] shrink-0" />
27
+ ),
28
+ },
29
+
30
+ warning: {
31
+ bar: "bg-[#E58E00]",
32
+ icon: (
33
+ <TriangleAlert className="h-5 w-5 text-[#E58E00] shrink-0" />
34
+ ),
35
+ },
36
+
37
+ error: {
38
+ bar: "bg-[#D93025]",
39
+ icon: (
40
+ <XCircle className="h-5 w-5 text-[#D93025] shrink-0" />
41
+ ),
42
+ },
43
+ };
44
+
45
+ const current = configs[variant];
46
+
47
+ return (
48
+ <div
49
+ ref={ref}
50
+ className={`relative flex w-full min-h-[64px] items-center gap-2.5 rounded-lg border border-gray-200 bg-white px-3 py-2.5 shadow-[0_3px_12px_rgba(0,0,0,0.03)] dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none ${className}`}
51
+ {...props}
52
+ >
53
+ <div
54
+ className={`absolute left-0 top-0 w-16 h-1 ${current.bar} rounded-tl-lg`}
55
+ style={{
56
+ clipPath: "polygon(0 0, 100% 0, 92% 100%, 0 100%)",
57
+ }}
58
+ />
59
+
60
+ {/* Icon */}
61
+ <div className="shrink-0 flex items-center justify-center">
62
+ {current.icon}
63
+ </div>
64
+
65
+ <div className="flex-1 min-w-0">
66
+ {title && (
67
+ <h5 className="text-[13px] font-semibold tracking-tight text-zinc-900 dark:text-white leading-tight">
68
+ {title}
69
+ </h5>
70
+ )}
71
+
72
+ {description && (
73
+ <p className="mt-0.5 text-[11px] font-medium text-zinc-500 dark:text-zinc-300 leading-tight">
74
+ {description}
75
+ </p>
76
+ )}
77
+
78
+ {children}
79
+ </div>
80
+
81
+ {action && (
82
+ <div className={`shrink-0 ml-auto ${dismissible || onDismiss ? "mr-5" : ""}`}>
83
+ {action}
84
+ </div>
85
+ )}
86
+
87
+ {/* Close button */}
88
+ {(dismissible || onDismiss) && (
89
+ <button
90
+ onClick={onDismiss}
91
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-200 transition-colors"
92
+ >
93
+ <X className="h-3.5 w-3.5 stroke-[3]" />
94
+ </button>
95
+ )}
96
+ </div>
97
+ );
98
+ }
99
+ );
100
+
101
+ Alert.displayName = "Alert";
102
+
103
+ export { Alert };