@nationaldesignstudio/react 0.0.19 → 0.2.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.
Files changed (34) hide show
  1. package/dist/component-registry.md +46 -19
  2. package/dist/components/atoms/accordion/accordion.d.ts +9 -9
  3. package/dist/components/atoms/background/background.d.ts +23 -0
  4. package/dist/components/atoms/button/icon-button.d.ts +44 -16
  5. package/dist/components/sections/banner/banner.d.ts +9 -9
  6. package/dist/components/sections/faq-section/faq-section.d.ts +1 -1
  7. package/dist/components/sections/hero/hero.d.ts +115 -18
  8. package/dist/components/sections/tout/tout.d.ts +8 -8
  9. package/dist/components/sections/two-column-section/two-column-section.d.ts +7 -21
  10. package/dist/index.js +2177 -2027
  11. package/dist/index.js.map +1 -1
  12. package/dist/tokens.css +16 -16
  13. package/package.json +1 -1
  14. package/src/components/atoms/accordion/accordion.test.tsx +231 -0
  15. package/src/components/atoms/accordion/accordion.tsx +21 -19
  16. package/src/components/atoms/background/background.test.tsx +213 -0
  17. package/src/components/atoms/background/background.tsx +104 -27
  18. package/src/components/atoms/button/button.tsx +11 -1
  19. package/src/components/atoms/button/icon-button.test.tsx +254 -0
  20. package/src/components/atoms/button/icon-button.tsx +108 -16
  21. package/src/components/atoms/pager-control/pager-control.tsx +32 -3
  22. package/src/components/dev-tools/dev-toolbar/dev-toolbar.tsx +2 -0
  23. package/src/components/organisms/card/card.test.tsx +4 -2
  24. package/src/components/organisms/navbar/navbar.tsx +2 -0
  25. package/src/components/sections/banner/banner.stories.tsx +5 -1
  26. package/src/components/sections/banner/banner.tsx +10 -10
  27. package/src/components/sections/faq-section/faq-section.stories.tsx +7 -7
  28. package/src/components/sections/faq-section/faq-section.tsx +3 -3
  29. package/src/components/sections/hero/hero.tsx +33 -51
  30. package/src/components/sections/tout/tout.stories.tsx +31 -7
  31. package/src/components/sections/tout/tout.tsx +6 -8
  32. package/src/components/sections/two-column-section/two-column-section.stories.tsx +11 -11
  33. package/src/components/sections/two-column-section/two-column-section.tsx +16 -10
  34. package/src/stories/ThemeProvider.stories.tsx +9 -3
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import * as React from "react";
2
4
  import { tv } from "tailwind-variants";
3
5
 
@@ -56,28 +58,69 @@ export interface BackgroundImageProps
56
58
  * Object position (default: "center")
57
59
  */
58
60
  position?: string;
61
+ /**
62
+ * Custom render prop for element composition.
63
+ * Accepts a React element or render function.
64
+ * @example
65
+ * ```tsx
66
+ * // Element pattern
67
+ * <BackgroundImage render={<img className="custom" />} src="/bg.jpg" />
68
+ *
69
+ * // Callback pattern
70
+ * <BackgroundImage render={(props) => <img {...props} />} src="/bg.jpg" />
71
+ * ```
72
+ */
73
+ render?:
74
+ | React.ReactElement
75
+ | ((
76
+ props: React.ImgHTMLAttributes<HTMLImageElement>,
77
+ ) => React.ReactElement);
59
78
  }
60
79
 
61
80
  /**
62
81
  * Background image layer using an actual img element with object-cover.
63
82
  * Supports native lazy loading, srcset, and better accessibility.
83
+ * Supports render prop for element composition.
64
84
  */
65
85
  const BackgroundImage = React.forwardRef<
66
86
  HTMLImageElement,
67
87
  BackgroundImageProps
68
- >(({ className, src, position = "center", alt = "", style, ...props }, ref) => (
69
- <img
70
- ref={ref}
71
- src={src}
72
- alt={alt}
73
- className={backgroundImageVariants({ class: className })}
74
- style={{
75
- objectPosition: position,
76
- ...style,
77
- }}
78
- {...props}
79
- />
80
- ));
88
+ >(
89
+ (
90
+ { className, src, position = "center", alt = "", style, render, ...props },
91
+ ref,
92
+ ) => {
93
+ const imgClassName = backgroundImageVariants({ class: className });
94
+ const imgStyle = { objectPosition: position, ...style };
95
+ const imgProps = {
96
+ src,
97
+ alt,
98
+ className: imgClassName,
99
+ style: imgStyle,
100
+ ...props,
101
+ };
102
+
103
+ // Handle render prop (element or function)
104
+ if (render) {
105
+ if (typeof render === "function") {
106
+ return render({
107
+ ref,
108
+ ...imgProps,
109
+ } as React.ImgHTMLAttributes<HTMLImageElement>);
110
+ }
111
+ // Clone the render element with merged props
112
+ return React.cloneElement(render, {
113
+ ref,
114
+ ...imgProps,
115
+ ...(render.props as Record<string, unknown>),
116
+ });
117
+ }
118
+
119
+ // Default: render as img
120
+ // biome-ignore lint/a11y/useAltText: alt is provided via imgProps spread
121
+ return <img ref={ref} {...imgProps} />;
122
+ },
123
+ );
81
124
  BackgroundImage.displayName = "Background.Image";
82
125
 
83
126
  // =============================================================================
@@ -102,10 +145,23 @@ export interface BackgroundVideoProps
102
145
  * Poster image URL shown before video loads
103
146
  */
104
147
  poster?: string;
148
+ /**
149
+ * Custom render prop for element composition.
150
+ * @example
151
+ * ```tsx
152
+ * <BackgroundVideo render={<video className="custom" />} src="/bg.mp4" />
153
+ * ```
154
+ */
155
+ render?:
156
+ | React.ReactElement
157
+ | ((
158
+ props: React.VideoHTMLAttributes<HTMLVideoElement>,
159
+ ) => React.ReactElement);
105
160
  }
106
161
 
107
162
  /**
108
163
  * Background video layer using HTML5 video element.
164
+ * Supports render prop for element composition.
109
165
  */
110
166
  const BackgroundVideo = React.forwardRef<
111
167
  HTMLVideoElement,
@@ -121,23 +177,44 @@ const BackgroundVideo = React.forwardRef<
121
177
  loop = true,
122
178
  muted = true,
123
179
  playsInline = true,
180
+ render,
124
181
  ...props
125
182
  },
126
183
  ref,
127
- ) => (
128
- <video
129
- ref={ref}
130
- autoPlay={autoPlay}
131
- loop={loop}
132
- muted={muted}
133
- playsInline={playsInline}
134
- poster={poster}
135
- className={backgroundVideoVariants({ class: className })}
136
- {...props}
137
- >
138
- <source src={src} type={type} />
139
- </video>
140
- ),
184
+ ) => {
185
+ const videoClassName = backgroundVideoVariants({ class: className });
186
+ const videoProps = {
187
+ autoPlay,
188
+ loop,
189
+ muted,
190
+ playsInline,
191
+ poster,
192
+ className: videoClassName,
193
+ ...props,
194
+ };
195
+
196
+ // Handle render prop (element or function)
197
+ if (render) {
198
+ if (typeof render === "function") {
199
+ return render({
200
+ ref,
201
+ ...videoProps,
202
+ } as React.VideoHTMLAttributes<HTMLVideoElement>);
203
+ }
204
+ // Clone the render element with merged props
205
+ return React.cloneElement(render, {
206
+ ...videoProps,
207
+ ...(render.props as Record<string, unknown>),
208
+ });
209
+ }
210
+
211
+ // Default: render as video with source
212
+ return (
213
+ <video ref={ref} {...videoProps}>
214
+ <source src={src} type={type} />
215
+ </video>
216
+ );
217
+ },
141
218
  );
142
219
  BackgroundVideo.displayName = "Background.Video";
143
220
 
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  Button as BaseButton,
3
5
  type ButtonProps as BaseButtonProps,
@@ -72,7 +74,7 @@ const buttonVariants = tv({
72
74
  variant: "outline",
73
75
  colorScheme: "dark",
74
76
  class:
75
- "border-border-strong text-gray-1000 hover:bg-alpha-black-5 active:bg-alpha-black-10 focus-visible:ring-gray-1000",
77
+ "border-border-subtle text-gray-1000 hover:bg-alpha-black-5 active:bg-alpha-black-10 focus-visible:ring-gray-1000",
76
78
  },
77
79
  // Outline + Light (for dark backgrounds)
78
80
  {
@@ -159,6 +161,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
159
161
  const themeStyles = buttonThemeToStyleVars(theme);
160
162
  const combinedStyles = hasTheme ? { ...themeStyles, ...style } : style;
161
163
 
164
+ // Resolve actual values for data attributes
165
+ const resolvedVariant = effectiveVariant ?? "solid";
166
+ const resolvedColorScheme = colorScheme ?? "dark";
167
+ const resolvedSize = size ?? "default";
168
+
162
169
  return (
163
170
  <BaseButton
164
171
  className={buttonVariants({
@@ -171,6 +178,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
171
178
  render={render}
172
179
  nativeButton={isNativeButton}
173
180
  style={combinedStyles}
181
+ data-variant={resolvedVariant}
182
+ data-color-scheme={resolvedColorScheme}
183
+ data-size={resolvedSize}
174
184
  {...props}
175
185
  />
176
186
  );
@@ -0,0 +1,254 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { page, userEvent } from "vitest/browser";
3
+ import { render } from "vitest-browser-react";
4
+ import { IconButton } from "./icon-button";
5
+
6
+ // Simple icon for testing
7
+ const TestIcon = () => (
8
+ // biome-ignore lint/a11y/noSvgWithoutTitle: Test component doesn't need accessibility title
9
+ <svg data-testid="test-icon" width="24" height="24" viewBox="0 0 24 24">
10
+ <path d="M12 2L2 7l10 5 10-5-10-5z" />
11
+ </svg>
12
+ );
13
+
14
+ describe("IconButton", () => {
15
+ describe("Accessibility", () => {
16
+ test("has correct button role", async () => {
17
+ render(
18
+ <IconButton aria-label="Test action">
19
+ <TestIcon />
20
+ </IconButton>,
21
+ );
22
+ await expect
23
+ .element(page.getByRole("button", { name: "Test action" }))
24
+ .toBeInTheDocument();
25
+ });
26
+
27
+ test("is focusable via keyboard", async () => {
28
+ render(
29
+ <IconButton aria-label="Focusable">
30
+ <TestIcon />
31
+ </IconButton>,
32
+ );
33
+ await userEvent.keyboard("{Tab}");
34
+ await expect
35
+ .element(page.getByRole("button", { name: "Focusable" }))
36
+ .toHaveFocus();
37
+ });
38
+
39
+ test("disabled button has disabled attribute", async () => {
40
+ render(
41
+ <IconButton disabled aria-label="Disabled">
42
+ <TestIcon />
43
+ </IconButton>,
44
+ );
45
+ await expect
46
+ .element(page.getByRole("button", { name: "Disabled" }))
47
+ .toBeDisabled();
48
+ });
49
+
50
+ test("disabled button is not focusable", async () => {
51
+ render(
52
+ <>
53
+ <IconButton disabled aria-label="Disabled">
54
+ <TestIcon />
55
+ </IconButton>
56
+ <IconButton aria-label="After">
57
+ <TestIcon />
58
+ </IconButton>
59
+ </>,
60
+ );
61
+ await userEvent.keyboard("{Tab}");
62
+ await expect
63
+ .element(page.getByRole("button", { name: "After" }))
64
+ .toHaveFocus();
65
+ });
66
+
67
+ test("renders icon inside button", async () => {
68
+ render(
69
+ <IconButton aria-label="With icon">
70
+ <TestIcon />
71
+ </IconButton>,
72
+ );
73
+ const icon = page.getByTestId("test-icon");
74
+ await expect.element(icon).toBeInTheDocument();
75
+ });
76
+ });
77
+
78
+ describe("Interactions", () => {
79
+ test("calls onClick when clicked", async () => {
80
+ const handleClick = vi.fn();
81
+ render(
82
+ <IconButton onClick={handleClick} aria-label="Clickable">
83
+ <TestIcon />
84
+ </IconButton>,
85
+ );
86
+ await page.getByRole("button", { name: "Clickable" }).click();
87
+ expect(handleClick).toHaveBeenCalledOnce();
88
+ });
89
+
90
+ test("responds to Enter key when focused", async () => {
91
+ const handleClick = vi.fn();
92
+ render(
93
+ <IconButton onClick={handleClick} aria-label="Enter key">
94
+ <TestIcon />
95
+ </IconButton>,
96
+ );
97
+ page.getByRole("button", { name: "Enter key" }).element().focus();
98
+ await userEvent.keyboard("{Enter}");
99
+ expect(handleClick).toHaveBeenCalledOnce();
100
+ });
101
+
102
+ test("responds to Space key when focused", async () => {
103
+ const handleClick = vi.fn();
104
+ render(
105
+ <IconButton onClick={handleClick} aria-label="Space key">
106
+ <TestIcon />
107
+ </IconButton>,
108
+ );
109
+ page.getByRole("button", { name: "Space key" }).element().focus();
110
+ await userEvent.keyboard(" ");
111
+ expect(handleClick).toHaveBeenCalledOnce();
112
+ });
113
+
114
+ test("does not fire onClick when disabled", async () => {
115
+ const handleClick = vi.fn();
116
+ render(
117
+ <IconButton disabled onClick={handleClick} aria-label="Disabled">
118
+ <TestIcon />
119
+ </IconButton>,
120
+ );
121
+ await page
122
+ .getByRole("button", { name: "Disabled" })
123
+ .click({ force: true });
124
+ expect(handleClick).not.toHaveBeenCalled();
125
+ });
126
+ });
127
+
128
+ describe("render prop", () => {
129
+ test("renders as anchor element when render prop is used", async () => {
130
+ render(
131
+ // biome-ignore lint/a11y/useAnchorContent: Content is provided via IconButton children
132
+ <IconButton render={<a href="/test" />} aria-label="Link button">
133
+ <TestIcon />
134
+ </IconButton>,
135
+ );
136
+ const element = page.getByRole("link", { name: "Link button" });
137
+ await expect.element(element).toBeInTheDocument();
138
+ await expect.element(element).toHaveAttribute("href", "/test");
139
+ });
140
+ });
141
+
142
+ describe("Data attributes", () => {
143
+ test("includes data-variant attribute", async () => {
144
+ render(
145
+ <IconButton variant="ghost" aria-label="Ghost variant">
146
+ <TestIcon />
147
+ </IconButton>,
148
+ );
149
+ await expect
150
+ .element(page.getByRole("button", { name: "Ghost variant" }))
151
+ .toHaveAttribute("data-variant", "ghost");
152
+ });
153
+
154
+ test("includes data-size attribute", async () => {
155
+ render(
156
+ <IconButton size="lg" aria-label="Large size">
157
+ <TestIcon />
158
+ </IconButton>,
159
+ );
160
+ await expect
161
+ .element(page.getByRole("button", { name: "Large size" }))
162
+ .toHaveAttribute("data-size", "lg");
163
+ });
164
+
165
+ test("includes data-color-scheme attribute", async () => {
166
+ render(
167
+ <IconButton colorScheme="light" aria-label="Light scheme">
168
+ <TestIcon />
169
+ </IconButton>,
170
+ );
171
+ await expect
172
+ .element(page.getByRole("button", { name: "Light scheme" }))
173
+ .toHaveAttribute("data-color-scheme", "light");
174
+ });
175
+
176
+ test("includes data-rounded attribute", async () => {
177
+ render(
178
+ <IconButton rounded="full" aria-label="Full rounded">
179
+ <TestIcon />
180
+ </IconButton>,
181
+ );
182
+ await expect
183
+ .element(page.getByRole("button", { name: "Full rounded" }))
184
+ .toHaveAttribute("data-rounded", "full");
185
+ });
186
+
187
+ test("has default data attributes when not specified", async () => {
188
+ render(
189
+ <IconButton aria-label="Defaults">
190
+ <TestIcon />
191
+ </IconButton>,
192
+ );
193
+ const button = page.getByRole("button", { name: "Defaults" });
194
+ await expect.element(button).toHaveAttribute("data-variant", "solid");
195
+ await expect.element(button).toHaveAttribute("data-size", "default");
196
+ await expect.element(button).toHaveAttribute("data-color-scheme", "dark");
197
+ await expect.element(button).toHaveAttribute("data-rounded", "default");
198
+ });
199
+ });
200
+
201
+ describe("Variants", () => {
202
+ test("applies solid variant classes by default", async () => {
203
+ render(
204
+ <IconButton aria-label="Default">
205
+ <TestIcon />
206
+ </IconButton>,
207
+ );
208
+ const button = page.getByRole("button", { name: "Default" });
209
+ await expect.element(button).toHaveClass(/bg-gray-1200/);
210
+ });
211
+
212
+ test("applies ghost variant classes", async () => {
213
+ render(
214
+ <IconButton variant="ghost" aria-label="Ghost">
215
+ <TestIcon />
216
+ </IconButton>,
217
+ );
218
+ const button = page.getByRole("button", { name: "Ghost" });
219
+ await expect.element(button).toHaveClass(/text-gray-700/);
220
+ });
221
+ });
222
+
223
+ describe("Sizes", () => {
224
+ test("applies small size classes", async () => {
225
+ render(
226
+ <IconButton size="sm" aria-label="Small">
227
+ <TestIcon />
228
+ </IconButton>,
229
+ );
230
+ const button = page.getByRole("button", { name: "Small" });
231
+ await expect.element(button).toHaveClass(/size-32/);
232
+ });
233
+
234
+ test("applies default size classes", async () => {
235
+ render(
236
+ <IconButton size="default" aria-label="Default">
237
+ <TestIcon />
238
+ </IconButton>,
239
+ );
240
+ const button = page.getByRole("button", { name: "Default" });
241
+ await expect.element(button).toHaveClass(/size-40/);
242
+ });
243
+
244
+ test("applies large size classes", async () => {
245
+ render(
246
+ <IconButton size="lg" aria-label="Large">
247
+ <TestIcon />
248
+ </IconButton>,
249
+ );
250
+ const button = page.getByRole("button", { name: "Large" });
251
+ await expect.element(button).toHaveClass(/size-48/);
252
+ });
253
+ });
254
+ });
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import { Slot } from "@radix-ui/react-slot";
2
4
  import * as React from "react";
3
5
  import { tv, type VariantProps } from "tailwind-variants";
@@ -30,9 +32,9 @@ import { tv, type VariantProps } from "tailwind-variants";
30
32
  * - light: Light colors for use on dark backgrounds
31
33
  *
32
34
  * Sizes:
33
- * - lg: Large (48x48)
34
- * - default: Medium (40x40)
35
35
  * - sm: Small (32x32)
36
+ * - default: Medium (40x40)
37
+ * - lg: Large (48x48)
36
38
  *
37
39
  * Rounded:
38
40
  * - default: Standard border radius
@@ -53,9 +55,9 @@ const iconButtonVariants = tv({
53
55
  light: "",
54
56
  },
55
57
  size: {
56
- lg: "size-48",
57
- default: "size-40",
58
58
  sm: "size-32",
59
+ default: "size-40",
60
+ lg: "size-48",
59
61
  },
60
62
  rounded: {
61
63
  default: "rounded-radius-12",
@@ -132,6 +134,38 @@ const iconButtonVariants = tv({
132
134
  export interface IconButtonProps
133
135
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
134
136
  VariantProps<typeof iconButtonVariants> {
137
+ /**
138
+ * Custom render prop for element composition.
139
+ * Accepts a React element or render function.
140
+ * @example
141
+ * ```tsx
142
+ * // Render as a link
143
+ * <IconButton render={<a href="/contact" />} aria-label="Contact">
144
+ * <LinkIcon />
145
+ * </IconButton>
146
+ *
147
+ * // Render with custom element
148
+ * <IconButton render={(props) => <Link {...props} to="/home" />} aria-label="Home">
149
+ * <HomeIcon />
150
+ * </IconButton>
151
+ * ```
152
+ */
153
+ render?:
154
+ | React.ReactElement
155
+ | ((
156
+ props: React.ButtonHTMLAttributes<HTMLButtonElement>,
157
+ ) => React.ReactElement);
158
+ /**
159
+ * @deprecated Use `render` prop instead for element composition.
160
+ * @example
161
+ * ```tsx
162
+ * // Old (deprecated)
163
+ * <IconButton asChild><a href="/link">...</a></IconButton>
164
+ *
165
+ * // New (recommended)
166
+ * <IconButton render={<a href="/link" />}>...</IconButton>
167
+ * ```
168
+ */
135
169
  asChild?: boolean;
136
170
  }
137
171
 
@@ -143,14 +177,16 @@ const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
143
177
  colorScheme,
144
178
  size,
145
179
  rounded,
146
- asChild = false,
180
+ render,
181
+ asChild,
147
182
  ...props
148
183
  },
149
184
  ref,
150
185
  ) => {
151
- // Development warning for missing accessible label
186
+ // Development warnings
152
187
  React.useEffect(() => {
153
188
  if (import.meta.env?.DEV) {
189
+ // Warn about missing accessible label
154
190
  const hasAccessibleLabel =
155
191
  props["aria-label"] || props["aria-labelledby"] || props.title;
156
192
  if (!hasAccessibleLabel) {
@@ -158,20 +194,76 @@ const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
158
194
  "IconButton: Missing accessible label. Icon-only buttons must have an aria-label, aria-labelledby, or title attribute for screen reader users.",
159
195
  );
160
196
  }
197
+ // Warn about deprecated asChild prop
198
+ if (asChild !== undefined) {
199
+ console.warn(
200
+ 'IconButton: The "asChild" prop is deprecated. Use the "render" prop instead for element composition.\n' +
201
+ 'Example: <IconButton render={<a href="/link" />}>...</IconButton>',
202
+ );
203
+ }
161
204
  }
162
- }, [props["aria-label"], props["aria-labelledby"], props.title]);
205
+ }, [props["aria-label"], props["aria-labelledby"], props.title, asChild]);
206
+
207
+ // Resolve actual values for data attributes
208
+ const resolvedVariant = variant ?? "solid";
209
+ const resolvedColorScheme = colorScheme ?? "dark";
210
+ const resolvedSize = size ?? "default";
211
+ const resolvedRounded = rounded ?? "default";
212
+
213
+ const buttonClassName = iconButtonVariants({
214
+ variant,
215
+ colorScheme,
216
+ size,
217
+ rounded,
218
+ class: className,
219
+ });
220
+
221
+ const dataAttributes = {
222
+ "data-variant": resolvedVariant,
223
+ "data-color-scheme": resolvedColorScheme,
224
+ "data-size": resolvedSize,
225
+ "data-rounded": resolvedRounded,
226
+ };
227
+
228
+ // Handle render prop (element or function)
229
+ if (render) {
230
+ if (typeof render === "function") {
231
+ return render({
232
+ ref,
233
+ className: buttonClassName,
234
+ ...dataAttributes,
235
+ ...props,
236
+ } as React.ButtonHTMLAttributes<HTMLButtonElement>);
237
+ }
238
+ // Clone the render element with merged props
239
+ return React.cloneElement(render, {
240
+ ref,
241
+ className: buttonClassName,
242
+ ...dataAttributes,
243
+ ...props,
244
+ ...(render.props as Record<string, unknown>),
245
+ });
246
+ }
247
+
248
+ // Handle deprecated asChild prop
249
+ if (asChild) {
250
+ return (
251
+ <Slot
252
+ ref={ref}
253
+ className={buttonClassName}
254
+ {...dataAttributes}
255
+ {...props}
256
+ />
257
+ );
258
+ }
163
259
 
164
- const Comp = asChild ? Slot : "button";
260
+ // Default: render as button
165
261
  return (
166
- <Comp
167
- className={iconButtonVariants({
168
- variant,
169
- colorScheme,
170
- size,
171
- rounded,
172
- class: className,
173
- })}
262
+ <button
174
263
  ref={ref}
264
+ type="button"
265
+ className={buttonClassName}
266
+ {...dataAttributes}
175
267
  {...props}
176
268
  />
177
269
  );
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import * as React from "react";
2
4
  import { tv, type VariantProps } from "tailwind-variants";
3
5
  import { cn } from "@/lib/utils";
@@ -118,12 +120,39 @@ const PagerControl = React.forwardRef<HTMLDivElement, PagerControlProps>(
118
120
  controlledIndex !== undefined ? controlledIndex : internalIndex;
119
121
  const isControlled = controlledIndex !== undefined;
120
122
 
123
+ // Development warnings for common issues
124
+ React.useEffect(() => {
125
+ if (import.meta.env?.DEV) {
126
+ if (count < 1) {
127
+ console.warn("PagerControl: count must be at least 1");
128
+ }
129
+ if (controlledIndex !== undefined && controlledIndex >= count) {
130
+ console.warn(
131
+ `PagerControl: activeIndex (${controlledIndex}) is out of bounds. Must be less than count (${count}).`,
132
+ );
133
+ }
134
+ if (controlledIndex !== undefined && controlledIndex < 0) {
135
+ console.warn(
136
+ `PagerControl: activeIndex (${controlledIndex}) cannot be negative.`,
137
+ );
138
+ }
139
+ if (isControlled && onChange === undefined) {
140
+ console.warn(
141
+ "PagerControl: controlled mode (activeIndex provided) requires an onChange handler.",
142
+ );
143
+ }
144
+ }
145
+ }, [count, controlledIndex, isControlled, onChange]);
146
+
147
+ // Clamp activeIndex to valid bounds
148
+ const safeActiveIndex = Math.max(0, Math.min(activeIndex, count - 1));
149
+
121
150
  const animationFrameRef = React.useRef<number | null>(null);
122
151
  const startTimeRef = React.useRef<number | null>(null);
123
152
  const pausedProgressRef = React.useRef<number>(0);
124
153
 
125
154
  const goToNext = React.useCallback(() => {
126
- const nextIndex = activeIndex + 1;
155
+ const nextIndex = safeActiveIndex + 1;
127
156
  if (nextIndex >= count) {
128
157
  if (loop) {
129
158
  if (!isControlled) setInternalIndex(0);
@@ -133,7 +162,7 @@ const PagerControl = React.forwardRef<HTMLDivElement, PagerControlProps>(
133
162
  if (!isControlled) setInternalIndex(nextIndex);
134
163
  onChange?.(nextIndex);
135
164
  }
136
- }, [activeIndex, count, loop, isControlled, onChange]);
165
+ }, [safeActiveIndex, count, loop, isControlled, onChange]);
137
166
 
138
167
  const goToIndex = React.useCallback(
139
168
  (index: number) => {
@@ -268,7 +297,7 @@ const PagerControl = React.forwardRef<HTMLDivElement, PagerControlProps>(
268
297
  {...props}
269
298
  >
270
299
  {Array.from({ length: count }, (_, index) => {
271
- const isActive = index === activeIndex;
300
+ const isActive = index === safeActiveIndex;
272
301
 
273
302
  if (isActive) {
274
303
  // Active dot with progress fill
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import { useCallback, useEffect, useRef, useState } from "react";
2
4
  import { GridOverlay } from "../grid-overlay";
3
5