@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.
@@ -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,8 +1,8 @@
1
- import { clsx, type ClassValue } from "clsx";
1
+ import { type ClassValue, clsx } from "clsx";
2
2
  import { cnBase as twMerge } from "tailwind-variants";
3
3
 
4
4
  export { twMerge };
5
5
 
6
6
  export function cn(...inputs: ClassValue[]) {
7
- return twMerge(clsx(inputs));
7
+ return twMerge(clsx(inputs));
8
8
  }
@@ -11,13 +11,17 @@ export type {
11
11
  ColorThemeName,
12
12
  CSSVariableMap,
13
13
  NestedStringRecord,
14
+ ResolvedProjectTheme,
14
15
  SurfaceThemeName,
15
16
  ThemeComposition,
16
17
  } from "@nds-design-system/tokens";
18
+ // Re-export project theme utilities for convenience
17
19
  // Re-export theme registries for programmatic access
18
20
  export {
19
21
  colorThemeNames,
20
22
  colorThemes,
23
+ defineTheme,
24
+ generateThemeCSS,
21
25
  surfaceThemeNames,
22
26
  surfaceThemes,
23
27
  } from "@nds-design-system/tokens";
@@ -9,6 +9,7 @@ import type {
9
9
  ColorThemeName,
10
10
  CSSVariableMap,
11
11
  NestedStringRecord,
12
+ ResolvedProjectTheme,
12
13
  SurfaceThemeName,
13
14
  TokenModule,
14
15
  } from "@nds-design-system/tokens";
@@ -46,6 +47,11 @@ export interface ThemeProviderProps {
46
47
  color?: ColorThemeName;
47
48
  /** Surface theme name (defaults to "base") */
48
49
  surface?: SurfaceThemeName;
50
+ /**
51
+ * Custom project theme created with defineTheme()
52
+ * When provided, overrides color/surface props with custom theme tokens
53
+ */
54
+ customTheme?: ResolvedProjectTheme;
49
55
  /** Children to render */
50
56
  children: ReactNode;
51
57
  /** Optional className for the wrapper div */
@@ -239,8 +245,23 @@ function deepMerge(
239
245
  * <App />
240
246
  * </ThemeProvider>
241
247
  *
242
- * // Mix and match
243
- * <ThemeProvider color="institution" surface="soft">
248
+ * // Use a custom project theme
249
+ * import { defineTheme, srgb } from "@nds-design-system/tokens";
250
+ *
251
+ * const myTheme = defineTheme({
252
+ * name: "my-project",
253
+ * extends: "base",
254
+ * tokens: {
255
+ * semantic: {
256
+ * color: {
257
+ * bg: { page: srgb("#FEFDF9") },
258
+ * accent: { brand: srgb("#A68B5E") },
259
+ * },
260
+ * },
261
+ * },
262
+ * });
263
+ *
264
+ * <ThemeProvider customTheme={myTheme}>
244
265
  * <App />
245
266
  * </ThemeProvider>
246
267
  * ```
@@ -248,6 +269,7 @@ function deepMerge(
248
269
  export function ThemeProvider({
249
270
  color = "base",
250
271
  surface = "base",
272
+ customTheme,
251
273
  children,
252
274
  className,
253
275
  applyStyles = true,
@@ -255,12 +277,17 @@ export function ThemeProvider({
255
277
  const { tokens, cssVars } = useMemo(() => {
256
278
  const flatTokens: Record<string, string> = {};
257
279
 
258
- // Get color theme (merge with base if not base)
280
+ // Get base color theme
259
281
  const baseColorModule = colorThemes.base;
260
- const colorModule = colorThemes[color];
282
+
283
+ // If customTheme is provided, use its base theme; otherwise use the color prop
284
+ const effectiveColor = customTheme ? customTheme.extends : color;
285
+ const colorModule =
286
+ colorThemes[effectiveColor as keyof typeof colorThemes] ??
287
+ colorThemes.base;
261
288
 
262
289
  const mergedColor =
263
- color === "base"
290
+ effectiveColor === "base"
264
291
  ? (baseColorModule as Record<string, unknown>)
265
292
  : mergeTokenModules(baseColorModule, colorModule);
266
293
 
@@ -287,15 +314,28 @@ export function ThemeProvider({
287
314
 
288
315
  // Use shared utilities from tokens package
289
316
  const nestedTokens = flatToNested(flatTokens);
290
- const cssVariables = flatToCSSVars(flatTokens);
317
+ let cssVariables = flatToCSSVars(flatTokens);
318
+
319
+ // If customTheme provided, merge its CSS variables (overriding base values)
320
+ if (customTheme) {
321
+ cssVariables = {
322
+ ...cssVariables,
323
+ ...Object.fromEntries(
324
+ Object.entries(customTheme.cssVars).map(([key, value]) => [
325
+ key.startsWith("--") ? key : `--${key}`,
326
+ value,
327
+ ]),
328
+ ),
329
+ };
330
+ }
291
331
 
292
332
  return { tokens: nestedTokens, cssVars: cssVariables };
293
- }, [color, surface]);
333
+ }, [color, surface, customTheme]);
294
334
 
295
335
  const contextValue: ThemeContextValue = {
296
336
  cssVars,
297
337
  tokens,
298
- colorTheme: color,
338
+ colorTheme: customTheme ? (customTheme.extends as ColorThemeName) : color,
299
339
  surfaceTheme: surface,
300
340
  };
301
341