@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,145 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ /**
6
+ * Design system breakpoint values in pixels.
7
+ * These match the primitive breakpoint tokens from packages/tokens.
8
+ */
9
+ export const BREAKPOINTS = {
10
+ sm: 320,
11
+ md: 768,
12
+ lg: 1440,
13
+ } as const;
14
+
15
+ export type Breakpoint = keyof typeof BREAKPOINTS;
16
+
17
+ /**
18
+ * Get the current breakpoint based on viewport width.
19
+ */
20
+ function getCurrentBreakpoint(width: number): Breakpoint {
21
+ if (width >= BREAKPOINTS.lg) return "lg";
22
+ if (width >= BREAKPOINTS.md) return "md";
23
+ return "sm";
24
+ }
25
+
26
+ /**
27
+ * Hook to get the current responsive breakpoint.
28
+ * Returns the active breakpoint name based on viewport width.
29
+ *
30
+ * @returns The current breakpoint: 'sm' | 'md' | 'lg'
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * function Component() {
35
+ * const breakpoint = useBreakpoint();
36
+ *
37
+ * return (
38
+ * <div>
39
+ * {breakpoint === 'sm' && <MobileLayout />}
40
+ * {breakpoint === 'md' && <TabletLayout />}
41
+ * {breakpoint === 'lg' && <DesktopLayout />}
42
+ * </div>
43
+ * );
44
+ * }
45
+ * ```
46
+ */
47
+ export function useBreakpoint(): Breakpoint {
48
+ const [breakpoint, setBreakpoint] = React.useState<Breakpoint>(() => {
49
+ if (typeof window === "undefined") return "sm";
50
+ return getCurrentBreakpoint(window.innerWidth);
51
+ });
52
+
53
+ React.useEffect(() => {
54
+ const handleResize = () => {
55
+ setBreakpoint(getCurrentBreakpoint(window.innerWidth));
56
+ };
57
+
58
+ // Set initial value
59
+ handleResize();
60
+
61
+ window.addEventListener("resize", handleResize);
62
+ return () => window.removeEventListener("resize", handleResize);
63
+ }, []);
64
+
65
+ return breakpoint;
66
+ }
67
+
68
+ /**
69
+ * Hook to check if viewport is at or above a specific breakpoint.
70
+ *
71
+ * @param breakpoint - The minimum breakpoint to check against
72
+ * @returns boolean indicating if viewport is at or above the breakpoint
73
+ *
74
+ * @example
75
+ * ```tsx
76
+ * function Component() {
77
+ * const isDesktop = useMinBreakpoint('lg');
78
+ * const isTabletUp = useMinBreakpoint('md');
79
+ *
80
+ * return isDesktop ? <DesktopView /> : <MobileView />;
81
+ * }
82
+ * ```
83
+ */
84
+ export function useMinBreakpoint(breakpoint: Breakpoint): boolean {
85
+ const [matches, setMatches] = React.useState<boolean>(() => {
86
+ if (typeof window === "undefined") return false;
87
+ return window.innerWidth >= BREAKPOINTS[breakpoint];
88
+ });
89
+
90
+ React.useEffect(() => {
91
+ const mediaQuery = window.matchMedia(
92
+ `(min-width: ${BREAKPOINTS[breakpoint]}px)`,
93
+ );
94
+
95
+ setMatches(mediaQuery.matches);
96
+
97
+ const handleChange = (event: MediaQueryListEvent) => {
98
+ setMatches(event.matches);
99
+ };
100
+
101
+ mediaQuery.addEventListener("change", handleChange);
102
+ return () => mediaQuery.removeEventListener("change", handleChange);
103
+ }, [breakpoint]);
104
+
105
+ return matches;
106
+ }
107
+
108
+ /**
109
+ * Hook to check if viewport is below a specific breakpoint.
110
+ *
111
+ * @param breakpoint - The breakpoint to check against
112
+ * @returns boolean indicating if viewport is below the breakpoint
113
+ *
114
+ * @example
115
+ * ```tsx
116
+ * function Component() {
117
+ * const isMobile = useMaxBreakpoint('md'); // Below tablet
118
+ *
119
+ * return isMobile ? <MobileNav /> : <DesktopNav />;
120
+ * }
121
+ * ```
122
+ */
123
+ export function useMaxBreakpoint(breakpoint: Breakpoint): boolean {
124
+ const [matches, setMatches] = React.useState<boolean>(() => {
125
+ if (typeof window === "undefined") return false;
126
+ return window.innerWidth < BREAKPOINTS[breakpoint];
127
+ });
128
+
129
+ React.useEffect(() => {
130
+ const mediaQuery = window.matchMedia(
131
+ `(max-width: ${BREAKPOINTS[breakpoint] - 1}px)`,
132
+ );
133
+
134
+ setMatches(mediaQuery.matches);
135
+
136
+ const handleChange = (event: MediaQueryListEvent) => {
137
+ setMatches(event.matches);
138
+ };
139
+
140
+ mediaQuery.addEventListener("change", handleChange);
141
+ return () => mediaQuery.removeEventListener("change", handleChange);
142
+ }, [breakpoint]);
143
+
144
+ return matches;
145
+ }
@@ -0,0 +1,247 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ /**
6
+ * Represents a single caption cue parsed from VTT.
7
+ */
8
+ export interface CaptionCue {
9
+ /** Unique identifier for the cue */
10
+ id: string;
11
+ /** Start time in seconds */
12
+ startTime: number;
13
+ /** End time in seconds */
14
+ endTime: number;
15
+ /** Caption text content */
16
+ text: string;
17
+ }
18
+
19
+ /**
20
+ * Parse VTT timestamp to seconds.
21
+ * Handles formats: HH:MM:SS.mmm or MM:SS.mmm
22
+ */
23
+ function parseVttTimestamp(timestamp: string): number {
24
+ const parts = timestamp.trim().split(":");
25
+ let hours = 0;
26
+ let minutes = 0;
27
+ let seconds = 0;
28
+
29
+ if (parts.length === 3) {
30
+ hours = Number.parseInt(parts[0], 10);
31
+ minutes = Number.parseInt(parts[1], 10);
32
+ seconds = Number.parseFloat(parts[2]);
33
+ } else if (parts.length === 2) {
34
+ minutes = Number.parseInt(parts[0], 10);
35
+ seconds = Number.parseFloat(parts[1]);
36
+ }
37
+
38
+ return hours * 3600 + minutes * 60 + seconds;
39
+ }
40
+
41
+ /**
42
+ * Parse VTT content string into an array of caption cues.
43
+ */
44
+ function parseVtt(vttContent: string): CaptionCue[] {
45
+ const cues: CaptionCue[] = [];
46
+ const lines = vttContent.trim().split("\n");
47
+
48
+ let i = 0;
49
+
50
+ // Skip WEBVTT header and any metadata
51
+ while (i < lines.length && !lines[i].includes("-->")) {
52
+ i++;
53
+ }
54
+
55
+ while (i < lines.length) {
56
+ const line = lines[i].trim();
57
+
58
+ // Look for timestamp line (contains -->)
59
+ if (line.includes("-->")) {
60
+ const [startStr, endStr] = line.split("-->").map((s) => s.trim());
61
+
62
+ // Handle optional cue settings after timestamp
63
+ const endParts = endStr.split(" ");
64
+ const endTime = parseVttTimestamp(endParts[0]);
65
+ const startTime = parseVttTimestamp(startStr);
66
+
67
+ // Collect text lines until empty line or next timestamp
68
+ const textLines: string[] = [];
69
+ i++;
70
+ while (
71
+ i < lines.length &&
72
+ lines[i].trim() !== "" &&
73
+ !lines[i].includes("-->")
74
+ ) {
75
+ // Skip cue identifier lines (numbers only)
76
+ if (!/^\d+$/.test(lines[i].trim())) {
77
+ textLines.push(lines[i].trim());
78
+ }
79
+ i++;
80
+ }
81
+
82
+ if (textLines.length > 0) {
83
+ cues.push({
84
+ id: `cue-${cues.length}`,
85
+ startTime,
86
+ endTime,
87
+ text: textLines.join("\n"),
88
+ });
89
+ }
90
+ } else {
91
+ i++;
92
+ }
93
+ }
94
+
95
+ return cues;
96
+ }
97
+
98
+ /**
99
+ * Strip VTT formatting tags from text.
100
+ * Removes <v>, <c>, <i>, <b>, <u>, etc.
101
+ */
102
+ function stripVttTags(text: string): string {
103
+ return text
104
+ .replace(/<\/?[^>]+(>|$)/g, "") // Remove HTML-like tags
105
+ .replace(/&nbsp;/g, " ")
106
+ .replace(/&amp;/g, "&")
107
+ .replace(/&lt;/g, "<")
108
+ .replace(/&gt;/g, ">")
109
+ .trim();
110
+ }
111
+
112
+ interface UseCaptionsOptions {
113
+ /** VTT file URL to fetch */
114
+ src?: string;
115
+ /** Pre-loaded VTT content string */
116
+ content?: string;
117
+ /** Strip VTT formatting tags from caption text */
118
+ stripTags?: boolean;
119
+ /** Current playback time in seconds (alternative to setCurrentTime) */
120
+ currentTime?: number;
121
+ }
122
+
123
+ interface UseCaptionsReturn {
124
+ /** All parsed caption cues */
125
+ cues: CaptionCue[];
126
+ /** Currently active cue based on current time */
127
+ activeCue: CaptionCue | null;
128
+ /** Update the current playback time to get active cue */
129
+ setCurrentTime: (time: number) => void;
130
+ /** Loading state */
131
+ isLoading: boolean;
132
+ /** Error state */
133
+ error: Error | null;
134
+ }
135
+
136
+ /**
137
+ * Hook for parsing VTT captions and tracking the active cue.
138
+ *
139
+ * @param options - Caption source options
140
+ * @returns Parsed cues, active cue, and state
141
+ *
142
+ * @example
143
+ * ```tsx
144
+ * function VideoPlayer() {
145
+ * const videoRef = React.useRef<HTMLVideoElement>(null);
146
+ * const { activeCue, setCurrentTime } = useCaptions({
147
+ * src: '/captions.vtt',
148
+ * });
149
+ *
150
+ * // Sync with video time
151
+ * React.useEffect(() => {
152
+ * const video = videoRef.current;
153
+ * if (!video) return;
154
+ *
155
+ * const handleTimeUpdate = () => setCurrentTime(video.currentTime);
156
+ * video.addEventListener('timeupdate', handleTimeUpdate);
157
+ * return () => video.removeEventListener('timeupdate', handleTimeUpdate);
158
+ * }, [setCurrentTime]);
159
+ *
160
+ * return (
161
+ * <div>
162
+ * <video ref={videoRef} src="/video.mp4" />
163
+ * {activeCue && <div className="caption">{activeCue.text}</div>}
164
+ * </div>
165
+ * );
166
+ * }
167
+ * ```
168
+ */
169
+ export function useCaptions(
170
+ options: UseCaptionsOptions = {},
171
+ ): UseCaptionsReturn {
172
+ const { src, content, stripTags = true, currentTime: externalTime } = options;
173
+
174
+ const [cues, setCues] = React.useState<CaptionCue[]>([]);
175
+ const [internalTime, setInternalTime] = React.useState(0);
176
+ const [isLoading, setIsLoading] = React.useState(false);
177
+ const [error, setError] = React.useState<Error | null>(null);
178
+
179
+ // Use external time if provided, otherwise use internal state
180
+ const currentTime = externalTime ?? internalTime;
181
+
182
+ // Parse content or fetch from URL
183
+ React.useEffect(() => {
184
+ if (content) {
185
+ // Use provided content directly
186
+ const parsed = parseVtt(content);
187
+ setCues(
188
+ stripTags
189
+ ? parsed.map((cue) => ({ ...cue, text: stripVttTags(cue.text) }))
190
+ : parsed,
191
+ );
192
+ return;
193
+ }
194
+
195
+ if (!src) {
196
+ setCues([]);
197
+ return;
198
+ }
199
+
200
+ setIsLoading(true);
201
+ setError(null);
202
+
203
+ fetch(src)
204
+ .then((response) => {
205
+ if (!response.ok) {
206
+ throw new Error(`Failed to fetch captions: ${response.status}`);
207
+ }
208
+ return response.text();
209
+ })
210
+ .then((vttContent) => {
211
+ const parsed = parseVtt(vttContent);
212
+ setCues(
213
+ stripTags
214
+ ? parsed.map((cue) => ({ ...cue, text: stripVttTags(cue.text) }))
215
+ : parsed,
216
+ );
217
+ })
218
+ .catch((err) => {
219
+ setError(err instanceof Error ? err : new Error(String(err)));
220
+ })
221
+ .finally(() => {
222
+ setIsLoading(false);
223
+ });
224
+ }, [src, content, stripTags]);
225
+
226
+ // Find active cue for current time
227
+ const activeCue = React.useMemo(() => {
228
+ return (
229
+ cues.find(
230
+ (cue) => currentTime >= cue.startTime && currentTime <= cue.endTime,
231
+ ) ?? null
232
+ );
233
+ }, [cues, currentTime]);
234
+
235
+ // Memoize setCurrentTime to avoid unnecessary re-renders
236
+ const handleSetCurrentTime = React.useCallback((time: number) => {
237
+ setInternalTime(time);
238
+ }, []);
239
+
240
+ return {
241
+ cues,
242
+ activeCue,
243
+ setCurrentTime: handleSetCurrentTime,
244
+ isLoading,
245
+ error,
246
+ };
247
+ }
@@ -0,0 +1,230 @@
1
+ import * as React from "react";
2
+
3
+ // ============================================================================
4
+ // Types
5
+ // ============================================================================
6
+
7
+ export interface UseVideoKeyboardOptions {
8
+ /** Ref to the video element */
9
+ videoRef: React.RefObject<HTMLVideoElement | null>;
10
+ /** Whether keyboard handling is enabled (default: true) */
11
+ enabled?: boolean;
12
+ /** Seek amount in seconds for arrow keys (default: 5) */
13
+ seekAmount?: number;
14
+ /** Volume change amount for arrow keys (default: 0.1) */
15
+ volumeStep?: number;
16
+ /** Callback when play/pause is toggled */
17
+ onTogglePlay?: () => void;
18
+ /** Callback when fullscreen is toggled */
19
+ onToggleFullscreen?: () => void;
20
+ /** Callback when captions are toggled */
21
+ onToggleCaptions?: () => void;
22
+ /** Callback when controls should be shown */
23
+ onShowControls?: () => void;
24
+ }
25
+
26
+ export interface UseVideoKeyboardReturn {
27
+ /** Key down handler to attach to container element */
28
+ handleKeyDown: (e: React.KeyboardEvent) => void;
29
+ /** Props to spread on the container element */
30
+ containerProps: {
31
+ onKeyDown: (e: React.KeyboardEvent) => void;
32
+ tabIndex: number;
33
+ role: string;
34
+ "aria-label": string;
35
+ };
36
+ }
37
+
38
+ // ============================================================================
39
+ // Hook
40
+ // ============================================================================
41
+
42
+ /**
43
+ * Hook for handling keyboard shortcuts in a video player.
44
+ *
45
+ * Supported shortcuts:
46
+ * - Space: Play/pause
47
+ * - Left Arrow: Seek backward
48
+ * - Right Arrow: Seek forward
49
+ * - Up Arrow: Volume up
50
+ * - Down Arrow: Volume down
51
+ * - M: Toggle mute
52
+ * - F: Toggle fullscreen
53
+ * - C: Toggle captions
54
+ *
55
+ * @example
56
+ * ```tsx
57
+ * const { containerProps } = useVideoKeyboard({
58
+ * videoRef,
59
+ * onTogglePlay: () => video.paused ? video.play() : video.pause(),
60
+ * onToggleFullscreen: () => toggleFullscreen(),
61
+ * onShowControls: () => setControlsVisible(true),
62
+ * });
63
+ *
64
+ * return <div {...containerProps}>...</div>;
65
+ * ```
66
+ */
67
+ export function useVideoKeyboard({
68
+ videoRef,
69
+ enabled = true,
70
+ seekAmount = 5,
71
+ volumeStep = 0.1,
72
+ onTogglePlay,
73
+ onToggleFullscreen,
74
+ onToggleCaptions,
75
+ onShowControls,
76
+ }: UseVideoKeyboardOptions): UseVideoKeyboardReturn {
77
+ const handleKeyDown = React.useCallback(
78
+ (e: React.KeyboardEvent) => {
79
+ if (!enabled) return;
80
+
81
+ const video = videoRef.current;
82
+ if (!video) return;
83
+
84
+ // Don't handle if focus is on interactive elements
85
+ const target = e.target as HTMLElement;
86
+ if (
87
+ target.tagName === "BUTTON" ||
88
+ target.tagName === "INPUT" ||
89
+ target.tagName === "TEXTAREA" ||
90
+ target.closest("button") ||
91
+ target.closest("input") ||
92
+ target.closest("[role='slider']")
93
+ ) {
94
+ return;
95
+ }
96
+
97
+ let handled = false;
98
+
99
+ switch (e.key) {
100
+ // Play/Pause
101
+ case " ":
102
+ case "Spacebar":
103
+ case "k": // YouTube-style shortcut
104
+ if (onTogglePlay) {
105
+ onTogglePlay();
106
+ } else {
107
+ if (video.paused) {
108
+ video.play().catch(() => {});
109
+ } else {
110
+ video.pause();
111
+ }
112
+ }
113
+ handled = true;
114
+ break;
115
+
116
+ // Seek backward
117
+ case "ArrowLeft":
118
+ case "j": // YouTube-style shortcut
119
+ video.currentTime = Math.max(0, video.currentTime - seekAmount);
120
+ handled = true;
121
+ break;
122
+
123
+ // Seek forward
124
+ case "ArrowRight":
125
+ case "l": // YouTube-style shortcut
126
+ video.currentTime = Math.min(
127
+ video.duration || 0,
128
+ video.currentTime + seekAmount,
129
+ );
130
+ handled = true;
131
+ break;
132
+
133
+ // Volume up
134
+ case "ArrowUp":
135
+ video.volume = Math.min(1, video.volume + volumeStep);
136
+ video.muted = false;
137
+ handled = true;
138
+ break;
139
+
140
+ // Volume down
141
+ case "ArrowDown":
142
+ video.volume = Math.max(0, video.volume - volumeStep);
143
+ handled = true;
144
+ break;
145
+
146
+ // Toggle mute
147
+ case "m":
148
+ case "M":
149
+ video.muted = !video.muted;
150
+ handled = true;
151
+ break;
152
+
153
+ // Toggle fullscreen
154
+ case "f":
155
+ case "F":
156
+ onToggleFullscreen?.();
157
+ handled = true;
158
+ break;
159
+
160
+ // Toggle captions
161
+ case "c":
162
+ case "C":
163
+ onToggleCaptions?.();
164
+ handled = true;
165
+ break;
166
+
167
+ // Jump to start
168
+ case "Home":
169
+ video.currentTime = 0;
170
+ handled = true;
171
+ break;
172
+
173
+ // Jump to end
174
+ case "End":
175
+ video.currentTime = video.duration || 0;
176
+ handled = true;
177
+ break;
178
+
179
+ // Number keys for percentage seeking (0-9)
180
+ case "0":
181
+ case "1":
182
+ case "2":
183
+ case "3":
184
+ case "4":
185
+ case "5":
186
+ case "6":
187
+ case "7":
188
+ case "8":
189
+ case "9":
190
+ if (video.duration) {
191
+ const percent = parseInt(e.key, 10) / 10;
192
+ video.currentTime = video.duration * percent;
193
+ handled = true;
194
+ }
195
+ break;
196
+ }
197
+
198
+ if (handled) {
199
+ e.preventDefault();
200
+ e.stopPropagation();
201
+ onShowControls?.();
202
+ }
203
+ },
204
+ [
205
+ enabled,
206
+ videoRef,
207
+ seekAmount,
208
+ volumeStep,
209
+ onTogglePlay,
210
+ onToggleFullscreen,
211
+ onToggleCaptions,
212
+ onShowControls,
213
+ ],
214
+ );
215
+
216
+ const containerProps = React.useMemo(
217
+ () => ({
218
+ onKeyDown: handleKeyDown,
219
+ tabIndex: 0,
220
+ role: "application" as const,
221
+ "aria-label": "Video player, press space to play or pause",
222
+ }),
223
+ [handleKeyDown],
224
+ );
225
+
226
+ return {
227
+ handleKeyDown,
228
+ containerProps,
229
+ };
230
+ }
package/src/lib/utils.ts CHANGED
@@ -1 +1,8 @@
1
- export { cnBase as cn } from "tailwind-variants";
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { cnBase as twMerge } from "tailwind-variants";
3
+
4
+ export { twMerge };
5
+
6
+ export function cn(...inputs: ClassValue[]) {
7
+ return twMerge(clsx(inputs));
8
+ }