apex-ui-kit 2.6.0 → 2.8.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 (33) hide show
  1. package/ReadMe.md +32 -10
  2. package/dist/ApexUI-Kit/templates/AccordionMarquee/AccordionMarquee.jsx +340 -161
  3. package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +1 -1
  4. package/dist/ApexUI-Kit/templates/ArcGallery/ArcGallery.jsx +356 -0
  5. package/dist/ApexUI-Kit/templates/BounceCards/BounceCards.jsx +22 -2
  6. package/dist/ApexUI-Kit/templates/CircularCarousel/CircularCarousel.jsx +381 -0
  7. package/dist/ApexUI-Kit/templates/CommandPalette/CommandPalette.jsx +250 -0
  8. package/dist/ApexUI-Kit/templates/CrossedMarquee/CrossedMarquee.jsx +158 -0
  9. package/dist/ApexUI-Kit/templates/DitherWaves/DitherWaves.jsx +207 -0
  10. package/dist/ApexUI-Kit/templates/EdgeBlur/EdgeBlur.jsx +92 -0
  11. package/dist/ApexUI-Kit/templates/FlipClock/FlipClock.jsx +5 -1
  12. package/dist/ApexUI-Kit/templates/GlassLens/GlassLens.jsx +274 -0
  13. package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +2 -2
  14. package/dist/ApexUI-Kit/templates/HorizonGlow/HorizonGlow.jsx +259 -0
  15. package/dist/ApexUI-Kit/templates/ImageAccordion/ImageAccordion.jsx +116 -0
  16. package/dist/ApexUI-Kit/templates/InfiniteCanvas/InfiniteCanvas.jsx +230 -0
  17. package/dist/ApexUI-Kit/templates/MagneticButton/MagneticButton.jsx +69 -0
  18. package/dist/ApexUI-Kit/templates/Marquee3D/Marquee3D.jsx +68 -0
  19. package/dist/ApexUI-Kit/templates/NavDropDown/NavDropDown.jsx +123 -1
  20. package/dist/ApexUI-Kit/templates/OrbitIcons/OrbitIcons.jsx +169 -0
  21. package/dist/ApexUI-Kit/templates/ParticleText/ParticleText.jsx +328 -0
  22. package/dist/ApexUI-Kit/templates/RadialMenu/RadialMenu.jsx +119 -0
  23. package/dist/ApexUI-Kit/templates/ScrollStack/ScrollStack.jsx +88 -0
  24. package/dist/ApexUI-Kit/templates/SidebarNav/SidebarNav.jsx +22 -1
  25. package/dist/ApexUI-Kit/templates/SunriseBackground/SunriseBackground.jsx +290 -0
  26. package/dist/ApexUI-Kit/templates/TextRing3D/TextRing3D.jsx +18 -3
  27. package/dist/ApexUI-Kit/templates/ThemeSwitcher/ThemeSwitcher.jsx +215 -0
  28. package/dist/ApexUI-Kit/templates/ThemeSwitcher/themes.js +38 -0
  29. package/dist/ApexUI-Kit/templates/ToastStack/ToastStack.jsx +21 -13
  30. package/dist/ApexUI-Kit/templates/WireframeKnot3D/WireframeKnot3D.jsx +112 -0
  31. package/dist/cli/index.js +20 -0
  32. package/package.json +2 -2
  33. package/dist/ApexUI-Kit/templates/AccordionMarquee/AccordionMarquee.css +0 -1
@@ -0,0 +1,381 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { Color, DoubleSide, Group, Mesh, PerspectiveCamera, PlaneGeometry, Scene, ShaderMaterial, SRGBColorSpace, TextureLoader, Vector2, Vector3, WebGLRenderer } from "three";
3
+
4
+ /*
5
+ * Circular Carousel: image cards bent around a cylinder and drawn in WebGL.
6
+ * The ring drifts on its own; drag or flick to throw it, and it glides,
7
+ * then settles on a card. Hover pauses, a click brings that card to the
8
+ * front, arrows / keys step through. Cards turning away fade into the
9
+ * background and you can see their backs through the ring.
10
+ */
11
+
12
+ const IK = "https://ik.imagekit.io/ny6yinyut/apexui/assets";
13
+ const img = (n) => `${IK}/${n}.png?tr=f-auto,q-80,w-640`;
14
+ const DEFAULT_ITEMS = [
15
+ { src: img("spiderman"), title: "Spider-Man" },
16
+ { src: img("black-panther"), title: "Black Panther" },
17
+ { src: img("hulk"), title: "Hulk" },
18
+ { src: img("thanos"), title: "Thanos" },
19
+ { src: img("captainamerica"), title: "Captain America" },
20
+ { src: img("doctorStrange"), title: "Doctor Strange" },
21
+ { src: img("ironman"), title: "Iron Man" },
22
+ { src: img("spiderman"), title: "Spider-Man" },
23
+ { src: img("hulk"), title: "Hulk" },
24
+ ];
25
+
26
+ const VERT = `
27
+ uniform float uRadius; uniform float uBase;
28
+ varying vec2 vUv; varying float vFacing;
29
+ void main(){
30
+ vUv = uv;
31
+ // Bend the flat card onto the cylinder.
32
+ float a = uBase + position.x / uRadius;
33
+ vec3 p = vec3(sin(a) * uRadius, position.y, cos(a) * uRadius);
34
+ vec3 n = vec3(sin(a), 0.0, cos(a));
35
+ vFacing = (modelViewMatrix * vec4(n, 0.0)).z;
36
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
37
+ }`;
38
+
39
+ const FRAG = `
40
+ uniform sampler2D uTex; uniform float uReady; uniform vec2 uCover; uniform float uCorner;
41
+ uniform vec3 uFade; uniform float uDepth; uniform float uAspect; uniform float uInner;
42
+ varying vec2 vUv; varying float vFacing;
43
+ void main(){
44
+ vec2 uv = gl_FrontFacing ? vUv : vec2(1.0 - vUv.x, vUv.y);
45
+ // Rounded corners.
46
+ vec2 q = abs(uv - 0.5) * vec2(uAspect, 1.0);
47
+ vec2 b = vec2(uAspect, 1.0) * 0.5 - uCorner;
48
+ float d = length(max(q - b, 0.0)) - uCorner;
49
+ float edge = 1.0 - smoothstep(-0.004, 0.004, d);
50
+ if (edge <= 0.0) discard;
51
+ vec3 col = texture2D(uTex, (uv - 0.5) * uCover + 0.5).rgb;
52
+ // Backs of far cards melt into the background, so it works on light and dark.
53
+ if (!gl_FrontFacing) col = mix(col, uFade, uInner);
54
+ float away = clamp((1.0 - vFacing) * 0.5, 0.0, 1.0);
55
+ col = mix(col, uFade, uDepth * pow(away, 1.2));
56
+ gl_FragColor = vec4(col, edge * uReady);
57
+ }`;
58
+
59
+ const CircularCarousel = ({
60
+ items = DEFAULT_ITEMS,
61
+ cardWidth = 1,
62
+ aspectRatio = 0.8,
63
+ gap = 0.12,
64
+ speed = 12,
65
+ autoplay = true,
66
+ pauseOnHover = true,
67
+ snap = true,
68
+ tilt = -6,
69
+ depthFade = 0.6,
70
+ fadeColor = "#0a0a0a",
71
+ cornerRadius = 0.06,
72
+ captions = true,
73
+ height = "28rem",
74
+ onChange,
75
+ className = "",
76
+ }) => {
77
+ const wrap = useRef(null);
78
+ const api = useRef(null);
79
+ const [active, setActive] = useState(0);
80
+ const live = useRef({});
81
+ live.current = { speed, autoplay, pauseOnHover, snap, tilt, depthFade, fadeColor, onChange };
82
+ const list = items.length ? items : DEFAULT_ITEMS;
83
+ const key = list.map((i) => i.src).join("|");
84
+
85
+ useEffect(() => {
86
+ const el = wrap.current;
87
+ const n = list.length;
88
+ const step = (Math.PI * 2) / n;
89
+ const w = cardWidth;
90
+ const h = w / aspectRatio;
91
+ // Radius so the cards (plus gap) wrap the ring exactly.
92
+ const R = (n * w * (1 + gap)) / (Math.PI * 2);
93
+
94
+ const renderer = new WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
95
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
96
+ renderer.outputColorSpace = SRGBColorSpace;
97
+ Object.assign(renderer.domElement.style, { display: "block", width: "100%", height: "100%", touchAction: "pan-y" });
98
+ el.prepend(renderer.domElement);
99
+ const scene = new Scene();
100
+ const camera = new PerspectiveCamera(32, 1, 0.1, 200);
101
+ const ring = new Group();
102
+ scene.add(ring);
103
+
104
+ const geometry = new PlaneGeometry(w, h, 32, 1);
105
+ const loader = new TextureLoader().setCrossOrigin("anonymous");
106
+ const fade = new Color(live.current.fadeColor);
107
+ const materials = list.map((item, i) => {
108
+ const m = new ShaderMaterial({
109
+ vertexShader: VERT,
110
+ fragmentShader: FRAG,
111
+ transparent: true,
112
+ side: DoubleSide,
113
+ uniforms: {
114
+ uRadius: { value: R },
115
+ uBase: { value: i * step },
116
+ uTex: { value: null },
117
+ uReady: { value: 0 },
118
+ uCover: { value: new Vector2(1, 1) },
119
+ uCorner: { value: cornerRadius },
120
+ uFade: { value: fade },
121
+ uDepth: { value: depthFade },
122
+ uAspect: { value: aspectRatio },
123
+ uInner: { value: 0.72 },
124
+ },
125
+ });
126
+ loader.load(item.src, (t) => {
127
+ t.colorSpace = SRGBColorSpace;
128
+ m.uniforms.uTex.value = t;
129
+ const ia = t.image.width / t.image.height;
130
+ // Cover crop: show the middle of the image at the card's shape.
131
+ m.uniforms.uCover.value.set(ia > aspectRatio ? aspectRatio / ia : 1, ia > aspectRatio ? 1 : ia / aspectRatio);
132
+ m.userData.loadedAt = performance.now();
133
+ wake();
134
+ });
135
+ const mesh = new Mesh(geometry, m);
136
+ mesh.renderOrder = 0;
137
+ ring.add(mesh);
138
+ return m;
139
+ });
140
+
141
+ const fit = () => {
142
+ const { width, height: hh } = el.getBoundingClientRect();
143
+ if (!width || !hh) return;
144
+ renderer.setSize(width, hh, false);
145
+ camera.aspect = width / hh;
146
+ const vfov = (camera.fov * Math.PI) / 180;
147
+ const hfov = 2 * Math.atan(Math.tan(vfov / 2) * camera.aspect);
148
+ // Distance so the whole ring fits the width and the cards fit the height.
149
+ const dW = (R * 0.98) / Math.tan(hfov / 2);
150
+ const dH = (h * 0.75) / Math.tan(vfov / 2);
151
+ // On narrow screens let the sides crop rather than shrink the front card below half the width.
152
+ const dCard = R + w / (2 * 0.5 * Math.tan(hfov / 2));
153
+ camera.userData.dist = Math.max(Math.min(R + dW, dCard), R + dH);
154
+ camera.updateProjectionMatrix();
155
+ wake();
156
+ };
157
+ const ro = new ResizeObserver(fit);
158
+ ro.observe(el);
159
+
160
+ const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
161
+ const s = { angle: 0, vel: 0, target: null, hover: false, drag: null, px: 0, py: 0, yaw: 0, pitch: 0, dir: -1, hold: 0 };
162
+ let raf = 0;
163
+ let last = 0;
164
+ let visible = true;
165
+ let shown = -1;
166
+
167
+ const nearest = (a) => Math.round(a / step) * step;
168
+ const frontIndex = () => ((Math.round(-s.angle / step) % n) + n) % n;
169
+
170
+ const frame = (now) => {
171
+ raf = 0;
172
+ const o = live.current;
173
+ const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
174
+ last = now;
175
+ const paused = (o.pauseOnHover && s.hover) || s.drag || now < s.hold || reduced;
176
+ const cruise = o.autoplay && !paused ? ((o.speed * Math.PI) / 180) * s.dir : 0;
177
+ let busy = !!s.drag || cruise !== 0;
178
+ if (!s.drag) {
179
+ if (s.target !== null) {
180
+ // Critically damped spring onto the target card.
181
+ const k = 90;
182
+ const c = 2 * Math.sqrt(k);
183
+ s.vel += (k * (s.target - s.angle) - c * s.vel) * dt;
184
+ s.angle += s.vel * dt;
185
+ if (Math.abs(s.target - s.angle) < 0.0005 && Math.abs(s.vel) < 0.001) {
186
+ s.angle = s.target;
187
+ s.vel = 0;
188
+ s.target = null;
189
+ }
190
+ busy = true;
191
+ } else {
192
+ s.vel += (cruise - s.vel) * (1 - Math.exp(-dt / 0.6));
193
+ s.angle += s.vel * dt;
194
+ if (!cruise && o.snap && Math.abs(s.vel) < 0.25) s.target = nearest(s.angle);
195
+ if (Math.abs(s.vel) > 0.0005 || s.target !== null) busy = true;
196
+ }
197
+ }
198
+ s.yaw += (s.px * 0.08 - s.yaw) * (1 - Math.exp(-dt / 0.3));
199
+ s.pitch += (s.py * 0.05 - s.pitch) * (1 - Math.exp(-dt / 0.3));
200
+ if (Math.abs(s.px * 0.08 - s.yaw) > 0.0005) busy = true;
201
+
202
+ ring.rotation.y = s.angle;
203
+ const d = camera.userData.dist || R * 3;
204
+ const t = ((o.tilt * Math.PI) / 180) + s.pitch;
205
+ camera.position.set(Math.sin(s.yaw) * d, -Math.sin(t) * d, Math.cos(s.yaw) * Math.cos(t) * d);
206
+ camera.lookAt(0, 0, 0);
207
+ fade.set(o.fadeColor);
208
+ materials.forEach((m) => {
209
+ m.uniforms.uDepth.value = o.depthFade;
210
+ const at = m.userData.loadedAt;
211
+ if (at) {
212
+ m.uniforms.uReady.value = Math.min(1, (now - at) / 400);
213
+ if (m.uniforms.uReady.value < 1) busy = true;
214
+ }
215
+ });
216
+ renderer.render(scene, camera);
217
+
218
+ const idx = frontIndex();
219
+ if (idx !== shown) {
220
+ shown = idx;
221
+ setActive(idx);
222
+ o.onChange?.(idx);
223
+ }
224
+ if (busy && visible) raf = requestAnimationFrame(frame);
225
+ else last = 0;
226
+ };
227
+ function wake() {
228
+ if (!raf && visible) raf = requestAnimationFrame(frame);
229
+ }
230
+
231
+ const goTo = (i) => {
232
+ let target = -i * step;
233
+ target += Math.PI * 2 * Math.round((s.angle - target) / (Math.PI * 2));
234
+ s.target = target;
235
+ s.hold = performance.now() + 2500;
236
+ wake();
237
+ };
238
+ api.current = {
239
+ next: () => goTo(frontIndex() + 1),
240
+ prev: () => goTo(frontIndex() - 1),
241
+ goTo,
242
+ };
243
+
244
+ const canvas = renderer.domElement;
245
+ const onDown = (e) => {
246
+ if (e.button !== 0) return;
247
+ s.drag = { x: e.clientX, y: e.clientY, a: s.angle, moved: false, samples: [{ t: performance.now(), a: s.angle }] };
248
+ s.target = null;
249
+ wake();
250
+ };
251
+ const onMove = (e) => {
252
+ const r = canvas.getBoundingClientRect();
253
+ s.px = ((e.clientX - r.left) / r.width) * 2 - 1;
254
+ s.py = ((e.clientY - r.top) / r.height) * 2 - 1;
255
+ const g = s.drag;
256
+ if (g) {
257
+ const dx = e.clientX - g.x;
258
+ if (!g.moved && Math.abs(dx) > 5) {
259
+ g.moved = true;
260
+ canvas.setPointerCapture(e.pointerId);
261
+ }
262
+ if (g.moved) {
263
+ // Drag speed follows the ring's on-screen size.
264
+ s.angle = g.a + (dx / r.width) * 2.4;
265
+ const now = performance.now();
266
+ g.samples.push({ t: now, a: s.angle });
267
+ while (g.samples.length > 2 && now - g.samples[0].t > 100) g.samples.shift();
268
+ s.vel = 0;
269
+ }
270
+ }
271
+ wake();
272
+ };
273
+ const onUp = (e) => {
274
+ const g = s.drag;
275
+ if (!g) return;
276
+ s.drag = null;
277
+ if (g.moved) {
278
+ const a = g.samples[0];
279
+ const b = g.samples[g.samples.length - 1];
280
+ const span = (b.t - a.t) / 1000;
281
+ s.vel = span > 0.01 ? Math.max(-12, Math.min(12, (b.a - a.a) / span)) : 0;
282
+ if (Math.abs(s.vel) > 0.6) s.dir = Math.sign(s.vel);
283
+ s.hold = performance.now() + 1200;
284
+ } else {
285
+ // A click: the front-facing card whose center lands nearest the pointer.
286
+ const r = canvas.getBoundingClientRect();
287
+ const x = ((e.clientX - r.left) / r.width - 0.5) * 2;
288
+ const p = new Vector3();
289
+ let best = -1;
290
+ let bestDx = Infinity;
291
+ for (let i = 0; i < n; i++) {
292
+ const a = i * step + s.angle;
293
+ if (Math.cos(a) < 0.15) continue;
294
+ const dx = Math.abs(p.set(Math.sin(a) * R, 0, Math.cos(a) * R).project(camera).x - x);
295
+ if (dx < bestDx) {
296
+ bestDx = dx;
297
+ best = i;
298
+ }
299
+ }
300
+ if (best >= 0) goTo(best);
301
+ }
302
+ wake();
303
+ };
304
+ const onEnter = () => {
305
+ s.hover = true;
306
+ wake();
307
+ };
308
+ const onLeave = () => {
309
+ s.hover = false;
310
+ s.px = 0;
311
+ s.py = 0;
312
+ if (s.drag) onUp({ clientX: 0, clientY: 0 });
313
+ wake();
314
+ };
315
+ canvas.addEventListener("pointerdown", onDown);
316
+ canvas.addEventListener("pointermove", onMove);
317
+ canvas.addEventListener("pointerup", onUp);
318
+ canvas.addEventListener("pointercancel", onUp);
319
+ canvas.addEventListener("pointerenter", onEnter);
320
+ canvas.addEventListener("pointerleave", onLeave);
321
+ const io = new IntersectionObserver(([entry]) => {
322
+ visible = entry.isIntersecting;
323
+ wake();
324
+ });
325
+ io.observe(el);
326
+ fit();
327
+
328
+ return () => {
329
+ cancelAnimationFrame(raf);
330
+ ro.disconnect();
331
+ io.disconnect();
332
+ canvas.removeEventListener("pointerdown", onDown);
333
+ canvas.removeEventListener("pointermove", onMove);
334
+ canvas.removeEventListener("pointerup", onUp);
335
+ canvas.removeEventListener("pointercancel", onUp);
336
+ canvas.removeEventListener("pointerenter", onEnter);
337
+ canvas.removeEventListener("pointerleave", onLeave);
338
+ materials.forEach((m) => {
339
+ m.uniforms.uTex.value?.dispose();
340
+ m.dispose();
341
+ });
342
+ geometry.dispose();
343
+ renderer.dispose();
344
+ renderer.forceContextLoss();
345
+ canvas.remove();
346
+ api.current = null;
347
+ };
348
+ // Rebuild when the cards or their shape change; motion settings are live.
349
+ // eslint-disable-next-line react-hooks/exhaustive-deps
350
+ }, [key, cardWidth, aspectRatio, gap, cornerRadius]);
351
+
352
+ const current = list[active] ?? list[0];
353
+ return (
354
+ <div
355
+ ref={wrap}
356
+ role="region"
357
+ aria-roledescription="carousel"
358
+ aria-label="Image carousel"
359
+ tabIndex={0}
360
+ onKeyDown={(e) => {
361
+ if (e.key === "ArrowRight") api.current?.next();
362
+ else if (e.key === "ArrowLeft") api.current?.prev();
363
+ else return;
364
+ e.preventDefault();
365
+ }}
366
+ className={`relative w-full cursor-grab select-none outline-none active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-white/30 ${className}`}
367
+ style={{ height }}
368
+ >
369
+ {captions && current && (
370
+ <div className="pointer-events-none absolute inset-x-0 bottom-3 text-center" aria-live="polite">
371
+ <p className="text-sm font-semibold">{current.title}</p>
372
+ <p className="font-mono text-[0.7rem] tabular-nums opacity-50">
373
+ {String(active + 1).padStart(2, "0")} / {String(list.length).padStart(2, "0")}
374
+ </p>
375
+ </div>
376
+ )}
377
+ </div>
378
+ );
379
+ };
380
+
381
+ export default CircularCarousel;
@@ -0,0 +1,250 @@
1
+ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { CornerDownLeft, FileText, Github, LayoutGrid, LogOut, Moon, Plus, Search, Settings, Sparkles, User } from "lucide-react";
3
+
4
+ /*
5
+ * Command Palette: a ⌘K / Ctrl+K search box for jumping anywhere. Typing
6
+ * filters with a forgiving fuzzy match (letters in order, gaps allowed) and
7
+ * highlights the matched letters; arrow keys move a highlight that slides
8
+ * between rows, Enter runs the command, Escape closes. The panel eases in and
9
+ * out with CSS transitions only, so it never blinks.
10
+ */
11
+
12
+ const DEFAULT_GROUPS = [
13
+ {
14
+ heading: "Suggestions",
15
+ items: [
16
+ { id: "new", label: "Create new project", icon: Plus, shortcut: "N" },
17
+ { id: "components", label: "Browse components", icon: LayoutGrid, hint: "107 ready to use" },
18
+ { id: "ai", label: "Ask AI to build a section", icon: Sparkles, keywords: "assistant generate" },
19
+ ],
20
+ },
21
+ {
22
+ heading: "Pages",
23
+ items: [
24
+ { id: "docs", label: "Documentation", icon: FileText },
25
+ { id: "github", label: "Open GitHub", icon: Github, keywords: "repo source" },
26
+ ],
27
+ },
28
+ {
29
+ heading: "Settings",
30
+ items: [
31
+ { id: "profile", label: "Profile", icon: User, shortcut: "P" },
32
+ { id: "theme", label: "Toggle dark mode", icon: Moon, keywords: "theme light" },
33
+ { id: "settings", label: "Preferences", icon: Settings, shortcut: "," },
34
+ { id: "logout", label: "Log out", icon: LogOut },
35
+ ],
36
+ },
37
+ ];
38
+
39
+ // Letters in order, gaps allowed. Lower score is better; returns matched indexes.
40
+ function fuzzy(text, query) {
41
+ if (!query) return { score: 0, hits: [] };
42
+ const t = text.toLowerCase();
43
+ const q = query.toLowerCase();
44
+ let ti = 0;
45
+ let score = 0;
46
+ let prev = -1;
47
+ const hits = [];
48
+ for (const ch of q) {
49
+ if (ch === " ") continue;
50
+ const at = t.indexOf(ch, ti);
51
+ if (at < 0) return null;
52
+ score += at - prev - 1 + (at === 0 || t[at - 1] === " " ? 0 : 0.5);
53
+ hits.push(at);
54
+ prev = at;
55
+ ti = at + 1;
56
+ }
57
+ return { score, hits };
58
+ }
59
+
60
+ const Highlight = ({ text, hits }) => {
61
+ if (!hits.length) return text;
62
+ const set = new Set(hits);
63
+ return [...text].map((ch, i) => (set.has(i) ? <mark key={i} className="bg-transparent font-semibold text-[var(--cp-accent)]">{ch}</mark> : ch));
64
+ };
65
+
66
+ const CommandPalette = ({ groups = DEFAULT_GROUPS, placeholder = "Type a command or search…", hotkey = "k", accentColor = "#a3e635", theme = "dark", contained = false, triggerLabel = "Search", defaultOpen = false, onSelect, className = "" }) => {
67
+ const [open, setOpen] = useState(defaultOpen);
68
+ const [mounted, setMounted] = useState(defaultOpen);
69
+ const [shown, setShown] = useState(defaultOpen);
70
+ const [query, setQuery] = useState("");
71
+ const [active, setActive] = useState(0);
72
+ const [done, setDone] = useState(null);
73
+ const input = useRef(null);
74
+ const listRef = useRef(null);
75
+ const bar = useRef(null);
76
+ const rows = useRef([]);
77
+ const dark = theme !== "light";
78
+
79
+ const results = useMemo(() => {
80
+ const out = [];
81
+ groups.forEach((g) => {
82
+ const items = g.items
83
+ .map((it) => {
84
+ const m = fuzzy(it.label, query) ?? (it.keywords && fuzzy(it.keywords, query) ? { score: 50, hits: [] } : null);
85
+ return m && { ...it, ...m };
86
+ })
87
+ .filter(Boolean)
88
+ .sort((a, b) => a.score - b.score);
89
+ if (items.length) out.push({ heading: g.heading, items });
90
+ });
91
+ return out;
92
+ }, [groups, query]);
93
+ const flat = useMemo(() => results.flatMap((g) => g.items), [results]);
94
+
95
+ // Mount, then show on the next frame so the enter transition runs; hide, then unmount.
96
+ useEffect(() => {
97
+ if (open) {
98
+ setMounted(true);
99
+ const id = requestAnimationFrame(() => requestAnimationFrame(() => setShown(true)));
100
+ return () => cancelAnimationFrame(id);
101
+ }
102
+ setShown(false);
103
+ const t = setTimeout(() => setMounted(false), 220);
104
+ return () => clearTimeout(t);
105
+ }, [open]);
106
+
107
+ useEffect(() => {
108
+ if (shown) input.current?.focus({ preventScroll: true });
109
+ }, [shown]);
110
+
111
+ useEffect(() => {
112
+ const onKey = (e) => {
113
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === hotkey) {
114
+ e.preventDefault();
115
+ setOpen((o) => !o);
116
+ }
117
+ };
118
+ window.addEventListener("keydown", onKey);
119
+ return () => window.removeEventListener("keydown", onKey);
120
+ }, [hotkey]);
121
+
122
+ useEffect(() => setActive(0), [query]);
123
+
124
+ // Slide the highlight to the active row and keep it in view.
125
+ useLayoutEffect(() => {
126
+ const row = rows.current[active];
127
+ const b = bar.current;
128
+ if (!row || !b) {
129
+ if (b) b.style.opacity = "0";
130
+ return;
131
+ }
132
+ b.style.opacity = "1";
133
+ b.style.transform = `translateY(${row.offsetTop}px)`;
134
+ b.style.height = `${row.offsetHeight}px`;
135
+ row.scrollIntoView({ block: "nearest" });
136
+ }, [active, flat.length, mounted]);
137
+
138
+ const close = useCallback(() => {
139
+ setOpen(false);
140
+ setTimeout(() => setQuery(""), 220);
141
+ }, []);
142
+ const run = (item) => {
143
+ if (!item) return;
144
+ onSelect?.(item);
145
+ setDone(item.label);
146
+ setTimeout(() => setDone(null), 1600);
147
+ close();
148
+ };
149
+
150
+ const onKeyDown = (e) => {
151
+ if (e.key === "ArrowDown") setActive((a) => (flat.length ? (a + 1) % flat.length : 0));
152
+ else if (e.key === "ArrowUp") setActive((a) => (flat.length ? (a - 1 + flat.length) % flat.length : 0));
153
+ else if (e.key === "Home") setActive(0);
154
+ else if (e.key === "End") setActive(Math.max(0, flat.length - 1));
155
+ else if (e.key === "Enter") run(flat[active]);
156
+ else if (e.key === "Escape") close();
157
+ else return;
158
+ e.preventDefault();
159
+ };
160
+
161
+ const t = dark
162
+ ? { panel: "border-white/10 bg-neutral-950/90 text-white", muted: "text-white/45", line: "border-white/10", row: "text-white/85", hl: "bg-white/[0.07]", kbd: "border-white/15 bg-white/5 text-white/60", trigger: "border-white/10 bg-neutral-900 text-white/70 hover:text-white" }
163
+ : { panel: "border-black/10 bg-white/95 text-neutral-900", muted: "text-neutral-400", line: "border-black/10", row: "text-neutral-700", hl: "bg-black/[0.05]", kbd: "border-black/10 bg-black/[0.03] text-neutral-500", trigger: "border-black/10 bg-white text-neutral-500 hover:text-neutral-900" };
164
+ let n = -1;
165
+ const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
166
+
167
+ return (
168
+ <div className={className} style={{ "--cp-accent": accentColor }}>
169
+ <button type="button" onClick={() => setOpen(true)} className={`inline-flex w-full max-w-xs items-center gap-2.5 rounded-xl border px-3.5 py-2.5 text-sm transition-colors ${t.trigger}`}>
170
+ <Search className="h-4 w-4" />
171
+ <span className="flex-1 text-left">{triggerLabel}</span>
172
+ <kbd className={`rounded-md border px-1.5 py-0.5 font-mono text-[0.68rem] ${t.kbd}`}>{isMac ? "⌘" : "Ctrl"} {hotkey.toUpperCase()}</kbd>
173
+ </button>
174
+ {done && <p className={`mt-3 text-center text-xs ${t.muted}`}>Ran “{done}”</p>}
175
+
176
+ {mounted && (
177
+ <div className={`${contained ? "absolute" : "fixed"} inset-0 z-[300] flex items-start justify-center px-3 pt-[12vh]`} onPointerDown={(e) => e.target === e.currentTarget && close()}>
178
+ <div aria-hidden="true" className="absolute inset-0 bg-black/50 backdrop-blur-[2px] transition-opacity duration-200 ease-out" style={{ opacity: shown ? 1 : 0 }} onPointerDown={close} />
179
+ <div
180
+ role="dialog"
181
+ aria-modal="true"
182
+ aria-label="Command palette"
183
+ className={`relative w-full max-w-lg overflow-hidden rounded-2xl border shadow-[0_30px_80px_-20px_rgba(0,0,0,0.6)] backdrop-blur-xl ${t.panel}`}
184
+ style={{ opacity: shown ? 1 : 0, transform: shown ? "none" : "translateY(-10px) scale(0.97)", transition: "opacity 180ms ease-out, transform 260ms cubic-bezier(0.22, 1, 0.36, 1)" }}
185
+ >
186
+ <div className={`flex items-center gap-3 border-b px-4 ${t.line}`}>
187
+ <Search className={`h-4 w-4 shrink-0 ${t.muted}`} />
188
+ <input
189
+ ref={input}
190
+ value={query}
191
+ onChange={(e) => setQuery(e.target.value)}
192
+ onKeyDown={onKeyDown}
193
+ placeholder={placeholder}
194
+ role="combobox"
195
+ aria-expanded="true"
196
+ aria-controls="cp-list"
197
+ aria-activedescendant={flat[active] ? `cp-${flat[active].id}` : undefined}
198
+ className="h-12 w-full bg-transparent text-[0.95rem] outline-none placeholder:opacity-50"
199
+ />
200
+ <kbd className={`hidden rounded-md border px-1.5 py-0.5 font-mono text-[0.65rem] sm:block ${t.kbd}`}>ESC</kbd>
201
+ </div>
202
+ <div ref={listRef} id="cp-list" role="listbox" className="relative max-h-[min(22rem,55vh)] overflow-y-auto overscroll-contain p-2 [scrollbar-width:thin]">
203
+ <div ref={bar} aria-hidden="true" className={`pointer-events-none absolute inset-x-2 top-0 rounded-xl ${t.hl}`} style={{ transition: "transform 180ms cubic-bezier(0.22, 1, 0.36, 1), height 180ms ease, opacity 120ms" }}>
204
+ <span className="absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-full bg-[var(--cp-accent)]" />
205
+ </div>
206
+ {results.length === 0 && <p className={`px-3 py-10 text-center text-sm ${t.muted}`}>No results for “{query}”</p>}
207
+ {results.map((g) => (
208
+ <div key={g.heading} role="group" aria-label={g.heading}>
209
+ <p className={`px-3 pb-1 pt-2.5 text-[0.68rem] font-semibold uppercase tracking-[0.14em] ${t.muted}`}>{g.heading}</p>
210
+ {g.items.map((it) => {
211
+ n += 1;
212
+ const i = n;
213
+ const Icon = it.icon;
214
+ return (
215
+ <div
216
+ key={it.id}
217
+ id={`cp-${it.id}`}
218
+ ref={(e) => (rows.current[i] = e)}
219
+ role="option"
220
+ aria-selected={i === active}
221
+ onPointerMove={() => i !== active && setActive(i)}
222
+ onClick={() => run(it)}
223
+ className={`relative flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-sm ${t.row}`}
224
+ >
225
+ {Icon && <Icon className={`h-4 w-4 shrink-0 transition-colors ${i === active ? "text-[var(--cp-accent)]" : t.muted}`} />}
226
+ <span className="flex-1 truncate">
227
+ <Highlight text={it.label} hits={it.hits} />
228
+ {it.hint && <span className={`ml-2 text-xs ${t.muted}`}>{it.hint}</span>}
229
+ </span>
230
+ {it.shortcut && <kbd className={`rounded-md border px-1.5 py-0.5 font-mono text-[0.65rem] ${t.kbd}`}>{isMac ? "⌘" : "Ctrl"} {it.shortcut}</kbd>}
231
+ {i === active && <CornerDownLeft className={`h-3.5 w-3.5 ${t.muted}`} />}
232
+ </div>
233
+ );
234
+ })}
235
+ </div>
236
+ ))}
237
+ </div>
238
+ <div className={`flex items-center gap-4 border-t px-4 py-2.5 text-[0.7rem] ${t.line} ${t.muted}`}>
239
+ <span>↑↓ to move</span>
240
+ <span>↵ to run</span>
241
+ <span className="ml-auto">esc to close</span>
242
+ </div>
243
+ </div>
244
+ </div>
245
+ )}
246
+ </div>
247
+ );
248
+ };
249
+
250
+ export default CommandPalette;