@nationaldesignstudio/react 0.5.5 → 0.6.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.
@@ -12,6 +12,16 @@ const cardVariants = tv({
12
12
  * Use with Background components for images/gradients.
13
13
  */
14
14
  overlay: "w-full flex-col",
15
+ /**
16
+ * Profile layout - square image with stacked content below.
17
+ * Ideal for team member cards, user profiles, testimonials.
18
+ */
19
+ profile: "w-full flex-col",
20
+ /**
21
+ * Compact layout - small thumbnail with condensed horizontal content.
22
+ * Ideal for news items, article previews, resource lists.
23
+ */
24
+ compact: "w-full flex-row items-center gap-16",
15
25
  },
16
26
  },
17
27
  defaultVariants: {
@@ -48,16 +58,26 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
48
58
  Card.displayName = "Card";
49
59
 
50
60
  const cardImageVariants = tv({
51
- base: [
52
- "relative shrink-0 bg-bg-muted",
53
- // Vertical: full width with aspect ratio
54
- "aspect-video w-full",
55
- // When in horizontal card (parent has flex-row), override
56
- "[.flex-row>&]:aspect-auto [.flex-row>&]:w-2/5 [.flex-row>&]:self-stretch",
57
- ],
61
+ base: "relative shrink-0 bg-bg-muted",
62
+ variants: {
63
+ layout: {
64
+ vertical: "aspect-video w-full",
65
+ horizontal: "aspect-auto w-2/5 self-stretch",
66
+ overlay: "aspect-video w-full",
67
+ /** Profile: square aspect ratio for headshots/avatars */
68
+ profile: "aspect-square w-full",
69
+ /** Compact: fixed small size for thumbnails */
70
+ compact: "size-80 rounded-8",
71
+ },
72
+ },
73
+ defaultVariants: {
74
+ layout: "vertical",
75
+ },
58
76
  });
59
77
 
60
- export interface CardImageProps extends React.HTMLAttributes<HTMLDivElement> {
78
+ export interface CardImageProps
79
+ extends React.HTMLAttributes<HTMLDivElement>,
80
+ VariantProps<typeof cardImageVariants> {
61
81
  /**
62
82
  * The image source URL
63
83
  */
@@ -69,15 +89,18 @@ export interface CardImageProps extends React.HTMLAttributes<HTMLDivElement> {
69
89
  }
70
90
 
71
91
  /**
72
- * Card image area. For vertical layout, displays with 16:9 aspect ratio.
73
- * For horizontal layout, takes up ~40% width and stretches to content height.
92
+ * Card image area with layout-specific styling.
93
+ * - vertical: 16:9 aspect ratio, full width
94
+ * - horizontal: ~40% width, stretches to content height
95
+ * - profile: square aspect ratio for headshots/avatars
96
+ * - compact: fixed small size for thumbnails
74
97
  */
75
98
  const CardImage = React.forwardRef<HTMLDivElement, CardImageProps>(
76
- ({ className, src, alt = "", ...props }, ref) => {
99
+ ({ className, src, alt = "", layout, ...props }, ref) => {
77
100
  return (
78
101
  <div
79
102
  ref={ref}
80
- className={cardImageVariants({ class: className })}
103
+ className={cardImageVariants({ layout, class: className })}
81
104
  {...props}
82
105
  >
83
106
  {src && (
@@ -263,6 +286,56 @@ const CardActions = React.forwardRef<HTMLDivElement, CardActionsProps>(
263
286
  );
264
287
  CardActions.displayName = "CardActions";
265
288
 
289
+ const cardLinkVariants = tv({
290
+ base: "group/link flex items-center gap-4 text-text-muted transition-colors duration-200 hover:text-text-primary",
291
+ });
292
+
293
+ export interface CardLinkProps extends React.HTMLAttributes<HTMLSpanElement> {
294
+ /**
295
+ * Whether to show the arrow indicator
296
+ * @default true
297
+ */
298
+ showArrow?: boolean;
299
+ }
300
+
301
+ /**
302
+ * Inline link element for cards with optional animated arrow.
303
+ * Commonly used for "Read More →" or "Learn More →" patterns.
304
+ *
305
+ * @example
306
+ * ```tsx
307
+ * <Card layout="compact">
308
+ * <CardImage src="/thumb.jpg" layout="compact" />
309
+ * <CardContent>
310
+ * <CardTitle>Article Title</CardTitle>
311
+ * <CardLink>Read More</CardLink>
312
+ * </CardContent>
313
+ * </Card>
314
+ * ```
315
+ */
316
+ const CardLink = React.forwardRef<HTMLSpanElement, CardLinkProps>(
317
+ ({ className, children, showArrow = true, ...props }, ref) => {
318
+ return (
319
+ <span
320
+ ref={ref}
321
+ className={cardLinkVariants({ class: className })}
322
+ {...props}
323
+ >
324
+ <span>{children}</span>
325
+ {showArrow && (
326
+ <span
327
+ aria-hidden="true"
328
+ className="inline-block transition-transform duration-200 ease-out group-hover/link:translate-x-4"
329
+ >
330
+
331
+ </span>
332
+ )}
333
+ </span>
334
+ );
335
+ },
336
+ );
337
+ CardLink.displayName = "CardLink";
338
+
266
339
  export {
267
340
  Card,
268
341
  cardVariants,
@@ -280,4 +353,6 @@ export {
280
353
  cardBodyVariants,
281
354
  CardActions,
282
355
  cardActionsVariants,
356
+ CardLink,
357
+ cardLinkVariants,
283
358
  };
@@ -30,6 +30,7 @@ const heroVariants = tv({
30
30
  "md:p-56",
31
31
  ],
32
32
  title: DEFAULT_TITLE_TYPOGRAPHY,
33
+ indicator: "absolute inset-x-0 bottom-0 z-10 flex justify-center pb-24",
33
34
  },
34
35
  variants: {
35
36
  variant: {
@@ -46,6 +47,21 @@ const heroVariants = tv({
46
47
  content: ["items-center justify-center", "lg:p-64"],
47
48
  },
48
49
  },
50
+ /**
51
+ * Vertical alignment of content within the hero.
52
+ * Provides a simpler API than variant for basic alignment needs.
53
+ */
54
+ contentAlign: {
55
+ top: {
56
+ content: "justify-start",
57
+ },
58
+ center: {
59
+ content: "items-center justify-center",
60
+ },
61
+ bottom: {
62
+ content: "justify-end",
63
+ },
64
+ },
49
65
  colorScheme: {
50
66
  dark: {
51
67
  root: "bg-bg-page",
@@ -144,11 +160,24 @@ export interface HeroProps
144
160
  * - light: Light text for use on dark backgrounds
145
161
  */
146
162
  colorScheme?: "dark" | "light";
163
+ // Note: contentAlign is provided by VariantProps<typeof heroVariants>
164
+ // Options: "top" | "center" | "bottom"
147
165
  /**
148
166
  * Content for the top slot (full-width, no padding).
149
167
  * Use for USGovBanner, Navigation, etc.
150
168
  */
151
169
  top?: React.ReactNode;
170
+ /**
171
+ * Indicator slot for scroll hints, arrows, or other visual cues.
172
+ * Rendered at the bottom of the hero, below the main content.
173
+ * @example
174
+ * ```tsx
175
+ * <Hero indicator={<ChevronDown className="animate-bounce" />}>
176
+ * <h1>Welcome</h1>
177
+ * </Hero>
178
+ * ```
179
+ */
180
+ indicator?: React.ReactNode;
152
181
  /**
153
182
  * Background for the hero. Can be:
154
183
  * - A color string (hex, rgb, etc.) for solid backgrounds
@@ -235,7 +264,9 @@ const Hero = React.forwardRef<HTMLElement, HeroProps>(
235
264
  title,
236
265
  titleClassName,
237
266
  colorScheme = "dark",
267
+ contentAlign,
238
268
  top,
269
+ indicator,
239
270
  variant,
240
271
  background,
241
272
  overlayOpacity = 0,
@@ -251,6 +282,7 @@ const Hero = React.forwardRef<HTMLElement, HeroProps>(
251
282
  const hasMediaBackground = background && !isColor;
252
283
  const styles = heroVariants({
253
284
  variant,
285
+ contentAlign,
254
286
  colorScheme,
255
287
  hasBackground: !!background,
256
288
  });
@@ -295,6 +327,9 @@ const Hero = React.forwardRef<HTMLElement, HeroProps>(
295
327
  {/* Children - always render if provided */}
296
328
  {children}
297
329
  </div>
330
+
331
+ {/* Indicator slot - scroll hints, arrows, etc. */}
332
+ {indicator && <div className={styles.indicator()}>{indicator}</div>}
298
333
  </section>
299
334
  );
300
335
  },
@@ -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";
@@ -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
+ }