@ponti-studios/ui 1.6.0 → 1.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 +7 -11
- package/package.json +6 -31
- package/src/components/feedback/alert.tsx +8 -4
- package/src/components/feedback/index.ts +0 -4
- package/src/components/feedback/progress.stories.tsx +15 -0
- package/src/components/feedback/progress.tsx +14 -3
- package/src/components/feedback/update-guard.stories.tsx +4 -8
- package/src/components/feedback/update-guard.tsx +41 -272
- package/src/components/filters/filter-select.tsx +3 -5
- package/src/components/forms/date-picker.stories.tsx +158 -18
- package/src/components/forms/date-picker.tsx +286 -64
- package/src/components/forms/date-utils.ts +7 -2
- package/src/components/forms/drop-zone.tsx +11 -12
- package/src/components/forms/index.ts +2 -6
- package/src/components/forms/passkey-management.stories.tsx +8 -4
- package/src/components/forms/passkey-management.tsx +78 -42
- package/src/components/forms/radio-group.stories.tsx +3 -5
- package/src/components/forms/radio-group.tsx +2 -2
- package/src/components/forms/select.stories.tsx +16 -13
- package/src/components/forms/textarea.stories.tsx +1 -1
- package/src/components/layout/index.ts +2 -0
- package/src/components/layout/particle-background.stories.tsx +38 -0
- package/src/components/layout/particle-background.tsx +378 -0
- package/src/components/layout/scroll-area.stories.tsx +12 -4
- package/src/components/layout/scroll-area.tsx +41 -4
- package/src/components/navigation/tabs.stories.tsx +0 -21
- package/src/components/overlays/dialog.stories.tsx +38 -1
- package/src/components/overlays/dialog.tsx +53 -12
- package/src/components/overlays/index.ts +0 -11
- package/src/components/primitives/avatar-placeholder.svg +13 -0
- package/src/components/primitives/avatar.stories.tsx +2 -1
- package/src/components/primitives/badge.stories.tsx +13 -3
- package/src/components/primitives/badge.tsx +1 -1
- package/src/components/primitives/button.tsx +1 -1
- package/src/components/primitives/card.tsx +6 -2
- package/src/components/primitives/index.ts +0 -3
- package/src/index.ts +1 -3
- package/src/lib/index.ts +2 -3
- package/src/styles/animations.css +62 -0
- package/src/styles/index.css +27 -0
- package/src/styles/tokens/generated/dark.colors.generated.ts +15 -12
- package/src/styles/tokens/generated/dark.css +15 -12
- package/src/styles/tokens/generated/light.colors.generated.ts +14 -11
- package/src/styles/tokens/generated/light.css +14 -11
- package/src/styles/tokens/index.ts +0 -3
- package/src/styles/tokens/source/dark.tokens.json +24 -12
- package/src/styles/tokens/source/light.tokens.json +23 -11
- package/src/styles/tokens/typography.stories.tsx +157 -0
- package/src/components/feedback/file-upload-status.stories.tsx +0 -57
- package/src/components/feedback/file-upload-status.tsx +0 -160
- package/src/components/feedback/progress-bar.stories.tsx +0 -39
- package/src/components/feedback/progress-bar.tsx +0 -22
- package/src/components/forms/calendar.stories.tsx +0 -30
- package/src/components/forms/calendar.tsx +0 -75
- package/src/components/forms/date-field.stories.tsx +0 -202
- package/src/components/forms/date-field.tsx +0 -179
- package/src/components/overlays/alert-dialog.stories.tsx +0 -45
- package/src/components/overlays/alert-dialog.tsx +0 -116
- package/src/components/primitives/label.stories.tsx +0 -29
- package/src/components/primitives/label.tsx +0 -20
- package/src/components/primitives/status-badge.stories.tsx +0 -52
- package/src/components/primitives/status-badge.tsx +0 -33
- package/src/lib/create-memory-storage.ts +0 -24
- package/src/native/card.tsx +0 -36
- package/src/native/icon-button.tsx +0 -41
- package/src/native/index.ts +0 -38
- package/src/native/list-row.tsx +0 -67
- package/src/native/make-styles.ts +0 -32
- package/src/native/shadows.ts +0 -38
- package/src/native/text-field.tsx +0 -34
- package/src/native/uniwind.d.ts +0 -1
- package/src/native/use-theme-colors.ts +0 -19
- package/src/styles/tokens/foundations.stories.tsx +0 -109
- package/src/styles/tokens/generated/native.generated.ts +0 -266
- package/src/styles/tokens/normalize-native.ts +0 -88
- package/src/styles/tokens/source/native.tokens.json +0 -204
- package/src/styles/tokens/style-dictionary.native.mjs +0 -26
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { useMediaQuery } from "../../hooks/use-media-query";
|
|
6
|
+
import { cn } from "../../lib/utils";
|
|
7
|
+
|
|
8
|
+
interface Particle {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
vx: number;
|
|
12
|
+
vy: number;
|
|
13
|
+
opacity: number;
|
|
14
|
+
depth: number;
|
|
15
|
+
twinklePhase: number;
|
|
16
|
+
twinkleSpeed: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ParticleBackgroundPalette {
|
|
20
|
+
particle: string;
|
|
21
|
+
link: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ParticleBackgroundProps extends React.ComponentProps<"div"> {
|
|
25
|
+
enabled?: boolean;
|
|
26
|
+
interactive?: boolean;
|
|
27
|
+
particleCount?: number;
|
|
28
|
+
particleDensity?: number;
|
|
29
|
+
maxLinkDistance?: number;
|
|
30
|
+
attractRadius?: number;
|
|
31
|
+
attractStrength?: number;
|
|
32
|
+
velocity?: number;
|
|
33
|
+
showGradient?: boolean;
|
|
34
|
+
palette?: ParticleBackgroundPalette;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_PARTICLE_COUNT = 320;
|
|
38
|
+
const DEFAULT_PARTICLE_DENSITY = 2800;
|
|
39
|
+
const DEFAULT_LINK_DISTANCE = 90;
|
|
40
|
+
const DEFAULT_ATTRACT_STRENGTH = 0.003;
|
|
41
|
+
const DEFAULT_ATTRACT_RADIUS = 150;
|
|
42
|
+
const DEFAULT_VELOCITY = 0.06;
|
|
43
|
+
const PARALLAX_STRENGTH = 0.015;
|
|
44
|
+
const TWINKLE_MIN_SPEED = 0.0002;
|
|
45
|
+
const TWINKLE_MAX_SPEED = 0.0008;
|
|
46
|
+
const PARTICLE_BASE_OPACITY = 0.2;
|
|
47
|
+
|
|
48
|
+
const DEFAULT_PALETTE = {
|
|
49
|
+
particle: "15,23,42",
|
|
50
|
+
link: "15,23,42",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function readCssVar(name: string, fallback: string) {
|
|
54
|
+
if (typeof window === "undefined") return fallback;
|
|
55
|
+
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
56
|
+
return value || fallback;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toRgbTriplet(color: string): string {
|
|
60
|
+
if (color.startsWith("#")) {
|
|
61
|
+
let hex = color.slice(1);
|
|
62
|
+
if (hex.length === 3) {
|
|
63
|
+
hex = hex
|
|
64
|
+
.split("")
|
|
65
|
+
.map((c) => c + c)
|
|
66
|
+
.join("");
|
|
67
|
+
}
|
|
68
|
+
const num = Number.parseInt(hex, 16);
|
|
69
|
+
return `${(num >> 16) & 255},${(num >> 8) & 255},${num & 255}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const match = color.match(/rgba?\(([^)]+)\)/);
|
|
73
|
+
if (match) {
|
|
74
|
+
return match[1]
|
|
75
|
+
.split(",")
|
|
76
|
+
.slice(0, 3)
|
|
77
|
+
.map((part) => part.trim())
|
|
78
|
+
.join(",");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return DEFAULT_PALETTE.particle;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function useThemePalette(): ParticleBackgroundPalette {
|
|
85
|
+
const [palette, setPalette] = React.useState<ParticleBackgroundPalette>(DEFAULT_PALETTE);
|
|
86
|
+
|
|
87
|
+
React.useEffect(() => {
|
|
88
|
+
const resolve = () => {
|
|
89
|
+
const accent = toRgbTriplet(readCssVar("--color-primary", "#0969da"));
|
|
90
|
+
const foreground = toRgbTriplet(readCssVar("--color-foreground", "#25292e"));
|
|
91
|
+
setPalette({ particle: accent, link: foreground });
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
resolve();
|
|
95
|
+
|
|
96
|
+
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
|
97
|
+
media.addEventListener("change", resolve);
|
|
98
|
+
|
|
99
|
+
return () => {
|
|
100
|
+
media.removeEventListener("change", resolve);
|
|
101
|
+
};
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
return palette;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function useResolvedTheme() {
|
|
108
|
+
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">("light");
|
|
109
|
+
|
|
110
|
+
React.useEffect(() => {
|
|
111
|
+
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
|
112
|
+
const resolve = () => setResolvedTheme(media.matches ? "dark" : "light");
|
|
113
|
+
|
|
114
|
+
resolve();
|
|
115
|
+
media.addEventListener("change", resolve);
|
|
116
|
+
|
|
117
|
+
return () => {
|
|
118
|
+
media.removeEventListener("change", resolve);
|
|
119
|
+
};
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
return resolvedTheme;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Animated canvas background of drifting, linked particles. Skips rendering under `prefers-reduced-motion`. */
|
|
126
|
+
export function ParticleBackground({
|
|
127
|
+
className,
|
|
128
|
+
enabled = true,
|
|
129
|
+
interactive = true,
|
|
130
|
+
particleCount,
|
|
131
|
+
particleDensity = DEFAULT_PARTICLE_DENSITY,
|
|
132
|
+
maxLinkDistance = DEFAULT_LINK_DISTANCE,
|
|
133
|
+
attractRadius = DEFAULT_ATTRACT_RADIUS,
|
|
134
|
+
attractStrength = DEFAULT_ATTRACT_STRENGTH,
|
|
135
|
+
velocity = DEFAULT_VELOCITY,
|
|
136
|
+
showGradient: _showGradient = true,
|
|
137
|
+
palette,
|
|
138
|
+
...props
|
|
139
|
+
}: ParticleBackgroundProps) {
|
|
140
|
+
const prefersReduced = useMediaQuery("(prefers-reduced-motion: reduce)");
|
|
141
|
+
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
|
142
|
+
const mouse = React.useRef({ x: -9999, y: -9999 });
|
|
143
|
+
const particles = React.useRef<Particle[]>([]);
|
|
144
|
+
const raf = React.useRef<number>(0);
|
|
145
|
+
const scrollOffset = React.useRef(typeof window === "undefined" ? 0 : window.scrollY);
|
|
146
|
+
const resolvedTheme = useResolvedTheme();
|
|
147
|
+
const themePalette = useThemePalette();
|
|
148
|
+
const resolvedPalette = palette ?? themePalette;
|
|
149
|
+
|
|
150
|
+
React.useEffect(() => {
|
|
151
|
+
if (prefersReduced || !enabled) return;
|
|
152
|
+
|
|
153
|
+
const canvasEl = canvasRef.current;
|
|
154
|
+
if (!canvasEl) return;
|
|
155
|
+
|
|
156
|
+
const ctx = canvasEl.getContext("2d");
|
|
157
|
+
if (!ctx) return;
|
|
158
|
+
|
|
159
|
+
const canvas = canvasEl;
|
|
160
|
+
const context = ctx;
|
|
161
|
+
|
|
162
|
+
function resize() {
|
|
163
|
+
const rect = canvas.getBoundingClientRect();
|
|
164
|
+
const dpr = window.devicePixelRatio || 1;
|
|
165
|
+
|
|
166
|
+
canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
|
167
|
+
canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
|
168
|
+
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
169
|
+
|
|
170
|
+
const targetCount =
|
|
171
|
+
particleCount ??
|
|
172
|
+
Math.max(
|
|
173
|
+
80,
|
|
174
|
+
Math.min(
|
|
175
|
+
DEFAULT_PARTICLE_COUNT,
|
|
176
|
+
Math.round((rect.width * rect.height) / particleDensity),
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
particles.current = Array.from({ length: targetCount }, () => {
|
|
181
|
+
const depth = Math.random();
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
x: Math.random() * rect.width,
|
|
185
|
+
y: Math.random() * rect.height,
|
|
186
|
+
vx: (Math.random() - 0.5) * velocity * (0.4 + depth * 0.8),
|
|
187
|
+
vy: (Math.random() - 0.5) * velocity * (0.4 + depth * 0.8),
|
|
188
|
+
opacity: PARTICLE_BASE_OPACITY * (0.5 + depth * 0.5),
|
|
189
|
+
depth,
|
|
190
|
+
twinklePhase: Math.random() * Math.PI * 2,
|
|
191
|
+
twinkleSpeed: TWINKLE_MIN_SPEED + Math.random() * (TWINKLE_MAX_SPEED - TWINKLE_MIN_SPEED),
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let isRunning = false;
|
|
197
|
+
let inView = true;
|
|
198
|
+
let tabVisible = !document.hidden;
|
|
199
|
+
const renderPositions: { x: number; y: number }[] = [];
|
|
200
|
+
|
|
201
|
+
function draw(time: number) {
|
|
202
|
+
const width = canvas.clientWidth;
|
|
203
|
+
const height = canvas.clientHeight;
|
|
204
|
+
|
|
205
|
+
context.clearRect(0, 0, width, height);
|
|
206
|
+
context.lineCap = "round";
|
|
207
|
+
|
|
208
|
+
const ps = particles.current;
|
|
209
|
+
const parallaxY = scrollOffset.current * PARALLAX_STRENGTH;
|
|
210
|
+
|
|
211
|
+
for (let i = 0; i < ps.length; i++) {
|
|
212
|
+
const p = ps[i];
|
|
213
|
+
|
|
214
|
+
const mdx = mouse.current.x - p.x;
|
|
215
|
+
const mdy = mouse.current.y - p.y;
|
|
216
|
+
const mdist = Math.sqrt(mdx * mdx + mdy * mdy);
|
|
217
|
+
|
|
218
|
+
if (interactive && mdist < attractRadius) {
|
|
219
|
+
const force = (1 - mdist / attractRadius) * attractStrength;
|
|
220
|
+
p.vx += mdx * force;
|
|
221
|
+
p.vy += mdy * force;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
p.vx *= 0.98;
|
|
225
|
+
p.vy *= 0.98;
|
|
226
|
+
|
|
227
|
+
p.x += p.vx;
|
|
228
|
+
p.y += p.vy;
|
|
229
|
+
|
|
230
|
+
if (p.x < 0) p.x = width;
|
|
231
|
+
if (p.x > width) p.x = 0;
|
|
232
|
+
if (p.y < 0) p.y = height;
|
|
233
|
+
if (p.y > height) p.y = 0;
|
|
234
|
+
|
|
235
|
+
const renderY = (((p.y + parallaxY * p.depth) % height) + height) % height;
|
|
236
|
+
renderPositions[i] = { x: p.x, y: renderY };
|
|
237
|
+
|
|
238
|
+
const twinkle = 0.6 + 0.4 * Math.sin(time * p.twinkleSpeed + p.twinklePhase);
|
|
239
|
+
const radius = 0.6 + p.depth * 1.4;
|
|
240
|
+
|
|
241
|
+
context.beginPath();
|
|
242
|
+
context.arc(p.x, renderY, radius, 0, Math.PI * 2);
|
|
243
|
+
context.fillStyle = `rgba(${resolvedPalette.particle},${p.opacity * twinkle})`;
|
|
244
|
+
context.fill();
|
|
245
|
+
|
|
246
|
+
for (let j = i + 1; j < ps.length; j++) {
|
|
247
|
+
const q = ps[j];
|
|
248
|
+
const qRender = renderPositions[j] ?? { x: q.x, y: q.y };
|
|
249
|
+
const dx = p.x - q.x;
|
|
250
|
+
const dy = renderY - qRender.y;
|
|
251
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
252
|
+
|
|
253
|
+
if (dist < maxLinkDistance) {
|
|
254
|
+
const depthFactor = (p.depth + q.depth) / 2;
|
|
255
|
+
|
|
256
|
+
// Links are more transparent in dark mode to reduce contrast and visual noise, and slightly more opaque in light mode to add some subtlety.
|
|
257
|
+
const alpha =
|
|
258
|
+
(1 - dist / maxLinkDistance) *
|
|
259
|
+
(resolvedTheme === "dark" ? 0.015 : 0.01) *
|
|
260
|
+
depthFactor;
|
|
261
|
+
context.beginPath();
|
|
262
|
+
context.moveTo(p.x, renderY);
|
|
263
|
+
context.lineTo(qRender.x, qRender.y);
|
|
264
|
+
context.strokeStyle = `rgba(${resolvedPalette.link},${alpha})`;
|
|
265
|
+
context.lineWidth = 0.5;
|
|
266
|
+
context.stroke();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (isRunning) {
|
|
272
|
+
raf.current = requestAnimationFrame(draw);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function syncRunning() {
|
|
277
|
+
const shouldRun = inView && tabVisible;
|
|
278
|
+
|
|
279
|
+
if (shouldRun && !isRunning) {
|
|
280
|
+
isRunning = true;
|
|
281
|
+
raf.current = requestAnimationFrame(draw);
|
|
282
|
+
} else if (!shouldRun && isRunning) {
|
|
283
|
+
isRunning = false;
|
|
284
|
+
cancelAnimationFrame(raf.current);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Resizing the canvas element clears its pixels, so repaint a frame
|
|
289
|
+
// whenever the loop isn't already running to redraw for it.
|
|
290
|
+
function handleResize() {
|
|
291
|
+
resize();
|
|
292
|
+
if (!isRunning) {
|
|
293
|
+
draw(performance.now());
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const ro = new ResizeObserver(handleResize);
|
|
298
|
+
ro.observe(canvas);
|
|
299
|
+
resize();
|
|
300
|
+
// Paint one frame immediately so the canvas is never left blank while
|
|
301
|
+
// waiting on the intersection/visibility observers to report in.
|
|
302
|
+
draw(performance.now());
|
|
303
|
+
syncRunning();
|
|
304
|
+
|
|
305
|
+
const onMouseMove = (event: MouseEvent) => {
|
|
306
|
+
const rect = canvas.getBoundingClientRect();
|
|
307
|
+
mouse.current = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const onMouseLeave = () => {
|
|
311
|
+
mouse.current = { x: -9999, y: -9999 };
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
if (interactive) {
|
|
315
|
+
canvas.addEventListener("mousemove", onMouseMove);
|
|
316
|
+
canvas.addEventListener("mouseleave", onMouseLeave);
|
|
317
|
+
document.addEventListener("mousemove", onMouseMove);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const onScroll = () => {
|
|
321
|
+
scrollOffset.current = window.scrollY;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
325
|
+
|
|
326
|
+
const io = new IntersectionObserver(
|
|
327
|
+
([entry]) => {
|
|
328
|
+
inView = entry?.isIntersecting ?? true;
|
|
329
|
+
syncRunning();
|
|
330
|
+
},
|
|
331
|
+
{ threshold: 0 },
|
|
332
|
+
);
|
|
333
|
+
io.observe(canvas);
|
|
334
|
+
|
|
335
|
+
const onVisibilityChange = () => {
|
|
336
|
+
tabVisible = !document.hidden;
|
|
337
|
+
syncRunning();
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
341
|
+
|
|
342
|
+
return () => {
|
|
343
|
+
isRunning = false;
|
|
344
|
+
cancelAnimationFrame(raf.current);
|
|
345
|
+
ro.disconnect();
|
|
346
|
+
io.disconnect();
|
|
347
|
+
document.removeEventListener("mousemove", onMouseMove);
|
|
348
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
349
|
+
canvas.removeEventListener("mousemove", onMouseMove);
|
|
350
|
+
canvas.removeEventListener("mouseleave", onMouseLeave);
|
|
351
|
+
window.removeEventListener("scroll", onScroll);
|
|
352
|
+
};
|
|
353
|
+
}, [
|
|
354
|
+
attractRadius,
|
|
355
|
+
attractStrength,
|
|
356
|
+
enabled,
|
|
357
|
+
interactive,
|
|
358
|
+
maxLinkDistance,
|
|
359
|
+
resolvedPalette,
|
|
360
|
+
particleCount,
|
|
361
|
+
particleDensity,
|
|
362
|
+
prefersReduced,
|
|
363
|
+
resolvedTheme,
|
|
364
|
+
velocity,
|
|
365
|
+
]);
|
|
366
|
+
|
|
367
|
+
if (!enabled || prefersReduced) return null;
|
|
368
|
+
|
|
369
|
+
return (
|
|
370
|
+
<div
|
|
371
|
+
className={cn("pointer-events-none absolute inset-0 overflow-hidden", className)}
|
|
372
|
+
aria-hidden="true"
|
|
373
|
+
{...props}
|
|
374
|
+
>
|
|
375
|
+
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
@@ -25,7 +25,9 @@ export const Default: Story = {
|
|
|
25
25
|
className="flex size-40 shrink-0 items-center justify-center rounded-lg"
|
|
26
26
|
style={{ backgroundColor: item.bg }}
|
|
27
27
|
>
|
|
28
|
-
<span className="
|
|
28
|
+
<span className="font-medium" style={{ color: "#171714" }}>
|
|
29
|
+
{item.label}
|
|
30
|
+
</span>
|
|
29
31
|
</div>
|
|
30
32
|
)),
|
|
31
33
|
},
|
|
@@ -41,7 +43,9 @@ export const WithSnap: Story = {
|
|
|
41
43
|
className="flex size-48 shrink-0 items-center justify-center rounded-lg"
|
|
42
44
|
style={{ backgroundColor: item.bg }}
|
|
43
45
|
>
|
|
44
|
-
<span className="
|
|
46
|
+
<span className="font-medium" style={{ color: "#171714" }}>
|
|
47
|
+
{item.label}
|
|
48
|
+
</span>
|
|
45
49
|
</div>
|
|
46
50
|
)),
|
|
47
51
|
},
|
|
@@ -57,7 +61,9 @@ export const Vertical: Story = {
|
|
|
57
61
|
className="flex h-24 shrink-0 items-center justify-center rounded-lg"
|
|
58
62
|
style={{ backgroundColor: item.bg }}
|
|
59
63
|
>
|
|
60
|
-
<span className="
|
|
64
|
+
<span className="font-medium" style={{ color: "#171714" }}>
|
|
65
|
+
{item.label}
|
|
66
|
+
</span>
|
|
61
67
|
</div>
|
|
62
68
|
)),
|
|
63
69
|
},
|
|
@@ -74,7 +80,9 @@ export const ScreenshotsGallery: Story = {
|
|
|
74
80
|
className="flex aspect-video w-72 shrink-0 items-center justify-center overflow-hidden rounded-xl border"
|
|
75
81
|
style={{ backgroundColor: item.bg }}
|
|
76
82
|
>
|
|
77
|
-
<span className="text-
|
|
83
|
+
<span className="text-sm" style={{ color: "#171714" }}>
|
|
84
|
+
{item.label}
|
|
85
|
+
</span>
|
|
78
86
|
</div>
|
|
79
87
|
))}
|
|
80
88
|
</ScrollArea>
|
|
@@ -8,14 +8,45 @@ interface ScrollAreaProps extends React.ComponentProps<typeof ScrollAreaPrimitiv
|
|
|
8
8
|
snap?: "start" | "center" | "none";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Full literal class names, not `snap-${snap}` — Tailwind's scanner only
|
|
13
|
+
* picks up complete strings present in the source, so an interpolated class
|
|
14
|
+
* name never generates any CSS.
|
|
15
|
+
*/
|
|
16
|
+
const SNAP_ALIGN_CLASS_NAME = {
|
|
17
|
+
start: "snap-start",
|
|
18
|
+
center: "snap-center",
|
|
19
|
+
none: undefined,
|
|
20
|
+
} as const satisfies Record<NonNullable<ScrollAreaProps["snap"]>, string | undefined>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Overlay scrollbar: invisible at rest, fades in on hover/scroll and back
|
|
24
|
+
* out on idle (data-hovering / data-scrolling from Base UI). Opacity-only,
|
|
25
|
+
* transitions (not keyframes) since hover/scroll toggle rapidly.
|
|
26
|
+
*/
|
|
27
|
+
const scrollbarClassName = cn(
|
|
28
|
+
"flex touch-none p-0.5 opacity-0 transition-opacity duration-150 ease-out",
|
|
29
|
+
"data-[hovering]:opacity-100 data-[scrolling]:opacity-100",
|
|
30
|
+
"data-[orientation=vertical]:w-2.5",
|
|
31
|
+
"data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col",
|
|
32
|
+
);
|
|
33
|
+
|
|
11
34
|
const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
|
|
12
35
|
({ className, children, orientation = "horizontal", snap = "none", ...props }, ref) => {
|
|
13
36
|
return (
|
|
14
|
-
<ScrollAreaPrimitive.Root
|
|
37
|
+
<ScrollAreaPrimitive.Root
|
|
38
|
+
ref={ref}
|
|
39
|
+
className={cn(
|
|
40
|
+
"relative flex overflow-hidden",
|
|
41
|
+
orientation === "horizontal" ? "flex-row" : "flex-col",
|
|
42
|
+
className,
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
>
|
|
15
46
|
<ScrollAreaPrimitive.Viewport
|
|
16
47
|
className={cn(
|
|
17
48
|
"scrollbar-hide flex overflow-auto",
|
|
18
|
-
orientation === "horizontal" ? "flex-row" : "flex-col",
|
|
49
|
+
orientation === "horizontal" ? "min-w-0 flex-1 flex-row" : "min-h-0 flex-1 flex-col",
|
|
19
50
|
snap !== "none" && (orientation === "horizontal" ? "snap-x" : "snap-y"),
|
|
20
51
|
snap !== "none" && "snap-mandatory",
|
|
21
52
|
)}
|
|
@@ -24,13 +55,19 @@ const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
|
|
|
24
55
|
if (!React.isValidElement(child)) return child;
|
|
25
56
|
return React.cloneElement(child as React.ReactElement<{ className?: string }>, {
|
|
26
57
|
className: cn(
|
|
27
|
-
snap
|
|
28
|
-
snap !== "none" && orientation === "vertical" && `snap-${snap}`,
|
|
58
|
+
SNAP_ALIGN_CLASS_NAME[snap],
|
|
29
59
|
(child.props as { className?: string }).className,
|
|
30
60
|
),
|
|
31
61
|
});
|
|
32
62
|
})}
|
|
33
63
|
</ScrollAreaPrimitive.Viewport>
|
|
64
|
+
<ScrollAreaPrimitive.Scrollbar orientation="horizontal" className={scrollbarClassName}>
|
|
65
|
+
<ScrollAreaPrimitive.Thumb className="bg-foreground/40 hover:bg-foreground/60 relative flex-1 rounded-full transition-colors duration-150 ease" />
|
|
66
|
+
</ScrollAreaPrimitive.Scrollbar>
|
|
67
|
+
<ScrollAreaPrimitive.Scrollbar orientation="vertical" className={scrollbarClassName}>
|
|
68
|
+
<ScrollAreaPrimitive.Thumb className="bg-foreground/40 hover:bg-foreground/60 relative flex-1 rounded-full transition-colors duration-150 ease" />
|
|
69
|
+
</ScrollAreaPrimitive.Scrollbar>
|
|
70
|
+
<ScrollAreaPrimitive.Corner />
|
|
34
71
|
</ScrollAreaPrimitive.Root>
|
|
35
72
|
);
|
|
36
73
|
},
|
|
@@ -32,27 +32,6 @@ export const Default: Story = {
|
|
|
32
32
|
),
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
export const LineVariant: Story = {
|
|
36
|
-
render: () => (
|
|
37
|
-
<Tabs defaultValue="tab1" className="w-[400px]">
|
|
38
|
-
<TabsList className="border-x-0 border-t-0 bg-transparent">
|
|
39
|
-
<TabsTrigger value="tab1">Overview</TabsTrigger>
|
|
40
|
-
<TabsTrigger value="tab2">Analytics</TabsTrigger>
|
|
41
|
-
<TabsTrigger value="tab3">Reports</TabsTrigger>
|
|
42
|
-
</TabsList>
|
|
43
|
-
<TabsContent value="tab1">
|
|
44
|
-
<p className="text-muted-foreground p-4 text-sm">Overview content</p>
|
|
45
|
-
</TabsContent>
|
|
46
|
-
<TabsContent value="tab2">
|
|
47
|
-
<p className="text-muted-foreground p-4 text-sm">Analytics content</p>
|
|
48
|
-
</TabsContent>
|
|
49
|
-
<TabsContent value="tab3">
|
|
50
|
-
<p className="text-muted-foreground p-4 text-sm">Reports content</p>
|
|
51
|
-
</TabsContent>
|
|
52
|
-
</Tabs>
|
|
53
|
-
),
|
|
54
|
-
};
|
|
55
|
-
|
|
56
35
|
export const Vertical: Story = {
|
|
57
36
|
render: () => (
|
|
58
37
|
<Tabs defaultValue="tab1" orientation="vertical" className="w-[400px]">
|
|
@@ -5,6 +5,7 @@ import { booleanControl, hiddenControl } from "../../storybook/controls";
|
|
|
5
5
|
import { Button } from "../primitives/button";
|
|
6
6
|
import {
|
|
7
7
|
Dialog,
|
|
8
|
+
DialogClose,
|
|
8
9
|
DialogContent,
|
|
9
10
|
DialogDescription,
|
|
10
11
|
DialogFooter,
|
|
@@ -103,9 +104,45 @@ export const WithCloseButton: Story = {
|
|
|
103
104
|
<DialogDescription>Are you sure you want to proceed?</DialogDescription>
|
|
104
105
|
</DialogHeader>
|
|
105
106
|
<DialogFooter>
|
|
106
|
-
<
|
|
107
|
+
<DialogClose asChild>
|
|
108
|
+
<Button>Confirm</Button>
|
|
109
|
+
</DialogClose>
|
|
107
110
|
</DialogFooter>
|
|
108
111
|
</DialogContent>
|
|
109
112
|
</DialogPreview>
|
|
110
113
|
),
|
|
111
114
|
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `alert` swaps in Base UI's AlertDialog primitive: `role="alertdialog"`,
|
|
118
|
+
* forced modal, and no dismiss via outside click or Escape. Use it for
|
|
119
|
+
* confirmations that need explicit acknowledgment (destructive actions,
|
|
120
|
+
* unsaved-changes warnings). The close (X) button is off by default in this
|
|
121
|
+
* mode — dismissal should go through an explicit action.
|
|
122
|
+
*/
|
|
123
|
+
export const Alert: Story = {
|
|
124
|
+
render: () => (
|
|
125
|
+
<Dialog alert>
|
|
126
|
+
<DialogTrigger asChild>
|
|
127
|
+
<Button variant="outline">Delete account</Button>
|
|
128
|
+
</DialogTrigger>
|
|
129
|
+
<DialogContent>
|
|
130
|
+
<DialogHeader>
|
|
131
|
+
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
|
132
|
+
<DialogDescription>
|
|
133
|
+
This action cannot be undone. This will permanently delete your account and remove your
|
|
134
|
+
data from our servers.
|
|
135
|
+
</DialogDescription>
|
|
136
|
+
</DialogHeader>
|
|
137
|
+
<DialogFooter>
|
|
138
|
+
<DialogClose asChild>
|
|
139
|
+
<Button variant="outline">Cancel</Button>
|
|
140
|
+
</DialogClose>
|
|
141
|
+
<DialogClose asChild>
|
|
142
|
+
<Button>Continue</Button>
|
|
143
|
+
</DialogClose>
|
|
144
|
+
</DialogFooter>
|
|
145
|
+
</DialogContent>
|
|
146
|
+
</Dialog>
|
|
147
|
+
),
|
|
148
|
+
};
|
|
@@ -1,11 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AlertDialog as BaseAlertDialog } from "@base-ui/react/alert-dialog";
|
|
1
4
|
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
|
|
2
5
|
import { XIcon } from "lucide-react";
|
|
3
6
|
import * as React from "react";
|
|
4
7
|
|
|
5
8
|
import { cn } from "../../lib/utils";
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Whether the nearest ancestor `<Dialog alert>` is in alert mode. Every part
|
|
12
|
+
* below (`DialogContent`, etc.) reads this instead of taking its own `alert`
|
|
13
|
+
* prop, so the mode only has to be set once, at the root.
|
|
14
|
+
*/
|
|
15
|
+
const DialogAlertContext = React.createContext(false);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `alert: true` swaps the root primitive for Base UI's `AlertDialog.Root`:
|
|
19
|
+
* `role="alertdialog"`, forced `modal`, and no dismiss via outside click or
|
|
20
|
+
* Escape — for confirmations that need explicit acknowledgment. Every other
|
|
21
|
+
* part (`Trigger`, `Popup`, `Close`, `Title`, `Description`, ...) is a plain
|
|
22
|
+
* re-export shared by both Base UI primitives, so only the root needs to
|
|
23
|
+
* branch; `DialogContent` reads `DialogAlertContext` to default its close
|
|
24
|
+
* button off in alert mode.
|
|
25
|
+
*/
|
|
26
|
+
function Dialog({
|
|
27
|
+
alert = false,
|
|
28
|
+
modal,
|
|
29
|
+
disablePointerDismissal,
|
|
30
|
+
...props
|
|
31
|
+
}: Omit<React.ComponentProps<typeof BaseDialog.Root>, "handle"> & { alert?: boolean }) {
|
|
32
|
+
return (
|
|
33
|
+
<DialogAlertContext value={alert}>
|
|
34
|
+
{alert ? (
|
|
35
|
+
<BaseAlertDialog.Root {...props} />
|
|
36
|
+
) : (
|
|
37
|
+
<BaseDialog.Root
|
|
38
|
+
modal={modal}
|
|
39
|
+
disablePointerDismissal={disablePointerDismissal}
|
|
40
|
+
{...props}
|
|
41
|
+
/>
|
|
42
|
+
)}
|
|
43
|
+
</DialogAlertContext>
|
|
44
|
+
);
|
|
9
45
|
}
|
|
10
46
|
|
|
11
47
|
function DialogTrigger({
|
|
@@ -62,36 +98,41 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Base
|
|
|
62
98
|
return (
|
|
63
99
|
<BaseDialog.Backdrop
|
|
64
100
|
data-slot="dialog-overlay"
|
|
65
|
-
className={cn(
|
|
66
|
-
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/80 transition-opacity",
|
|
67
|
-
className,
|
|
68
|
-
)}
|
|
101
|
+
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
|
69
102
|
{...props}
|
|
70
103
|
/>
|
|
71
104
|
);
|
|
72
105
|
}
|
|
73
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Flush to the bottom edge as a sheet on mobile, centered as a card from
|
|
109
|
+
* `sm` up. Animation (slide-up on mobile, fade/zoom on desktop) is driven by
|
|
110
|
+
* [data-slot="dialog-content"] rules in animations.css, not by this class.
|
|
111
|
+
*/
|
|
112
|
+
const dialogContentClassName =
|
|
113
|
+
"bg-popover text-popover-foreground fixed inset-x-0 bottom-0 z-50 grid max-h-[90dvh] w-full gap-6 overflow-y-auto rounded-t-xl border p-4 outline-none sm:top-[50%] sm:bottom-auto sm:left-[50%] sm:w-[calc(100%-2rem)] sm:max-w-[30rem] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-xl sm:p-6";
|
|
114
|
+
|
|
74
115
|
function DialogContent({
|
|
75
116
|
className,
|
|
76
117
|
children,
|
|
77
|
-
showCloseButton
|
|
118
|
+
showCloseButton,
|
|
78
119
|
...props
|
|
79
120
|
}: React.ComponentProps<typeof BaseDialog.Popup> & {
|
|
80
121
|
showCloseButton?: boolean;
|
|
81
122
|
}) {
|
|
123
|
+
const isAlert = React.useContext(DialogAlertContext);
|
|
124
|
+
const shouldShowClose = showCloseButton ?? !isAlert;
|
|
125
|
+
|
|
82
126
|
return (
|
|
83
127
|
<BaseDialog.Portal data-slot="dialog-portal">
|
|
84
128
|
<DialogOverlay />
|
|
85
129
|
<BaseDialog.Popup
|
|
86
130
|
data-slot="dialog-content"
|
|
87
|
-
className={cn(
|
|
88
|
-
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[90dvh] w-[calc(100%-2rem)] max-w-[30rem] translate-x-[-50%] translate-y-[-50%] gap-6 overflow-y-auto rounded-lg border p-4 duration-200 outline-none sm:p-6",
|
|
89
|
-
className,
|
|
90
|
-
)}
|
|
131
|
+
className={cn(dialogContentClassName, className)}
|
|
91
132
|
{...props}
|
|
92
133
|
>
|
|
93
134
|
{children}
|
|
94
|
-
{
|
|
135
|
+
{shouldShowClose && (
|
|
95
136
|
<BaseDialog.Close
|
|
96
137
|
data-slot="dialog-close"
|
|
97
138
|
className="text-muted-foreground hover:bg-muted hover:text-muted-foreground focus-visible:outline-ring absolute top-3 right-3 inline-flex size-9 items-center justify-center rounded-md border border-transparent opacity-100 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none sm:top-4 sm:right-4 [&_svg]:pointer-events-none [&_svg]:size-4.5 [&_svg]:shrink-0"
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
export {
|
|
2
|
-
AlertDialog,
|
|
3
|
-
AlertDialogAction,
|
|
4
|
-
AlertDialogCancel,
|
|
5
|
-
AlertDialogContent,
|
|
6
|
-
AlertDialogDescription,
|
|
7
|
-
AlertDialogFooter,
|
|
8
|
-
AlertDialogHeader,
|
|
9
|
-
AlertDialogTitle,
|
|
10
|
-
AlertDialogTrigger,
|
|
11
|
-
} from "./alert-dialog";
|
|
12
1
|
export {
|
|
13
2
|
Command,
|
|
14
3
|
CommandDialog,
|