@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.
Files changed (77) hide show
  1. package/README.md +7 -11
  2. package/package.json +6 -31
  3. package/src/components/feedback/alert.tsx +8 -4
  4. package/src/components/feedback/index.ts +0 -4
  5. package/src/components/feedback/progress.stories.tsx +15 -0
  6. package/src/components/feedback/progress.tsx +14 -3
  7. package/src/components/feedback/update-guard.stories.tsx +4 -8
  8. package/src/components/feedback/update-guard.tsx +41 -272
  9. package/src/components/filters/filter-select.tsx +3 -5
  10. package/src/components/forms/date-picker.stories.tsx +158 -18
  11. package/src/components/forms/date-picker.tsx +286 -64
  12. package/src/components/forms/date-utils.ts +7 -2
  13. package/src/components/forms/drop-zone.tsx +11 -12
  14. package/src/components/forms/index.ts +2 -6
  15. package/src/components/forms/passkey-management.stories.tsx +8 -4
  16. package/src/components/forms/passkey-management.tsx +78 -42
  17. package/src/components/forms/radio-group.stories.tsx +3 -5
  18. package/src/components/forms/radio-group.tsx +2 -2
  19. package/src/components/forms/select.stories.tsx +16 -13
  20. package/src/components/forms/textarea.stories.tsx +1 -1
  21. package/src/components/layout/index.ts +2 -0
  22. package/src/components/layout/particle-background.stories.tsx +38 -0
  23. package/src/components/layout/particle-background.tsx +378 -0
  24. package/src/components/layout/scroll-area.stories.tsx +12 -4
  25. package/src/components/layout/scroll-area.tsx +41 -4
  26. package/src/components/navigation/tabs.stories.tsx +0 -21
  27. package/src/components/overlays/dialog.stories.tsx +38 -1
  28. package/src/components/overlays/dialog.tsx +53 -12
  29. package/src/components/overlays/index.ts +0 -11
  30. package/src/components/primitives/avatar-placeholder.svg +13 -0
  31. package/src/components/primitives/avatar.stories.tsx +2 -1
  32. package/src/components/primitives/badge.stories.tsx +13 -3
  33. package/src/components/primitives/badge.tsx +1 -1
  34. package/src/components/primitives/button.tsx +1 -1
  35. package/src/components/primitives/card.tsx +6 -2
  36. package/src/components/primitives/index.ts +0 -3
  37. package/src/index.ts +1 -3
  38. package/src/lib/index.ts +2 -3
  39. package/src/styles/animations.css +62 -0
  40. package/src/styles/index.css +27 -0
  41. package/src/styles/tokens/generated/dark.colors.generated.ts +15 -12
  42. package/src/styles/tokens/generated/dark.css +15 -12
  43. package/src/styles/tokens/generated/light.colors.generated.ts +14 -11
  44. package/src/styles/tokens/generated/light.css +14 -11
  45. package/src/styles/tokens/index.ts +0 -3
  46. package/src/styles/tokens/source/dark.tokens.json +24 -12
  47. package/src/styles/tokens/source/light.tokens.json +23 -11
  48. package/src/styles/tokens/typography.stories.tsx +157 -0
  49. package/src/components/feedback/file-upload-status.stories.tsx +0 -57
  50. package/src/components/feedback/file-upload-status.tsx +0 -160
  51. package/src/components/feedback/progress-bar.stories.tsx +0 -39
  52. package/src/components/feedback/progress-bar.tsx +0 -22
  53. package/src/components/forms/calendar.stories.tsx +0 -30
  54. package/src/components/forms/calendar.tsx +0 -75
  55. package/src/components/forms/date-field.stories.tsx +0 -202
  56. package/src/components/forms/date-field.tsx +0 -179
  57. package/src/components/overlays/alert-dialog.stories.tsx +0 -45
  58. package/src/components/overlays/alert-dialog.tsx +0 -116
  59. package/src/components/primitives/label.stories.tsx +0 -29
  60. package/src/components/primitives/label.tsx +0 -20
  61. package/src/components/primitives/status-badge.stories.tsx +0 -52
  62. package/src/components/primitives/status-badge.tsx +0 -33
  63. package/src/lib/create-memory-storage.ts +0 -24
  64. package/src/native/card.tsx +0 -36
  65. package/src/native/icon-button.tsx +0 -41
  66. package/src/native/index.ts +0 -38
  67. package/src/native/list-row.tsx +0 -67
  68. package/src/native/make-styles.ts +0 -32
  69. package/src/native/shadows.ts +0 -38
  70. package/src/native/text-field.tsx +0 -34
  71. package/src/native/uniwind.d.ts +0 -1
  72. package/src/native/use-theme-colors.ts +0 -19
  73. package/src/styles/tokens/foundations.stories.tsx +0 -109
  74. package/src/styles/tokens/generated/native.generated.ts +0 -266
  75. package/src/styles/tokens/normalize-native.ts +0 -88
  76. package/src/styles/tokens/source/native.tokens.json +0 -204
  77. 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="text-foreground font-medium">{item.label}</span>
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="text-foreground font-medium">{item.label}</span>
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="text-foreground font-medium">{item.label}</span>
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-foreground text-sm">{item.label}</span>
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 ref={ref} className={cn("relative", className)} {...props}>
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 !== "none" && orientation === "horizontal" && `snap-${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
- <Button>Confirm</Button>
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
- function Dialog({ ...props }: React.ComponentProps<typeof BaseDialog.Root>) {
8
- return <BaseDialog.Root {...props} />;
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 = true,
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
- {showCloseButton && (
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,