@nationaldesignstudio/react 0.5.4 → 0.5.6

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.
@@ -0,0 +1,272 @@
1
+ "use client";
2
+
3
+ import { Dialog as BaseDialog } from "@base-ui-components/react/dialog";
4
+ import * as React from "react";
5
+ import { tv } from "tailwind-variants";
6
+ import {
7
+ BlurredVideoBackdrop,
8
+ type BlurredVideoBackdropProps,
9
+ } from "@/components/atoms/blurred-video-backdrop";
10
+ import {
11
+ VideoPlayer,
12
+ type VideoPlayerProps,
13
+ } from "@/components/atoms/video-player";
14
+ import { cn } from "@/lib/utils";
15
+
16
+ // ============================================================================
17
+ // Variant Definitions
18
+ // ============================================================================
19
+
20
+ /**
21
+ * Video dialog container variants.
22
+ * Uses fixed positioning to cover the viewport.
23
+ */
24
+ const videoDialogVariants = tv({
25
+ base: [
26
+ // Fixed positioning covering viewport
27
+ "fixed inset-0",
28
+ // Dark background base
29
+ "bg-black",
30
+ // Flex centering for the video
31
+ "flex items-center justify-center",
32
+ // Animation
33
+ "transition-opacity duration-300",
34
+ "data-[starting-style]:opacity-0",
35
+ "data-[ending-style]:opacity-0",
36
+ // Focus outline
37
+ "outline-none",
38
+ ],
39
+ });
40
+
41
+ /**
42
+ * Close button variants.
43
+ */
44
+ const closeButtonVariants = tv({
45
+ base: [
46
+ // Positioning
47
+ "absolute z-50",
48
+ // Size and shape
49
+ "w-48 h-48 rounded-full",
50
+ // Colors
51
+ "bg-black/60 text-white",
52
+ "hover:bg-black/80",
53
+ // Transition
54
+ "transition-all duration-150",
55
+ // Focus
56
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-white/50",
57
+ // Flex centering for icon
58
+ "flex items-center justify-center",
59
+ // Cursor
60
+ "cursor-pointer",
61
+ ],
62
+ variants: {
63
+ position: {
64
+ "top-right": "top-24 right-24",
65
+ "top-left": "top-24 left-24",
66
+ },
67
+ },
68
+ defaultVariants: {
69
+ position: "top-right",
70
+ },
71
+ });
72
+
73
+ /**
74
+ * Video container variants.
75
+ * Expands to fill viewport, constrained by height (maintains aspect ratio).
76
+ */
77
+ const videoContainerVariants = tv({
78
+ base: [
79
+ // Relative for z-index
80
+ "relative z-10",
81
+ // Fill available space
82
+ "w-full h-full",
83
+ // Flex to center the video
84
+ "flex items-center justify-center",
85
+ // Padding from viewport edges
86
+ "p-16 sm:p-24 lg:p-32",
87
+ ],
88
+ });
89
+
90
+ // ============================================================================
91
+ // VideoDialog Component
92
+ // ============================================================================
93
+
94
+ export interface VideoDialogProps
95
+ extends Omit<VideoPlayerProps, "videoRef" | "className"> {
96
+ /** Trigger element that opens the dialog */
97
+ trigger: React.ReactNode;
98
+ /** Blur intensity for backdrop (default: high) */
99
+ blur?: BlurredVideoBackdropProps["blur"];
100
+ /** Gradient overlay for backdrop (default: vignette) */
101
+ overlay?: BlurredVideoBackdropProps["overlay"];
102
+ /** Backdrop opacity (default: 0.6) */
103
+ backdropOpacity?: number;
104
+ /** Whether to show close button (default: true) */
105
+ showClose?: boolean;
106
+ /** Close button position (default: top-right) */
107
+ closePosition?: "top-right" | "top-left";
108
+ /** Video player rounded corners (default: lg) */
109
+ rounded?: VideoPlayerProps["rounded"];
110
+ /** Controlled open state */
111
+ open?: boolean;
112
+ /** Default open state */
113
+ defaultOpen?: boolean;
114
+ /** Callback when open state changes */
115
+ onOpenChange?: (open: boolean) => void;
116
+ /** Additional className for the dialog container */
117
+ className?: string;
118
+ }
119
+
120
+ /**
121
+ * VideoDialog - Fullscreen video modal with blurred video backdrop.
122
+ *
123
+ * Creates an immersive video viewing experience where the blurred video
124
+ * serves as the modal backdrop, with the main video centered on top.
125
+ * Based on the DGA modal pattern.
126
+ *
127
+ * Features:
128
+ * - Blurred video backdrop that syncs with main video
129
+ * - Configurable blur intensity and gradient overlays
130
+ * - Automatic play/pause when dialog opens/closes
131
+ * - HLS streaming support via Cloudflare Stream
132
+ * - Accessible dialog with focus trap and escape key handling
133
+ *
134
+ * @example
135
+ * ```tsx
136
+ * <VideoDialog
137
+ * trigger={<Button>Watch Video</Button>}
138
+ * cloudflare={{ videoId: "abc123", customerCode: "xyz789" }}
139
+ * blur="high"
140
+ * overlay="vignette"
141
+ * />
142
+ * ```
143
+ */
144
+ const VideoDialog = React.forwardRef<HTMLDivElement, VideoDialogProps>(
145
+ (
146
+ {
147
+ trigger,
148
+ src,
149
+ cloudflare,
150
+ blur = "high",
151
+ overlay = "vignette",
152
+ backdropOpacity = 0.6,
153
+ showClose = true,
154
+ closePosition = "top-right",
155
+ rounded = "lg",
156
+ open: controlledOpen,
157
+ defaultOpen,
158
+ onOpenChange,
159
+ className,
160
+ autoPlay,
161
+ ...videoProps
162
+ },
163
+ ref,
164
+ ) => {
165
+ // Internal open state for autoplay control
166
+ const [internalOpen, setInternalOpen] = React.useState(
167
+ defaultOpen ?? false,
168
+ );
169
+ const isControlled = controlledOpen !== undefined;
170
+ const open = isControlled ? controlledOpen : internalOpen;
171
+
172
+ const handleOpenChange = React.useCallback(
173
+ (newOpen: boolean) => {
174
+ if (!isControlled) {
175
+ setInternalOpen(newOpen);
176
+ }
177
+ onOpenChange?.(newOpen);
178
+ },
179
+ [isControlled, onOpenChange],
180
+ );
181
+
182
+ // Primary video ref for sync
183
+ const primaryVideoRef = React.useRef<HTMLVideoElement | null>(null);
184
+
185
+ return (
186
+ <BaseDialog.Root
187
+ open={open}
188
+ defaultOpen={defaultOpen}
189
+ onOpenChange={handleOpenChange}
190
+ >
191
+ <BaseDialog.Trigger
192
+ render={
193
+ React.isValidElement(trigger)
194
+ ? (trigger as React.ReactElement<Record<string, unknown>>)
195
+ : undefined
196
+ }
197
+ >
198
+ {!React.isValidElement(trigger) ? trigger : undefined}
199
+ </BaseDialog.Trigger>
200
+ <BaseDialog.Portal>
201
+ <BaseDialog.Popup
202
+ ref={ref}
203
+ className={cn(videoDialogVariants(), className)}
204
+ data-component="video-dialog"
205
+ >
206
+ {/* Blur Video Backdrop - covers entire viewport */}
207
+ <BlurredVideoBackdrop
208
+ videoRef={primaryVideoRef}
209
+ blur={blur}
210
+ overlay={overlay}
211
+ opacity={backdropOpacity}
212
+ extension={120}
213
+ />
214
+
215
+ {/* Close Button */}
216
+ {showClose && (
217
+ <BaseDialog.Close
218
+ className={closeButtonVariants({ position: closePosition })}
219
+ >
220
+ <svg
221
+ width="20"
222
+ height="20"
223
+ viewBox="0 0 20 20"
224
+ fill="none"
225
+ aria-hidden="true"
226
+ >
227
+ <path
228
+ d="M4 4L16 16M4 16L16 4"
229
+ stroke="currentColor"
230
+ strokeWidth="2"
231
+ strokeLinecap="round"
232
+ />
233
+ </svg>
234
+ <span className="sr-only">Close</span>
235
+ </BaseDialog.Close>
236
+ )}
237
+
238
+ {/* Video Container - fills viewport, video centered within */}
239
+ <div className={videoContainerVariants()}>
240
+ <VideoPlayer
241
+ src={src}
242
+ cloudflare={cloudflare}
243
+ videoRef={primaryVideoRef}
244
+ rounded={rounded}
245
+ autoPlay={autoPlay ?? open}
246
+ aspectRatio="16/9"
247
+ style={{
248
+ width: "min(100%, calc((100vh - 64px) * 16 / 9))",
249
+ maxHeight: "calc(100vh - 64px)",
250
+ }}
251
+ {...videoProps}
252
+ />
253
+ </div>
254
+ </BaseDialog.Popup>
255
+ </BaseDialog.Portal>
256
+ </BaseDialog.Root>
257
+ );
258
+ },
259
+ );
260
+
261
+ VideoDialog.displayName = "VideoDialog";
262
+
263
+ // ============================================================================
264
+ // Exports
265
+ // ============================================================================
266
+
267
+ export {
268
+ VideoDialog,
269
+ videoDialogVariants,
270
+ closeButtonVariants,
271
+ videoContainerVariants,
272
+ };
@@ -0,0 +1,383 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { tv, type VariantProps } from "tailwind-variants";
5
+ import {
6
+ BlurredVideoBackdrop,
7
+ type BlurredVideoBackdropProps,
8
+ } from "@/components/atoms/blurred-video-backdrop";
9
+ import {
10
+ VideoPlayer,
11
+ type VideoPlayerProps,
12
+ } from "@/components/atoms/video-player";
13
+ import { cn } from "@/lib/utils";
14
+
15
+ // ============================================================================
16
+ // Context
17
+ // ============================================================================
18
+
19
+ interface VideoWithBackdropContextValue {
20
+ /** Ref to primary video element */
21
+ videoRef: React.RefObject<HTMLVideoElement | null>;
22
+ /** Video source URL */
23
+ src?: string;
24
+ /** Cloudflare config */
25
+ cloudflare?: CloudflareConfig;
26
+ }
27
+
28
+ const VideoWithBackdropContext =
29
+ React.createContext<VideoWithBackdropContextValue | null>(null);
30
+
31
+ function useVideoWithBackdropContext() {
32
+ const context = React.useContext(VideoWithBackdropContext);
33
+ if (!context) {
34
+ throw new Error(
35
+ "VideoWithBackdrop compound components must be used within VideoWithBackdrop.Root",
36
+ );
37
+ }
38
+ return context;
39
+ }
40
+
41
+ // ============================================================================
42
+ // Types
43
+ // ============================================================================
44
+
45
+ /** Cloudflare Stream configuration */
46
+ interface CloudflareConfig {
47
+ /** Cloudflare Stream video ID */
48
+ videoId: string;
49
+ /** Cloudflare customer code/subdomain */
50
+ customerCode: string;
51
+ }
52
+
53
+ // ============================================================================
54
+ // Variant Definitions
55
+ // ============================================================================
56
+
57
+ /**
58
+ * Root container variants.
59
+ */
60
+ const videoWithBackdropVariants = tv({
61
+ base: ["relative", "overflow-hidden", "bg-black"],
62
+ variants: {
63
+ /**
64
+ * Full-height mode for dialogs.
65
+ */
66
+ fullHeight: {
67
+ true: "h-full w-full",
68
+ false: "",
69
+ },
70
+ },
71
+ defaultVariants: {
72
+ fullHeight: false,
73
+ },
74
+ });
75
+
76
+ /**
77
+ * Content container variants.
78
+ */
79
+ const contentVariants = tv({
80
+ base: ["relative", "z-10", "flex", "items-center", "justify-center"],
81
+ variants: {
82
+ fullHeight: {
83
+ true: "h-full w-full",
84
+ false: "",
85
+ },
86
+ padding: {
87
+ none: "",
88
+ sm: "p-16",
89
+ md: "p-24",
90
+ lg: "p-48",
91
+ },
92
+ },
93
+ defaultVariants: {
94
+ fullHeight: false,
95
+ padding: "md",
96
+ },
97
+ });
98
+
99
+ // ============================================================================
100
+ // Root Component
101
+ // ============================================================================
102
+
103
+ export interface VideoWithBackdropRootProps
104
+ extends React.HTMLAttributes<HTMLDivElement>,
105
+ VariantProps<typeof videoWithBackdropVariants> {
106
+ /** Video source URL (HLS .m3u8 or regular video file) */
107
+ src?: string;
108
+ /** Cloudflare Stream configuration (takes precedence over src) */
109
+ cloudflare?: CloudflareConfig;
110
+ /** Children to render */
111
+ children: React.ReactNode;
112
+ }
113
+
114
+ /**
115
+ * VideoWithBackdrop Root
116
+ *
117
+ * Container that provides video context to child components.
118
+ * Use with VideoWithBackdrop.Backdrop and VideoWithBackdrop.Content.
119
+ *
120
+ * @example
121
+ * ```tsx
122
+ * <VideoWithBackdrop.Root cloudflare={config}>
123
+ * <VideoWithBackdrop.Backdrop blur="high" overlay="vignette" />
124
+ * <VideoWithBackdrop.Content>
125
+ * <VideoWithBackdrop.Video />
126
+ * </VideoWithBackdrop.Content>
127
+ * </VideoWithBackdrop.Root>
128
+ * ```
129
+ */
130
+ const VideoWithBackdropRoot = React.forwardRef<
131
+ HTMLDivElement,
132
+ VideoWithBackdropRootProps
133
+ >(({ className, src, cloudflare, fullHeight, children, ...props }, ref) => {
134
+ const videoRef = React.useRef<HTMLVideoElement | null>(null);
135
+
136
+ const contextValue = React.useMemo(
137
+ () => ({ videoRef, src, cloudflare }),
138
+ [src, cloudflare],
139
+ );
140
+
141
+ return (
142
+ <VideoWithBackdropContext.Provider value={contextValue}>
143
+ <div
144
+ ref={ref}
145
+ className={cn(videoWithBackdropVariants({ fullHeight }), className)}
146
+ data-full-height={fullHeight ?? false}
147
+ {...props}
148
+ >
149
+ {children}
150
+ </div>
151
+ </VideoWithBackdropContext.Provider>
152
+ );
153
+ });
154
+ VideoWithBackdropRoot.displayName = "VideoWithBackdropRoot";
155
+
156
+ // ============================================================================
157
+ // Backdrop Component
158
+ // ============================================================================
159
+
160
+ export interface VideoWithBackdropBackdropProps
161
+ extends Omit<BlurredVideoBackdropProps, "videoRef"> {}
162
+
163
+ /**
164
+ * VideoWithBackdrop Backdrop
165
+ *
166
+ * Renders the blurred video backdrop layer using canvas.
167
+ * Automatically draws from the video element in context.
168
+ */
169
+ const VideoWithBackdropBackdrop = React.forwardRef<
170
+ HTMLDivElement,
171
+ VideoWithBackdropBackdropProps
172
+ >(({ ...props }, ref) => {
173
+ const { videoRef } = useVideoWithBackdropContext();
174
+
175
+ return <BlurredVideoBackdrop ref={ref} videoRef={videoRef} {...props} />;
176
+ });
177
+ VideoWithBackdropBackdrop.displayName = "VideoWithBackdropBackdrop";
178
+
179
+ // ============================================================================
180
+ // Content Component
181
+ // ============================================================================
182
+
183
+ export interface VideoWithBackdropContentProps
184
+ extends React.HTMLAttributes<HTMLDivElement>,
185
+ VariantProps<typeof contentVariants> {}
186
+
187
+ /**
188
+ * VideoWithBackdrop Content
189
+ *
190
+ * Container for the main video player and any additional content.
191
+ * Positioned above the backdrop with z-index.
192
+ */
193
+ const VideoWithBackdropContent = React.forwardRef<
194
+ HTMLDivElement,
195
+ VideoWithBackdropContentProps
196
+ >(({ className, fullHeight, padding, children, ...props }, ref) => {
197
+ return (
198
+ <div
199
+ ref={ref}
200
+ className={cn(contentVariants({ fullHeight, padding }), className)}
201
+ {...props}
202
+ >
203
+ {children}
204
+ </div>
205
+ );
206
+ });
207
+ VideoWithBackdropContent.displayName = "VideoWithBackdropContent";
208
+
209
+ // ============================================================================
210
+ // Video Component (convenience wrapper)
211
+ // ============================================================================
212
+
213
+ export interface VideoWithBackdropVideoProps
214
+ extends Omit<VideoPlayerProps, "videoRef"> {
215
+ /** Max width of the video player container */
216
+ maxWidth?: string;
217
+ }
218
+
219
+ /**
220
+ * VideoWithBackdrop Video
221
+ *
222
+ * Convenience wrapper for VideoPlayer that automatically connects
223
+ * to the backdrop via context.
224
+ */
225
+ const VideoWithBackdropVideo = React.forwardRef<
226
+ HTMLDivElement,
227
+ VideoWithBackdropVideoProps
228
+ >(({ className, maxWidth = "960px", cloudflare, src, ...props }, ref) => {
229
+ const context = useVideoWithBackdropContext();
230
+
231
+ // Use context values if not explicitly provided
232
+ const videoCloudflare = cloudflare ?? context.cloudflare;
233
+ const videoSrc = src ?? context.src;
234
+
235
+ return (
236
+ <div ref={ref} className={cn("w-full", className)} style={{ maxWidth }}>
237
+ <VideoPlayer
238
+ cloudflare={videoCloudflare}
239
+ src={videoSrc}
240
+ videoRef={context.videoRef}
241
+ {...props}
242
+ />
243
+ </div>
244
+ );
245
+ });
246
+ VideoWithBackdropVideo.displayName = "VideoWithBackdropVideo";
247
+
248
+ // ============================================================================
249
+ // Simple Pre-composed Component
250
+ // ============================================================================
251
+
252
+ export interface VideoWithBackdropProps
253
+ extends Omit<VideoPlayerProps, "videoRef" | "className" | "aspectRatio"> {
254
+ /** Blur intensity (default: high) */
255
+ blur?: BlurredVideoBackdropProps["blur"];
256
+ /** Gradient overlay (default: vignette) */
257
+ overlay?: BlurredVideoBackdropProps["overlay"];
258
+ /** Backdrop opacity (default: 0.6) */
259
+ backdropOpacity?: number;
260
+ /** Max width of video player (default: 960px) */
261
+ maxWidth?: string;
262
+ /** Content padding (default: md) */
263
+ padding?: "none" | "sm" | "md" | "lg";
264
+ /** Video player rounded corners */
265
+ rounded?: VideoPlayerProps["rounded"];
266
+ /** Additional className for root container */
267
+ className?: string;
268
+ /** Target FPS for backdrop canvas (default: 30) */
269
+ targetFps?: number;
270
+ /** Canvas scale factor for backdrop (default: 0.5) */
271
+ scale?: number;
272
+ }
273
+
274
+ /**
275
+ * VideoWithBackdrop - Pre-composed video player with blurred backdrop.
276
+ *
277
+ * A simple, ready-to-use component that combines VideoPlayer with
278
+ * BlurredVideoBackdrop for modal video experiences. Uses canvas rendering
279
+ * for optimal performance.
280
+ *
281
+ * For custom layouts, use the compound components:
282
+ * - VideoWithBackdrop.Root
283
+ * - VideoWithBackdrop.Backdrop
284
+ * - VideoWithBackdrop.Content
285
+ * - VideoWithBackdrop.Video
286
+ *
287
+ * @example
288
+ * ```tsx
289
+ * // Simple usage (in a full-height container like Dialog)
290
+ * <VideoWithBackdrop
291
+ * cloudflare={{ videoId: "...", customerCode: "..." }}
292
+ * autoPlay
293
+ * blur="high"
294
+ * overlay="vignette"
295
+ * />
296
+ *
297
+ * // With Dialog
298
+ * <Dialog size="full" variant="minimal">
299
+ * <VideoWithBackdrop cloudflare={config} autoPlay />
300
+ * </Dialog>
301
+ * ```
302
+ */
303
+ const VideoWithBackdrop = React.forwardRef<
304
+ HTMLDivElement,
305
+ VideoWithBackdropProps
306
+ >(
307
+ (
308
+ {
309
+ src,
310
+ cloudflare,
311
+ blur = "high",
312
+ overlay = "vignette",
313
+ backdropOpacity = 0.6,
314
+ maxWidth = "960px",
315
+ padding = "md",
316
+ rounded = "lg",
317
+ className,
318
+ targetFps = 30,
319
+ scale = 0.5,
320
+ ...videoProps
321
+ },
322
+ ref,
323
+ ) => {
324
+ const videoRef = React.useRef<HTMLVideoElement | null>(null);
325
+
326
+ return (
327
+ <div
328
+ ref={ref}
329
+ className={cn(
330
+ videoWithBackdropVariants({ fullHeight: true }),
331
+ className,
332
+ )}
333
+ data-component="video-with-backdrop"
334
+ >
335
+ <BlurredVideoBackdrop
336
+ videoRef={videoRef}
337
+ blur={blur}
338
+ overlay={overlay}
339
+ opacity={backdropOpacity}
340
+ targetFps={targetFps}
341
+ scale={scale}
342
+ />
343
+ <div className={cn(contentVariants({ fullHeight: true, padding }))}>
344
+ <div className="w-full" style={{ maxWidth }}>
345
+ <VideoPlayer
346
+ src={src}
347
+ cloudflare={cloudflare}
348
+ videoRef={videoRef}
349
+ rounded={rounded}
350
+ {...videoProps}
351
+ />
352
+ </div>
353
+ </div>
354
+ </div>
355
+ );
356
+ },
357
+ );
358
+ VideoWithBackdrop.displayName = "VideoWithBackdrop";
359
+
360
+ // ============================================================================
361
+ // Compound Component Export
362
+ // ============================================================================
363
+
364
+ export const VideoWithBackdropParts = Object.assign(VideoWithBackdropRoot, {
365
+ Root: VideoWithBackdropRoot,
366
+ Backdrop: VideoWithBackdropBackdrop,
367
+ Content: VideoWithBackdropContent,
368
+ Video: VideoWithBackdropVideo,
369
+ });
370
+
371
+ // ============================================================================
372
+ // Exports
373
+ // ============================================================================
374
+
375
+ export {
376
+ VideoWithBackdrop,
377
+ VideoWithBackdropRoot,
378
+ VideoWithBackdropBackdrop,
379
+ VideoWithBackdropContent,
380
+ VideoWithBackdropVideo,
381
+ videoWithBackdropVariants,
382
+ contentVariants,
383
+ };
@@ -1 +1,17 @@
1
+ // Utility hooks
2
+
3
+ export {
4
+ BREAKPOINTS,
5
+ type Breakpoint,
6
+ useBreakpoint,
7
+ useMaxBreakpoint,
8
+ useMinBreakpoint,
9
+ } from "./use-breakpoint";
10
+ // Video player hooks
11
+ export { type CaptionCue, useCaptions } from "./use-captions";
1
12
  export { useEventListener } from "./use-event-listener";
13
+ export {
14
+ type UseVideoKeyboardOptions,
15
+ type UseVideoKeyboardReturn,
16
+ useVideoKeyboard,
17
+ } from "./use-video-keyboard";