@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.
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Select as BaseSelect } from "@base-ui-components/react/select";
4
+ import { Check, ChevronDown, ChevronUp } from "lucide-react";
4
5
  import * as React from "react";
5
6
  import { tv, type VariantProps } from "tailwind-variants";
6
7
  import {
@@ -19,6 +20,7 @@ import { cn } from "@/lib/utils";
19
20
  * - Focus/Open: Accent border with focus ring
20
21
  * - Selected: Has a value selected (darker text)
21
22
  * - Disabled: Reduced opacity, not interactive
23
+ * - Invalid: Error border and styling
22
24
  */
23
25
  const selectTriggerVariants = tv({
24
26
  base: [
@@ -29,6 +31,8 @@ const selectTriggerVariants = tv({
29
31
  "data-[disabled]:bg-ui-control-background-disabled data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
30
32
  // Open state styling
31
33
  "data-[popup-open]:border-ui-accent-base data-[popup-open]:ring-4 data-[popup-open]:ring-ui-color-focus data-[popup-open]:bg-ui-control-background",
34
+ // Invalid state (aria-invalid)
35
+ "aria-[invalid=true]:border-ui-error-color aria-[invalid=true]:ring-ui-error-color/20",
32
36
  ],
33
37
  variants: {
34
38
  size: formControlSizes,
@@ -42,16 +46,25 @@ const selectTriggerVariants = tv({
42
46
 
43
47
  /**
44
48
  * Select popup/menu variants
49
+ *
50
+ * Uses overlay tokens for consistent floating panel styling:
51
+ * - color.overlay.background - Light background
52
+ * - color.overlay.border - Subtle border
53
+ * - surface.overlay.radius - Rounded corners
45
54
  */
46
55
  const selectPopupVariants = tv({
47
56
  base: [
48
57
  // Layout - match trigger width using CSS custom property from Base UI
49
- "flex flex-col gap-2 p-10",
58
+ "flex flex-col gap-2 p-8",
50
59
  "w-[var(--anchor-width)]",
51
- // Background and border - uses surface ui radius for theming support
52
- "bg-ui-control-background border border-solid border-ui-color-border-active rounded-surface-ui-medium",
53
- // Focus ring shadow (ui-focus-state from Figma)
54
- "ring-4 ring-ui-color-focus",
60
+ // Background - uses overlay token
61
+ "bg-overlay-background",
62
+ // Border - uses overlay token for subtle border
63
+ "border border-overlay-border",
64
+ // Border radius - uses overlay token for consistency with other overlays
65
+ "rounded-surface-overlay",
66
+ // Shadow for elevation
67
+ "shadow-lg",
55
68
  // Animation
56
69
  "origin-[var(--transform-origin)]",
57
70
  "transition-[transform,scale,opacity] duration-150",
@@ -59,6 +72,8 @@ const selectPopupVariants = tv({
59
72
  "data-[ending-style]:scale-95 data-[ending-style]:opacity-0",
60
73
  // Ensure it's above other content
61
74
  "z-50",
75
+ // Scrollable support
76
+ "overflow-y-auto max-h-[var(--available-height,300px)]",
62
77
  ],
63
78
  });
64
79
 
@@ -95,46 +110,27 @@ const selectOptionVariants = tv({
95
110
  });
96
111
 
97
112
  /**
98
- * Chevron icon for the select trigger
113
+ * Select separator variants
99
114
  */
100
- const SelectChevronIcon = ({ className }: { className?: string }) => (
101
- <svg
102
- className={cn("size-16 text-gray-500 shrink-0", className)}
103
- viewBox="0 0 16 16"
104
- fill="none"
105
- xmlns="http://www.w3.org/2000/svg"
106
- aria-hidden="true"
107
- >
108
- <path
109
- d="M4 6L8 10L12 6"
110
- stroke="currentColor"
111
- strokeWidth="1.5"
112
- strokeLinecap="round"
113
- strokeLinejoin="round"
114
- />
115
- </svg>
116
- );
115
+ const selectSeparatorVariants = tv({
116
+ base: ["h-px my-6 -mx-8", "bg-overlay-border"],
117
+ });
117
118
 
118
119
  /**
119
- * Checkmark icon for selected options
120
+ * Select scroll arrow variants (shared by up and down)
120
121
  */
121
- const CheckIcon = ({ className }: { className?: string }) => (
122
- <svg
123
- className={cn("size-14 shrink-0", className)}
124
- viewBox="0 0 14 14"
125
- fill="none"
126
- xmlns="http://www.w3.org/2000/svg"
127
- aria-hidden="true"
128
- >
129
- <path
130
- d="M11.6666 3.5L5.24992 9.91667L2.33325 7"
131
- stroke="currentColor"
132
- strokeWidth="1.5"
133
- strokeLinecap="round"
134
- strokeLinejoin="round"
135
- />
136
- </svg>
137
- );
122
+ const selectScrollArrowVariants = tv({
123
+ base: [
124
+ "flex items-center justify-center",
125
+ "py-4",
126
+ "text-text-muted",
127
+ "cursor-default",
128
+ // Sticky positioning for scroll indicators
129
+ "data-[direction=up]:sticky data-[direction=up]:top-0",
130
+ "data-[direction=down]:sticky data-[direction=down]:bottom-0",
131
+ "bg-overlay-background",
132
+ ],
133
+ });
138
134
 
139
135
  // ============================================================================
140
136
  // Select Root
@@ -161,17 +157,33 @@ export interface SelectTriggerProps
161
157
  VariantProps<typeof selectTriggerVariants> {
162
158
  className?: string;
163
159
  placeholder?: string;
160
+ /**
161
+ * Accessible label for the select trigger.
162
+ * Required for accessibility when no visible label is present.
163
+ */
164
+ "aria-label"?: string;
164
165
  }
165
166
 
166
167
  const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerProps>(
167
168
  (
168
- { className, size, error, placeholder = "Select option...", ...props },
169
+ {
170
+ className,
171
+ size,
172
+ error,
173
+ placeholder = "Select option...",
174
+ "aria-label": ariaLabel,
175
+ ...props
176
+ },
169
177
  ref,
170
178
  ) => {
171
179
  return (
172
180
  <BaseSelect.Trigger
173
181
  ref={ref}
182
+ aria-label={ariaLabel ?? placeholder}
183
+ aria-invalid={error || undefined}
174
184
  className={cn(selectTriggerVariants({ size, error }), className)}
185
+ data-size={size ?? "default"}
186
+ data-error={error ?? false}
175
187
  {...props}
176
188
  >
177
189
  <BaseSelect.Value>
@@ -183,8 +195,8 @@ const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerProps>(
183
195
  )
184
196
  }
185
197
  </BaseSelect.Value>
186
- <BaseSelect.Icon>
187
- <SelectChevronIcon />
198
+ <BaseSelect.Icon aria-hidden="true">
199
+ <ChevronDown className="size-16 text-gray-500 shrink-0" />
188
200
  </BaseSelect.Icon>
189
201
  </BaseSelect.Trigger>
190
202
  );
@@ -193,19 +205,53 @@ const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerProps>(
193
205
  SelectTrigger.displayName = "SelectTrigger";
194
206
 
195
207
  // ============================================================================
196
- // Select Portal & Popup
208
+ // Select Value (for custom trigger compositions)
209
+ // ============================================================================
210
+
211
+ export interface SelectValueProps
212
+ extends Omit<React.ComponentProps<typeof BaseSelect.Value>, "className"> {
213
+ className?: string;
214
+ placeholder?: string;
215
+ }
216
+
217
+ const SelectValue = React.forwardRef<HTMLSpanElement, SelectValueProps>(
218
+ ({ className, placeholder = "Select option...", ...props }, ref) => {
219
+ return (
220
+ <BaseSelect.Value ref={ref} className={className} {...props}>
221
+ {(value) =>
222
+ value ? value : <span className="text-text-muted">{placeholder}</span>
223
+ }
224
+ </BaseSelect.Value>
225
+ );
226
+ },
227
+ );
228
+ SelectValue.displayName = "SelectValue";
229
+
230
+ // ============================================================================
231
+ // Select Portal & Popup (Content)
197
232
  // ============================================================================
198
233
 
199
234
  export interface SelectPopupProps
200
235
  extends Omit<React.ComponentProps<typeof BaseSelect.Popup>, "className"> {
201
236
  className?: string;
237
+ /**
238
+ * Whether the selected item should align with the trigger.
239
+ * When true (default), the popup positions so the selected item appears over the trigger.
240
+ * When false, the popup aligns to the trigger edge.
241
+ */
242
+ alignItemWithTrigger?: boolean;
202
243
  }
203
244
 
204
245
  const SelectPopup = React.forwardRef<HTMLDivElement, SelectPopupProps>(
205
- ({ className, children, ...props }, ref) => {
246
+ ({ className, children, alignItemWithTrigger = true, ...props }, ref) => {
206
247
  return (
207
248
  <BaseSelect.Portal>
208
- <BaseSelect.Positioner side="bottom" sideOffset={4} align="start">
249
+ <BaseSelect.Positioner
250
+ side="bottom"
251
+ sideOffset={4}
252
+ align="start"
253
+ alignItemWithTrigger={alignItemWithTrigger}
254
+ >
209
255
  <BaseSelect.Popup
210
256
  ref={ref}
211
257
  className={cn(selectPopupVariants(), className)}
@@ -220,6 +266,9 @@ const SelectPopup = React.forwardRef<HTMLDivElement, SelectPopupProps>(
220
266
  );
221
267
  SelectPopup.displayName = "SelectPopup";
222
268
 
269
+ // Alias for shadcn compatibility
270
+ const SelectContent = SelectPopup;
271
+
223
272
  // ============================================================================
224
273
  // Select Option (wraps Base UI's Select.Item)
225
274
  // ============================================================================
@@ -238,8 +287,8 @@ const SelectOption = React.forwardRef<HTMLDivElement, SelectOptionProps>(
238
287
  {...props}
239
288
  >
240
289
  <BaseSelect.ItemText>{children}</BaseSelect.ItemText>
241
- <BaseSelect.ItemIndicator>
242
- <CheckIcon />
290
+ <BaseSelect.ItemIndicator aria-hidden="true">
291
+ <Check className="size-14 shrink-0" />
243
292
  </BaseSelect.ItemIndicator>
244
293
  </BaseSelect.Item>
245
294
  );
@@ -247,6 +296,9 @@ const SelectOption = React.forwardRef<HTMLDivElement, SelectOptionProps>(
247
296
  );
248
297
  SelectOption.displayName = "SelectOption";
249
298
 
299
+ // Alias for shadcn compatibility
300
+ const SelectItem = SelectOption;
301
+
250
302
  // ============================================================================
251
303
  // Select Group
252
304
  // ============================================================================
@@ -298,27 +350,128 @@ const SelectGroupLabel = React.forwardRef<
298
350
  });
299
351
  SelectGroupLabel.displayName = "SelectGroupLabel";
300
352
 
353
+ // Alias for shadcn compatibility
354
+ const SelectLabel = SelectGroupLabel;
355
+
356
+ // ============================================================================
357
+ // Select Separator
358
+ // ============================================================================
359
+
360
+ export interface SelectSeparatorProps
361
+ extends Omit<React.ComponentProps<typeof BaseSelect.Separator>, "className"> {
362
+ className?: string;
363
+ }
364
+
365
+ const SelectSeparator = React.forwardRef<HTMLDivElement, SelectSeparatorProps>(
366
+ ({ className, ...props }, ref) => {
367
+ return (
368
+ <BaseSelect.Separator
369
+ ref={ref}
370
+ className={cn(selectSeparatorVariants(), className)}
371
+ {...props}
372
+ />
373
+ );
374
+ },
375
+ );
376
+ SelectSeparator.displayName = "SelectSeparator";
377
+
378
+ // ============================================================================
379
+ // Select Scroll Up Arrow
380
+ // ============================================================================
381
+
382
+ export interface SelectScrollUpArrowProps
383
+ extends Omit<
384
+ React.ComponentProps<typeof BaseSelect.ScrollUpArrow>,
385
+ "className"
386
+ > {
387
+ className?: string;
388
+ }
389
+
390
+ const SelectScrollUpArrow = React.forwardRef<
391
+ HTMLDivElement,
392
+ SelectScrollUpArrowProps
393
+ >(({ className, ...props }, ref) => {
394
+ return (
395
+ <BaseSelect.ScrollUpArrow
396
+ ref={ref}
397
+ data-direction="up"
398
+ aria-label="Scroll up"
399
+ className={cn(selectScrollArrowVariants(), className)}
400
+ {...props}
401
+ >
402
+ <ChevronUp className="size-12" aria-hidden="true" />
403
+ </BaseSelect.ScrollUpArrow>
404
+ );
405
+ });
406
+ SelectScrollUpArrow.displayName = "SelectScrollUpArrow";
407
+
408
+ // ============================================================================
409
+ // Select Scroll Down Arrow
410
+ // ============================================================================
411
+
412
+ export interface SelectScrollDownArrowProps
413
+ extends Omit<
414
+ React.ComponentProps<typeof BaseSelect.ScrollDownArrow>,
415
+ "className"
416
+ > {
417
+ className?: string;
418
+ }
419
+
420
+ const SelectScrollDownArrow = React.forwardRef<
421
+ HTMLDivElement,
422
+ SelectScrollDownArrowProps
423
+ >(({ className, ...props }, ref) => {
424
+ return (
425
+ <BaseSelect.ScrollDownArrow
426
+ ref={ref}
427
+ data-direction="down"
428
+ aria-label="Scroll down"
429
+ className={cn(selectScrollArrowVariants(), className)}
430
+ {...props}
431
+ >
432
+ <ChevronDown className="size-12" aria-hidden="true" />
433
+ </BaseSelect.ScrollDownArrow>
434
+ );
435
+ });
436
+ SelectScrollDownArrow.displayName = "SelectScrollDownArrow";
437
+
301
438
  // ============================================================================
302
439
  // Compound Component Export
303
440
  // ============================================================================
304
441
 
305
442
  export const Select = Object.assign(SelectRoot, {
306
443
  Trigger: SelectTrigger,
444
+ Value: SelectValue,
307
445
  Popup: SelectPopup,
446
+ Content: SelectContent,
308
447
  Option: SelectOption,
448
+ Item: SelectItem,
309
449
  Group: SelectGroup,
310
450
  GroupLabel: SelectGroupLabel,
451
+ Label: SelectLabel,
452
+ Separator: SelectSeparator,
453
+ ScrollUpArrow: SelectScrollUpArrow,
454
+ ScrollDownArrow: SelectScrollDownArrow,
311
455
  });
312
456
 
313
457
  // Also export individual components for flexibility
314
458
  export {
315
459
  SelectRoot,
316
460
  SelectTrigger,
461
+ SelectValue,
317
462
  SelectPopup,
463
+ SelectContent,
318
464
  SelectOption,
465
+ SelectItem,
319
466
  SelectGroup,
320
467
  SelectGroupLabel,
468
+ SelectLabel,
469
+ SelectSeparator,
470
+ SelectScrollUpArrow,
471
+ SelectScrollDownArrow,
321
472
  selectTriggerVariants,
322
473
  selectPopupVariants,
323
474
  selectOptionVariants,
475
+ selectSeparatorVariants,
476
+ selectScrollArrowVariants,
324
477
  };
@@ -0,0 +1,107 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { tv, type VariantProps } from "tailwind-variants";
5
+ import type { CaptionCue } from "@/hooks/use-captions";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ /**
9
+ * Caption overlay variants using semantic tokens.
10
+ */
11
+ const captionOverlayVariants = tv({
12
+ base: [
13
+ // Positioning - absolute at bottom of video container
14
+ "pointer-events-none",
15
+ "absolute right-0 left-0",
16
+ "z-10",
17
+ "flex justify-center",
18
+ "px-4",
19
+ ],
20
+ variants: {
21
+ position: {
22
+ bottom: "bottom-64",
23
+ "bottom-sm": "bottom-24",
24
+ },
25
+ },
26
+ defaultVariants: {
27
+ position: "bottom-sm",
28
+ },
29
+ });
30
+
31
+ /**
32
+ * Caption text box variants.
33
+ */
34
+ const captionTextVariants = tv({
35
+ base: [
36
+ "flex items-center justify-center",
37
+ "w-fit max-w-[80%]",
38
+ "gap-10",
39
+ "px-12 py-6",
40
+ "text-center",
41
+ "font-normal leading-[1.4]",
42
+ "rounded-6",
43
+ "bg-video-player-caption-bg text-video-player-caption-text",
44
+ ],
45
+ variants: {
46
+ size: {
47
+ sm: "text-14",
48
+ md: "[font-size:clamp(16px,2vw,24px)]",
49
+ lg: "text-24",
50
+ },
51
+ },
52
+ defaultVariants: {
53
+ size: "md",
54
+ },
55
+ });
56
+
57
+ export interface CaptionOverlayProps
58
+ extends React.HTMLAttributes<HTMLOutputElement>,
59
+ VariantProps<typeof captionOverlayVariants>,
60
+ VariantProps<typeof captionTextVariants> {
61
+ /** Caption cue to display */
62
+ cue?: CaptionCue | null;
63
+ /** Caption text to display (alternative to cue) */
64
+ text?: string;
65
+ }
66
+
67
+ /**
68
+ * CaptionOverlay component.
69
+ *
70
+ * Displays caption text overlaid on video content.
71
+ * Styled to match the DGA video player implementation.
72
+ *
73
+ * @example
74
+ * ```tsx
75
+ * <CaptionOverlay cue={activeCue} />
76
+ *
77
+ * // Or with plain text
78
+ * <CaptionOverlay text="Hello, world!" />
79
+ * ```
80
+ */
81
+ const CaptionOverlay = React.forwardRef<HTMLOutputElement, CaptionOverlayProps>(
82
+ ({ className, cue, text, position, size, ...props }, ref) => {
83
+ // Use cue text or fallback to text prop
84
+ const displayText = cue?.text ?? text;
85
+
86
+ // Don't render anything if no caption text
87
+ if (!displayText) {
88
+ return null;
89
+ }
90
+
91
+ return (
92
+ <output
93
+ ref={ref}
94
+ className={cn(captionOverlayVariants({ position }), className)}
95
+ aria-live="polite"
96
+ aria-label="Video caption"
97
+ {...props}
98
+ >
99
+ <span className={captionTextVariants({ size })}>{displayText}</span>
100
+ </output>
101
+ );
102
+ },
103
+ );
104
+
105
+ CaptionOverlay.displayName = "CaptionOverlay";
106
+
107
+ export { CaptionOverlay, captionOverlayVariants, captionTextVariants };