apex-ui-kit 2.6.0 → 2.7.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/ReadMe.md +22 -10
- package/dist/ApexUI-Kit/templates/AccordionMarquee/AccordionMarquee.jsx +340 -161
- package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +1 -1
- package/dist/ApexUI-Kit/templates/ArcGallery/ArcGallery.jsx +356 -0
- package/dist/ApexUI-Kit/templates/BounceCards/BounceCards.jsx +22 -2
- package/dist/ApexUI-Kit/templates/CircularCarousel/CircularCarousel.jsx +381 -0
- package/dist/ApexUI-Kit/templates/EdgeBlur/EdgeBlur.jsx +92 -0
- package/dist/ApexUI-Kit/templates/FlipClock/FlipClock.jsx +5 -1
- package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +2 -2
- package/dist/ApexUI-Kit/templates/HorizonGlow/HorizonGlow.jsx +259 -0
- package/dist/ApexUI-Kit/templates/MagneticButton/MagneticButton.jsx +69 -0
- package/dist/ApexUI-Kit/templates/Marquee3D/Marquee3D.jsx +68 -0
- package/dist/ApexUI-Kit/templates/NavDropDown/NavDropDown.jsx +123 -1
- package/dist/ApexUI-Kit/templates/ParticleText/ParticleText.jsx +328 -0
- package/dist/ApexUI-Kit/templates/RadialMenu/RadialMenu.jsx +103 -0
- package/dist/ApexUI-Kit/templates/ScrollStack/ScrollStack.jsx +88 -0
- package/dist/ApexUI-Kit/templates/SidebarNav/SidebarNav.jsx +22 -1
- package/dist/ApexUI-Kit/templates/SunriseBackground/SunriseBackground.jsx +290 -0
- package/dist/ApexUI-Kit/templates/WireframeKnot3D/WireframeKnot3D.jsx +112 -0
- package/dist/cli/index.js +11 -0
- package/package.json +2 -2
- package/dist/ApexUI-Kit/templates/AccordionMarquee/AccordionMarquee.css +0 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Particle Text: your text rebuilt from thousands of tiny particles on a
|
|
5
|
+
* canvas. They fly in to form the words (assemble, fall, explode or wave),
|
|
6
|
+
* move out of the pointer's way, and a click sends a shockwave through them
|
|
7
|
+
* before they spring back. Moving particles light up in the accent color.
|
|
8
|
+
* When everything has settled and the pointer is away, the loop sleeps.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const TAU = Math.PI * 2;
|
|
12
|
+
|
|
13
|
+
// Points where the text is drawn, sampled from an offscreen canvas.
|
|
14
|
+
function sampleText(text, width, height, weight, family, maxSize, gap) {
|
|
15
|
+
const off = document.createElement("canvas");
|
|
16
|
+
off.width = Math.max(1, Math.floor(width));
|
|
17
|
+
off.height = Math.max(1, Math.floor(height));
|
|
18
|
+
const c = off.getContext("2d", { willReadFrequently: true });
|
|
19
|
+
let size = Math.min(maxSize, height * 0.7);
|
|
20
|
+
c.font = `${weight} ${size}px ${family}`;
|
|
21
|
+
const measured = c.measureText(text).width;
|
|
22
|
+
// Shrink to fit 90% of the width.
|
|
23
|
+
if (measured > width * 0.9) size *= (width * 0.9) / measured;
|
|
24
|
+
c.font = `${weight} ${size}px ${family}`;
|
|
25
|
+
c.textAlign = "center";
|
|
26
|
+
c.textBaseline = "middle";
|
|
27
|
+
c.fillStyle = "#fff";
|
|
28
|
+
c.fillText(text, width / 2, height / 2);
|
|
29
|
+
const data = c.getImageData(0, 0, off.width, off.height).data;
|
|
30
|
+
const pts = [];
|
|
31
|
+
for (let y = 0; y < off.height; y += gap) {
|
|
32
|
+
for (let x = 0; x < off.width; x += gap) {
|
|
33
|
+
if (data[(y * off.width + x) * 4 + 3] > 120) pts.push(x, y);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return pts;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ParticleText = ({
|
|
40
|
+
text = "ApexUI",
|
|
41
|
+
fontSize = 9,
|
|
42
|
+
fontWeight = 800,
|
|
43
|
+
fontFamily,
|
|
44
|
+
particleSize = 2,
|
|
45
|
+
gap = 4,
|
|
46
|
+
color = "currentColor",
|
|
47
|
+
accentColor = "#a3e635",
|
|
48
|
+
intro = "assemble",
|
|
49
|
+
replayOn = "none",
|
|
50
|
+
repelRadius = 90,
|
|
51
|
+
repelStrength = 1,
|
|
52
|
+
clickBurst = true,
|
|
53
|
+
height = "20rem",
|
|
54
|
+
className = "",
|
|
55
|
+
}) => {
|
|
56
|
+
const wrap = useRef(null);
|
|
57
|
+
const canvas = useRef(null);
|
|
58
|
+
const live = useRef({ particleSize, accentColor, color, repelRadius, repelStrength, clickBurst, replayOn, intro });
|
|
59
|
+
live.current = { particleSize, accentColor, color, repelRadius, repelStrength, clickBurst, replayOn, intro };
|
|
60
|
+
const wakeRef = useRef(() => {});
|
|
61
|
+
|
|
62
|
+
// Prop changes that don't need a rebuild just wake the loop to redraw.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
wakeRef.current();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const el = wrap.current;
|
|
69
|
+
const cv = canvas.current;
|
|
70
|
+
const ctx = cv.getContext("2d");
|
|
71
|
+
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
72
|
+
let P = new Float32Array(0); // per particle: x, y, vx, vy, tx, ty, delay, seed
|
|
73
|
+
let n = 0;
|
|
74
|
+
let w = 0;
|
|
75
|
+
let h = 0;
|
|
76
|
+
let dpr = 1;
|
|
77
|
+
let raf = 0;
|
|
78
|
+
let last = 0;
|
|
79
|
+
let started = 0;
|
|
80
|
+
let alive = true;
|
|
81
|
+
let visible = true;
|
|
82
|
+
let built = "";
|
|
83
|
+
const pointer = { x: -9999, y: -9999, on: false };
|
|
84
|
+
|
|
85
|
+
const startPositions = (mode) => {
|
|
86
|
+
started = performance.now();
|
|
87
|
+
for (let i = 0; i < n; i++) {
|
|
88
|
+
const o = i * 8;
|
|
89
|
+
const tx = P[o + 4];
|
|
90
|
+
const ty = P[o + 5];
|
|
91
|
+
const seed = P[o + 7];
|
|
92
|
+
let x = tx;
|
|
93
|
+
let y = ty;
|
|
94
|
+
let delay = 0;
|
|
95
|
+
if (reduced) {
|
|
96
|
+
// Appear in place.
|
|
97
|
+
} else if (mode === "fall") {
|
|
98
|
+
x = tx + (seed - 0.5) * 30;
|
|
99
|
+
y = -20 - seed * h * 0.6;
|
|
100
|
+
delay = seed * 500;
|
|
101
|
+
} else if (mode === "explode") {
|
|
102
|
+
const a = seed * TAU * 7;
|
|
103
|
+
x = w / 2 + Math.cos(a) * 4;
|
|
104
|
+
y = h / 2 + Math.sin(a) * 4;
|
|
105
|
+
delay = seed * 200;
|
|
106
|
+
} else if (mode === "wave") {
|
|
107
|
+
x = tx - w * 0.6;
|
|
108
|
+
y = ty + Math.sin(tx * 0.04) * 40;
|
|
109
|
+
delay = (tx / w) * 700;
|
|
110
|
+
} else {
|
|
111
|
+
x = Math.random() * w;
|
|
112
|
+
y = Math.random() * h;
|
|
113
|
+
delay = seed * 350;
|
|
114
|
+
}
|
|
115
|
+
P[o] = x;
|
|
116
|
+
P[o + 1] = y;
|
|
117
|
+
P[o + 2] = mode === "explode" && !reduced ? (Math.random() - 0.5) * 900 : 0;
|
|
118
|
+
P[o + 3] = mode === "explode" && !reduced ? (Math.random() - 0.5) * 900 : 0;
|
|
119
|
+
P[o + 6] = delay;
|
|
120
|
+
}
|
|
121
|
+
wake();
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const build = (withIntro) => {
|
|
125
|
+
const r = el.getBoundingClientRect();
|
|
126
|
+
if (!r.width || !r.height) return;
|
|
127
|
+
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
128
|
+
w = r.width;
|
|
129
|
+
h = r.height;
|
|
130
|
+
cv.width = Math.round(w * dpr);
|
|
131
|
+
cv.height = Math.round(h * dpr);
|
|
132
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
133
|
+
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
134
|
+
const family = fontFamily || getComputedStyle(el).fontFamily || "sans-serif";
|
|
135
|
+
const pts = sampleText(String(text || " "), w, h, fontWeight, family, fontSize * rem, Math.max(2, Math.round(gap)));
|
|
136
|
+
// Cap the count so huge containers stay smooth.
|
|
137
|
+
const cap = 6000;
|
|
138
|
+
const stride = Math.max(1, Math.ceil(pts.length / 2 / cap));
|
|
139
|
+
n = Math.floor(pts.length / 2 / stride);
|
|
140
|
+
const old = P;
|
|
141
|
+
const oldN = old.length / 8;
|
|
142
|
+
P = new Float32Array(n * 8);
|
|
143
|
+
for (let i = 0; i < n; i++) {
|
|
144
|
+
const o = i * 8;
|
|
145
|
+
const s = i * stride * 2;
|
|
146
|
+
P[o + 4] = pts[s];
|
|
147
|
+
P[o + 5] = pts[s + 1];
|
|
148
|
+
P[o + 7] = ((i * 9301 + 49297) % 233280) / 233280;
|
|
149
|
+
// Keep current positions on resize so nothing jumps.
|
|
150
|
+
if (!withIntro && i < oldN) {
|
|
151
|
+
P[o] = old[i * 8];
|
|
152
|
+
P[o + 1] = old[i * 8 + 1];
|
|
153
|
+
} else {
|
|
154
|
+
P[o] = pts[s];
|
|
155
|
+
P[o + 1] = pts[s + 1];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (withIntro) startPositions(live.current.intro);
|
|
159
|
+
wake();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const toRgb = (() => {
|
|
163
|
+
const c = document.createElement("canvas").getContext("2d");
|
|
164
|
+
return (value) => {
|
|
165
|
+
c.fillStyle = "#000";
|
|
166
|
+
c.fillStyle = value;
|
|
167
|
+
const hex = c.fillStyle;
|
|
168
|
+
if (hex.startsWith("#")) return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
|
|
169
|
+
const m = hex.match(/\d+(\.\d+)?/g) || [0, 0, 0];
|
|
170
|
+
return [+m[0], +m[1], +m[2]];
|
|
171
|
+
};
|
|
172
|
+
})();
|
|
173
|
+
|
|
174
|
+
const frame = (now) => {
|
|
175
|
+
raf = 0;
|
|
176
|
+
if (!alive) return;
|
|
177
|
+
const o = live.current;
|
|
178
|
+
const dt = last ? Math.min(0.033, (now - last) / 1000) : 1 / 60;
|
|
179
|
+
last = now;
|
|
180
|
+
const elapsed = now - started;
|
|
181
|
+
const base = o.color === "currentColor" ? getComputedStyle(el).color : o.color;
|
|
182
|
+
const [br, bg, bb] = toRgb(base);
|
|
183
|
+
const [ar, ag, ab] = toRgb(o.accentColor);
|
|
184
|
+
const size = Math.max(0.5, o.particleSize);
|
|
185
|
+
const rr = o.repelRadius;
|
|
186
|
+
const rs = o.repelStrength * 2600;
|
|
187
|
+
let busy = pointer.on;
|
|
188
|
+
|
|
189
|
+
ctx.clearRect(0, 0, w, h);
|
|
190
|
+
// Two passes: settled particles in the base color, moving ones in the accent.
|
|
191
|
+
const hot = [];
|
|
192
|
+
ctx.fillStyle = `rgb(${br}, ${bg}, ${bb})`;
|
|
193
|
+
ctx.beginPath();
|
|
194
|
+
for (let i = 0; i < n; i++) {
|
|
195
|
+
const k = i * 8;
|
|
196
|
+
let x = P[k];
|
|
197
|
+
let y = P[k + 1];
|
|
198
|
+
let vx = P[k + 2];
|
|
199
|
+
let vy = P[k + 3];
|
|
200
|
+
if (elapsed >= P[k + 6]) {
|
|
201
|
+
// Spring toward the target, with damping.
|
|
202
|
+
vx += (P[k + 4] - x) * 60 * dt;
|
|
203
|
+
vy += (P[k + 5] - y) * 60 * dt;
|
|
204
|
+
if (pointer.on && rr > 0) {
|
|
205
|
+
const dx = x - pointer.x;
|
|
206
|
+
const dy = y - pointer.y;
|
|
207
|
+
const d2 = dx * dx + dy * dy;
|
|
208
|
+
if (d2 < rr * rr && d2 > 0.01) {
|
|
209
|
+
const d = Math.sqrt(d2);
|
|
210
|
+
const f = (1 - d / rr) * rs * dt;
|
|
211
|
+
vx += (dx / d) * f;
|
|
212
|
+
vy += (dy / d) * f;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const damp = Math.pow(0.0009, dt);
|
|
216
|
+
vx *= damp;
|
|
217
|
+
vy *= damp;
|
|
218
|
+
x += vx * dt;
|
|
219
|
+
y += vy * dt;
|
|
220
|
+
}
|
|
221
|
+
P[k] = x;
|
|
222
|
+
P[k + 1] = y;
|
|
223
|
+
P[k + 2] = vx;
|
|
224
|
+
P[k + 3] = vy;
|
|
225
|
+
const off = Math.abs(P[k + 4] - x) + Math.abs(P[k + 5] - y);
|
|
226
|
+
if (off > 0.3 || Math.abs(vx) + Math.abs(vy) > 2) busy = true;
|
|
227
|
+
if (off > 4) hot.push(x, y, Math.min(1, off / 40));
|
|
228
|
+
else ctx.rect(x - size / 2, y - size / 2, size, size);
|
|
229
|
+
}
|
|
230
|
+
ctx.fill();
|
|
231
|
+
if (hot.length) {
|
|
232
|
+
ctx.globalCompositeOperation = "lighter";
|
|
233
|
+
for (let j = 0; j < hot.length; j += 3) {
|
|
234
|
+
const t = hot[j + 2];
|
|
235
|
+
ctx.fillStyle = `rgb(${Math.round(br + (ar - br) * t)}, ${Math.round(bg + (ag - bg) * t)}, ${Math.round(bb + (ab - bb) * t)})`;
|
|
236
|
+
ctx.fillRect(hot[j] - size / 2, hot[j + 1] - size / 2, size, size);
|
|
237
|
+
}
|
|
238
|
+
ctx.globalCompositeOperation = "source-over";
|
|
239
|
+
}
|
|
240
|
+
if (busy && visible) raf = requestAnimationFrame(frame);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
function wake() {
|
|
244
|
+
if (raf || !alive || !visible) return;
|
|
245
|
+
last = 0;
|
|
246
|
+
raf = requestAnimationFrame(frame);
|
|
247
|
+
}
|
|
248
|
+
wakeRef.current = wake;
|
|
249
|
+
|
|
250
|
+
const local = (e) => {
|
|
251
|
+
const r = cv.getBoundingClientRect();
|
|
252
|
+
return [e.clientX - r.left, e.clientY - r.top];
|
|
253
|
+
};
|
|
254
|
+
const onMove = (e) => {
|
|
255
|
+
[pointer.x, pointer.y] = local(e);
|
|
256
|
+
pointer.on = true;
|
|
257
|
+
wake();
|
|
258
|
+
};
|
|
259
|
+
const onEnter = (e) => {
|
|
260
|
+
onMove(e);
|
|
261
|
+
if (live.current.replayOn === "hover") startPositions(live.current.intro);
|
|
262
|
+
};
|
|
263
|
+
const onLeave = () => {
|
|
264
|
+
pointer.on = false;
|
|
265
|
+
pointer.x = pointer.y = -9999;
|
|
266
|
+
wake();
|
|
267
|
+
};
|
|
268
|
+
const onDown = (e) => {
|
|
269
|
+
const o = live.current;
|
|
270
|
+
if (o.replayOn === "click") return startPositions(o.intro);
|
|
271
|
+
if (!o.clickBurst || reduced) return;
|
|
272
|
+
const [cx, cy] = local(e);
|
|
273
|
+
for (let i = 0; i < n; i++) {
|
|
274
|
+
const k = i * 8;
|
|
275
|
+
const dx = P[k] - cx;
|
|
276
|
+
const dy = P[k + 1] - cy;
|
|
277
|
+
const d = Math.hypot(dx, dy) || 1;
|
|
278
|
+
const f = Math.max(0, 1 - d / 260) * 1400 * (0.6 + P[k + 7] * 0.8);
|
|
279
|
+
P[k + 2] += (dx / d) * f;
|
|
280
|
+
P[k + 3] += (dy / d) * f;
|
|
281
|
+
}
|
|
282
|
+
wake();
|
|
283
|
+
};
|
|
284
|
+
cv.addEventListener("pointerenter", onEnter);
|
|
285
|
+
cv.addEventListener("pointermove", onMove);
|
|
286
|
+
cv.addEventListener("pointerleave", onLeave);
|
|
287
|
+
cv.addEventListener("pointerdown", onDown);
|
|
288
|
+
|
|
289
|
+
const ro = new ResizeObserver(() => {
|
|
290
|
+
const r = el.getBoundingClientRect();
|
|
291
|
+
const key = `${Math.round(r.width)}x${Math.round(r.height)}`;
|
|
292
|
+
if (key === built) return;
|
|
293
|
+
const first = !built;
|
|
294
|
+
built = key;
|
|
295
|
+
build(first);
|
|
296
|
+
});
|
|
297
|
+
ro.observe(el);
|
|
298
|
+
const io = new IntersectionObserver(([entry]) => {
|
|
299
|
+
visible = entry.isIntersecting;
|
|
300
|
+
wake();
|
|
301
|
+
});
|
|
302
|
+
io.observe(el);
|
|
303
|
+
// Rebuild once web fonts are ready so the shape uses the real font.
|
|
304
|
+
document.fonts?.ready.then(() => alive && built && build(false));
|
|
305
|
+
|
|
306
|
+
return () => {
|
|
307
|
+
alive = false;
|
|
308
|
+
wakeRef.current = () => {};
|
|
309
|
+
if (raf) cancelAnimationFrame(raf);
|
|
310
|
+
ro.disconnect();
|
|
311
|
+
io.disconnect();
|
|
312
|
+
cv.removeEventListener("pointerenter", onEnter);
|
|
313
|
+
cv.removeEventListener("pointermove", onMove);
|
|
314
|
+
cv.removeEventListener("pointerleave", onLeave);
|
|
315
|
+
cv.removeEventListener("pointerdown", onDown);
|
|
316
|
+
};
|
|
317
|
+
// Text and font settings rebuild the particles; the rest is read live.
|
|
318
|
+
}, [text, fontSize, fontWeight, fontFamily, gap]);
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<div ref={wrap} className={`relative w-full touch-pan-y select-none overflow-hidden ${className}`} style={{ height }}>
|
|
322
|
+
<canvas ref={canvas} aria-hidden="true" className="absolute inset-0 block h-full w-full" />
|
|
323
|
+
<span className="sr-only">{text}</span>
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
export default ParticleText;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React, { useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
3
|
+
import { Camera, Heart, Home, MessageCircle, Plus, Settings, Share2, User } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Radial Menu: a round button that fans its actions out in a circle (or an
|
|
7
|
+
* arc). Items spring out one after another and tuck back in on close.
|
|
8
|
+
* Keyboard friendly: arrows move between items, Escape closes, and clicking
|
|
9
|
+
* outside closes too.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DEFAULT_ITEMS = [
|
|
13
|
+
{ icon: Home, label: "Home" },
|
|
14
|
+
{ icon: User, label: "Profile" },
|
|
15
|
+
{ icon: MessageCircle, label: "Messages" },
|
|
16
|
+
{ icon: Camera, label: "Camera" },
|
|
17
|
+
{ icon: Heart, label: "Likes" },
|
|
18
|
+
{ icon: Share2, label: "Share" },
|
|
19
|
+
{ icon: Settings, label: "Settings" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const RadialMenu = ({ items = DEFAULT_ITEMS, radius = 6.5, arc = 360, startAngle = -90, accentColor = "#a3e635", theme = "dark", onSelect, className = "" }) => {
|
|
23
|
+
const id = useId();
|
|
24
|
+
const [open, setOpen] = useState(false);
|
|
25
|
+
const [picked, setPicked] = useState(null);
|
|
26
|
+
const root = useRef(null);
|
|
27
|
+
const btns = useRef([]);
|
|
28
|
+
const dark = theme !== "light";
|
|
29
|
+
const n = items.length;
|
|
30
|
+
// Full circle spreads evenly; a partial arc includes both ends.
|
|
31
|
+
const step = arc >= 360 ? 360 / n : arc / Math.max(1, n - 1);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!open) return undefined;
|
|
35
|
+
const down = (e) => !root.current?.contains(e.target) && setOpen(false);
|
|
36
|
+
const key = (e) => e.key === "Escape" && setOpen(false);
|
|
37
|
+
document.addEventListener("pointerdown", down);
|
|
38
|
+
document.addEventListener("keydown", key);
|
|
39
|
+
return () => {
|
|
40
|
+
document.removeEventListener("pointerdown", down);
|
|
41
|
+
document.removeEventListener("keydown", key);
|
|
42
|
+
};
|
|
43
|
+
}, [open]);
|
|
44
|
+
|
|
45
|
+
const choose = (item, i) => {
|
|
46
|
+
setPicked(i);
|
|
47
|
+
setOpen(false);
|
|
48
|
+
onSelect?.(item, i);
|
|
49
|
+
};
|
|
50
|
+
const onItemKey = (e, i) => {
|
|
51
|
+
const next = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
|
|
52
|
+
if (!next) return;
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
btns.current[(i + next + n) % n]?.focus();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div ref={root} className={`relative inline-grid place-items-center ${className}`} style={{ width: `${radius * 2 + 4}rem`, height: `${radius * 2 + 4}rem`, maxWidth: "100%" }}>
|
|
59
|
+
<AnimatePresence>
|
|
60
|
+
{open &&
|
|
61
|
+
items.map((item, i) => {
|
|
62
|
+
const a = ((startAngle + i * step) * Math.PI) / 180;
|
|
63
|
+
const Icon = item.icon;
|
|
64
|
+
return (
|
|
65
|
+
<motion.button
|
|
66
|
+
key={`${id}-${i}`}
|
|
67
|
+
ref={(el) => (btns.current[i] = el)}
|
|
68
|
+
type="button"
|
|
69
|
+
aria-label={item.label}
|
|
70
|
+
title={item.label}
|
|
71
|
+
onClick={() => choose(item, i)}
|
|
72
|
+
onKeyDown={(e) => onItemKey(e, i)}
|
|
73
|
+
className={`group absolute grid h-12 w-12 place-items-center rounded-full shadow-lg outline-none ring-1 focus-visible:ring-2 ${dark ? "bg-neutral-900 text-white ring-white/10 hover:bg-neutral-800 focus-visible:ring-white/50" : "bg-white text-neutral-800 ring-black/10 hover:bg-neutral-50 focus-visible:ring-black/40"}`}
|
|
74
|
+
initial={{ x: 0, y: 0, scale: 0.3, opacity: 0 }}
|
|
75
|
+
animate={{ x: `${Math.cos(a) * radius}rem`, y: `${Math.sin(a) * radius}rem`, scale: 1, opacity: 1, transition: { type: "spring", stiffness: 420, damping: 24, delay: i * 0.03 } }}
|
|
76
|
+
exit={{ x: 0, y: 0, scale: 0.3, opacity: 0, transition: { duration: 0.18, delay: (n - 1 - i) * 0.015 } }}
|
|
77
|
+
style={picked === i ? { color: accentColor } : undefined}
|
|
78
|
+
>
|
|
79
|
+
{Icon ? <Icon className="h-5 w-5" /> : item.label[0]}
|
|
80
|
+
<span className={`pointer-events-none absolute top-full mt-1.5 whitespace-nowrap rounded-md px-1.5 py-0.5 text-[0.65rem] font-medium opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 ${dark ? "bg-white text-black" : "bg-neutral-900 text-white"}`}>{item.label}</span>
|
|
81
|
+
</motion.button>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
</AnimatePresence>
|
|
85
|
+
<motion.button
|
|
86
|
+
type="button"
|
|
87
|
+
aria-expanded={open}
|
|
88
|
+
aria-label={open ? "Close menu" : "Open menu"}
|
|
89
|
+
onClick={() => setOpen((o) => !o)}
|
|
90
|
+
onKeyDown={(e) => open && e.key === "ArrowRight" && (e.preventDefault(), btns.current[0]?.focus())}
|
|
91
|
+
whileTap={{ scale: 0.92 }}
|
|
92
|
+
className="relative z-10 grid h-16 w-16 place-items-center rounded-full text-black shadow-[0_10px_30px_-8px_rgba(0,0,0,0.5)] outline-none focus-visible:ring-4 focus-visible:ring-white/40"
|
|
93
|
+
style={{ background: accentColor }}
|
|
94
|
+
>
|
|
95
|
+
<motion.span animate={{ rotate: open ? 135 : 0 }} transition={{ type: "spring", stiffness: 400, damping: 22 }}>
|
|
96
|
+
<Plus className="h-7 w-7" strokeWidth={2.5} />
|
|
97
|
+
</motion.span>
|
|
98
|
+
</motion.button>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export default RadialMenu;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React, { Children, useLayoutEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Scroll Stack: cards pin one after another as you scroll and pile up. A
|
|
5
|
+
* card that gets covered shrinks a little, darkens and (optionally) blurs or
|
|
6
|
+
* leans back, so the pile reads as depth. It rides on native scrolling
|
|
7
|
+
* (position: sticky + one rAF update per scroll), so it stays smooth with
|
|
8
|
+
* mouse, trackpad, touch and keyboard. Scrolls inside its own box, or with
|
|
9
|
+
* the page (useWindowScroll).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const ScrollStackItem = ({ children, className = "", style }) => (
|
|
13
|
+
<div className={`relative h-[18rem] w-full origin-top overflow-hidden rounded-[1.75rem] p-8 shadow-[0_24px_48px_-24px_rgba(0,0,0,0.45)] will-change-transform ${className}`} style={style}>
|
|
14
|
+
{children}
|
|
15
|
+
<span data-stack-shade="" aria-hidden="true" className="pointer-events-none absolute inset-0 rounded-[inherit] bg-black opacity-0" />
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const ScrollStack = ({ children, gap = 90, offset = 22, top = 64, scaleStep = 0.05, minScale = 0.82, dim = 0.22, blur = 0, tilt = 0, endSpace = 180, useWindowScroll = false, height = "100%", className = "" }) => {
|
|
20
|
+
const root = useRef(null);
|
|
21
|
+
const slots = useRef([]);
|
|
22
|
+
const items = Children.toArray(children);
|
|
23
|
+
|
|
24
|
+
useLayoutEffect(() => {
|
|
25
|
+
const box = root.current;
|
|
26
|
+
const scroller = useWindowScroll ? window : box;
|
|
27
|
+
let raf = 0;
|
|
28
|
+
const update = () => {
|
|
29
|
+
raf = 0;
|
|
30
|
+
const els = slots.current.filter(Boolean);
|
|
31
|
+
const viewTop = useWindowScroll ? 0 : box.getBoundingClientRect().top;
|
|
32
|
+
const rects = els.map((e) => e.getBoundingClientRect());
|
|
33
|
+
// How much each later card covers the one before it (0 to 1).
|
|
34
|
+
const depth = els.map(() => 0);
|
|
35
|
+
for (let j = 1; j < els.length; j++) {
|
|
36
|
+
const pinPrev = viewTop + top + (j - 1) * offset;
|
|
37
|
+
const startCover = pinPrev + rects[j - 1].height;
|
|
38
|
+
const span = Math.max(1, rects[j - 1].height - offset);
|
|
39
|
+
const cover = Math.min(1, Math.max(0, (startCover - rects[j].top) / span));
|
|
40
|
+
for (let i = 0; i < j; i++) depth[i] += cover;
|
|
41
|
+
}
|
|
42
|
+
els.forEach((slot, i) => {
|
|
43
|
+
const card = slot.firstElementChild;
|
|
44
|
+
if (!card) return;
|
|
45
|
+
const d = depth[i];
|
|
46
|
+
const scale = Math.max(minScale, 1 - d * scaleStep);
|
|
47
|
+
const lean = Math.min(1, d) * tilt;
|
|
48
|
+
card.style.transform = d > 0.001 ? `perspective(1000px) rotateX(${-lean}deg) scale(${scale.toFixed(4)})` : "";
|
|
49
|
+
card.style.filter = blur && d > 0.01 ? `blur(${(d * blur).toFixed(2)}px)` : "";
|
|
50
|
+
const shade = card.querySelector("[data-stack-shade]");
|
|
51
|
+
if (shade) shade.style.opacity = (1 - Math.pow(1 - dim, d)).toFixed(3);
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
const schedule = () => {
|
|
55
|
+
if (!raf) raf = requestAnimationFrame(update);
|
|
56
|
+
};
|
|
57
|
+
scroller.addEventListener("scroll", schedule, { passive: true });
|
|
58
|
+
window.addEventListener("resize", schedule);
|
|
59
|
+
const ro = new ResizeObserver(schedule);
|
|
60
|
+
ro.observe(box);
|
|
61
|
+
update();
|
|
62
|
+
return () => {
|
|
63
|
+
cancelAnimationFrame(raf);
|
|
64
|
+
scroller.removeEventListener("scroll", schedule);
|
|
65
|
+
window.removeEventListener("resize", schedule);
|
|
66
|
+
ro.disconnect();
|
|
67
|
+
};
|
|
68
|
+
}, [useWindowScroll, top, offset, scaleStep, minScale, dim, blur, tilt, items.length]);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div ref={root} className={`relative w-full ${useWindowScroll ? "" : "overflow-y-auto overscroll-contain"} ${className}`} style={useWindowScroll ? undefined : { height }}>
|
|
72
|
+
<div className="px-[clamp(1rem,6%,4rem)]" style={{ paddingTop: useWindowScroll ? 0 : top, paddingBottom: endSpace }}>
|
|
73
|
+
{items.map((child, i) => (
|
|
74
|
+
<div
|
|
75
|
+
key={i}
|
|
76
|
+
ref={(e) => (slots.current[i] = e)}
|
|
77
|
+
className="sticky"
|
|
78
|
+
style={{ top: top + i * offset, marginTop: i ? gap : 0, zIndex: i + 1 }}
|
|
79
|
+
>
|
|
80
|
+
{child}
|
|
81
|
+
</div>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export default ScrollStack;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useId, useRef, useState } from "react";
|
|
1
|
+
import React, { useEffect, useId, useRef, useState } from "react";
|
|
2
2
|
import { AnimatePresence, LayoutGroup, motion } from "framer-motion";
|
|
3
3
|
import { BarChart3, Bell, ChevronDown, FolderKanban, Home, Inbox, LifeBuoy, PanelLeftClose, PanelLeftOpen, Settings, Users } from "lucide-react";
|
|
4
4
|
|
|
@@ -61,6 +61,7 @@ const SidebarNav = ({
|
|
|
61
61
|
user = { name: "Mohan Dalei", email: "mohan@apex-ui.in", avatar: "https://ik.imagekit.io/ny6yinyut/apexui/assets/captainamerica.png?tr=f-auto,q-80,w-96" },
|
|
62
62
|
defaultActive = "home",
|
|
63
63
|
defaultCollapsed = false,
|
|
64
|
+
autoCollapse = true,
|
|
64
65
|
accentColor = "#a3e635",
|
|
65
66
|
theme = "dark",
|
|
66
67
|
onSelect,
|
|
@@ -73,6 +74,25 @@ const SidebarNav = ({
|
|
|
73
74
|
const [open, setOpen] = useState(() => new Set(["projects"]));
|
|
74
75
|
const [tip, setTip] = useState(null);
|
|
75
76
|
const asideRef = useRef(null);
|
|
77
|
+
const userToggled = useRef(false);
|
|
78
|
+
|
|
79
|
+
// On narrow screens the full sidebar would squeeze the page, so it becomes
|
|
80
|
+
// an icon rail there and opens again when there is room, until the user
|
|
81
|
+
// picks a state with the toggle.
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const host = asideRef.current?.parentElement;
|
|
84
|
+
if (!autoCollapse || !host) return undefined;
|
|
85
|
+
let narrow = null;
|
|
86
|
+
const ro = new ResizeObserver(([entry]) => {
|
|
87
|
+
const now = entry.contentRect.width < 560;
|
|
88
|
+
if (now !== narrow) {
|
|
89
|
+
if (!userToggled.current && (now || narrow !== null)) setCollapsed(now || defaultCollapsed);
|
|
90
|
+
narrow = now;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
ro.observe(host);
|
|
94
|
+
return () => ro.disconnect();
|
|
95
|
+
}, [autoCollapse, defaultCollapsed]);
|
|
76
96
|
|
|
77
97
|
const select = (item) => {
|
|
78
98
|
setActive(item.id);
|
|
@@ -159,6 +179,7 @@ const SidebarNav = ({
|
|
|
159
179
|
type="button"
|
|
160
180
|
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
161
181
|
onClick={() => {
|
|
182
|
+
userToggled.current = true;
|
|
162
183
|
setCollapsed((c) => !c);
|
|
163
184
|
setTip(null);
|
|
164
185
|
}}
|