@nationaldesignstudio/react 0.0.17 → 0.1.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 (41) hide show
  1. package/dist/component-registry.md +181 -29
  2. package/dist/components/atoms/accordion/accordion.d.ts +2 -2
  3. package/dist/components/atoms/background/background.d.ts +158 -0
  4. package/dist/components/atoms/button/button.d.ts +64 -82
  5. package/dist/components/atoms/button/icon-button.d.ts +128 -66
  6. package/dist/components/organisms/card/card.d.ts +130 -4
  7. package/dist/components/organisms/us-gov-banner/us-gov-banner.d.ts +120 -2
  8. package/dist/components/sections/hero/hero.d.ts +166 -150
  9. package/dist/components/sections/quote-block/quote-block.d.ts +152 -0
  10. package/dist/index.d.ts +6 -2
  11. package/dist/index.js +4068 -6052
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/utils.d.ts +1 -2
  14. package/dist/tokens.css +207 -16
  15. package/package.json +2 -4
  16. package/src/components/atoms/accordion/accordion.test.tsx +233 -0
  17. package/src/components/atoms/accordion/accordion.tsx +8 -8
  18. package/src/components/atoms/background/background.test.tsx +213 -0
  19. package/src/components/atoms/background/background.tsx +435 -0
  20. package/src/components/atoms/background/index.ts +22 -0
  21. package/src/components/atoms/button/button.stories.tsx +81 -32
  22. package/src/components/atoms/button/button.tsx +101 -49
  23. package/src/components/atoms/button/icon-button.stories.tsx +179 -28
  24. package/src/components/atoms/button/icon-button.test.tsx +254 -0
  25. package/src/components/atoms/button/icon-button.tsx +178 -59
  26. package/src/components/atoms/pager-control/pager-control.tsx +32 -3
  27. package/src/components/dev-tools/dev-toolbar/dev-toolbar.tsx +2 -0
  28. package/src/components/organisms/card/card.tsx +82 -24
  29. package/src/components/organisms/card/index.ts +7 -0
  30. package/src/components/organisms/navbar/navbar.tsx +2 -0
  31. package/src/components/organisms/us-gov-banner/index.ts +5 -1
  32. package/src/components/organisms/us-gov-banner/us-gov-banner.tsx +72 -16
  33. package/src/components/sections/hero/hero.stories.tsx +124 -1
  34. package/src/components/sections/hero/hero.test.tsx +21 -18
  35. package/src/components/sections/hero/hero.tsx +188 -301
  36. package/src/components/sections/hero/index.ts +13 -0
  37. package/src/components/sections/quote-block/index.ts +5 -0
  38. package/src/components/sections/quote-block/quote-block.tsx +216 -0
  39. package/src/index.ts +40 -0
  40. package/src/lib/utils.ts +1 -6
  41. package/src/stories/ThemeProvider.stories.tsx +11 -5
@@ -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,4 +1,6 @@
1
- import { Slot } from "@radix-ui/react-slot";
1
+ "use client";
2
+
3
+ import { useRender } from "@base-ui-components/react/use-render";
2
4
  import * as React from "react";
3
5
  import { tv, type VariantProps } from "tailwind-variants";
4
6
 
@@ -17,84 +19,174 @@ import { tv, type VariantProps } from "tailwind-variants";
17
19
  * <IconButton aria-label="Close menu">
18
20
  * <CloseIcon />
19
21
  * </IconButton>
20
- *
21
- * // Correct usage with aria-labelledby
22
- * <IconButton aria-labelledby="close-label">
23
- * <CloseIcon />
24
- * </IconButton>
25
- * <span id="close-label" className="sr-only">Close menu</span>
26
22
  * ```
27
23
  *
28
24
  * Variants:
29
- * - charcoal: Dark filled button (for light backgrounds)
30
- * - charcoalOutline: Dark outlined button (for light backgrounds)
31
- * - charcoalOutlineQuiet: Subtle dark outlined button (for light backgrounds)
32
- * - ghost: No background/border, just icon (for light backgrounds)
33
- * - ghostDark: No background/border, just icon (for dark backgrounds)
34
- * - ivory: Light filled button (for dark backgrounds)
35
- * - ivoryOutline: Light outlined button (for dark backgrounds)
36
- * - ivoryOutlineQuiet: Subtle light outlined button (for dark backgrounds)
25
+ * - solid: Filled button
26
+ * - outline: Outlined button
27
+ * - ghost: No background/border, just icon
28
+ * - subtle: Subtle outlined button
29
+ *
30
+ * Color Schemes:
31
+ * - dark: Dark colors for use on light backgrounds (default)
32
+ * - light: Light colors for use on dark backgrounds
37
33
  *
38
34
  * Sizes:
39
- * - lg: Large (46x46)
40
- * - default: Medium (36x36)
41
- * - sm: Small (29x29)
35
+ * - sm: Small (32x32)
36
+ * - default: Medium (40x40)
37
+ * - lg: Large (48x48)
38
+ *
39
+ * Rounded:
40
+ * - default: Standard border radius
41
+ * - sm: Smaller border radius
42
+ * - full: Fully circular
42
43
  */
43
44
  const iconButtonVariants = tv({
44
45
  base: "inline-flex items-center justify-center whitespace-nowrap transition-colors duration-150 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
45
46
  variants: {
46
47
  variant: {
47
- // Charcoal (dark filled) - primary dark
48
- charcoal:
48
+ solid: "",
49
+ outline: "border",
50
+ ghost: "",
51
+ subtle: "border",
52
+ },
53
+ colorScheme: {
54
+ dark: "",
55
+ light: "",
56
+ },
57
+ size: {
58
+ sm: "size-32",
59
+ default: "size-40",
60
+ lg: "size-48",
61
+ },
62
+ rounded: {
63
+ default: "rounded-radius-12",
64
+ sm: "rounded-radius-10",
65
+ full: "rounded-full",
66
+ },
67
+ },
68
+ compoundVariants: [
69
+ // Solid + Dark (for light backgrounds)
70
+ {
71
+ variant: "solid",
72
+ colorScheme: "dark",
73
+ class:
49
74
  "bg-gray-1200 text-gray-100 hover:bg-gray-1100 active:bg-gray-1000 focus-visible:ring-gray-1000",
50
- // Charcoal Outline - outlined dark (for light backgrounds)
51
- charcoalOutline:
52
- "border border-alpha-black-30 text-gray-1000 hover:bg-alpha-black-5 active:bg-alpha-black-10 focus-visible:ring-gray-1000",
53
- // Charcoal Outline Quiet - subtle outlined dark (for light backgrounds)
54
- charcoalOutlineQuiet:
55
- "border border-alpha-black-20 text-alpha-black-60 hover:border-alpha-black-30 hover:text-alpha-black-80 active:bg-alpha-black-5 focus-visible:ring-gray-1000",
56
- // Ghost - no background/border (for light backgrounds)
57
- ghost:
75
+ },
76
+ // Solid + Light (for dark backgrounds)
77
+ {
78
+ variant: "solid",
79
+ colorScheme: "light",
80
+ class:
81
+ "bg-gray-50 text-gray-1000 hover:bg-gray-100 active:bg-gray-200 focus-visible:ring-gray-50 focus-visible:ring-offset-gray-1000",
82
+ },
83
+ // Outline + Dark (for light backgrounds)
84
+ {
85
+ variant: "outline",
86
+ colorScheme: "dark",
87
+ class:
88
+ "border-alpha-black-30 text-gray-1000 hover:bg-alpha-black-5 active:bg-alpha-black-10 focus-visible:ring-gray-1000",
89
+ },
90
+ // Outline + Light (for dark backgrounds)
91
+ {
92
+ variant: "outline",
93
+ colorScheme: "light",
94
+ class:
95
+ "border-gray-50 text-gray-50 hover:bg-alpha-white-10 active:bg-alpha-white-20 focus-visible:ring-gray-50 focus-visible:ring-offset-gray-1000",
96
+ },
97
+ // Ghost + Dark (for light backgrounds)
98
+ {
99
+ variant: "ghost",
100
+ colorScheme: "dark",
101
+ class:
58
102
  "text-gray-700 hover:text-gray-900 hover:bg-alpha-black-5 active:bg-alpha-black-10 focus-visible:ring-gray-1000",
59
- // Ghost Dark - no background/border (for dark backgrounds)
60
- ghostDark:
103
+ },
104
+ // Ghost + Light (for dark backgrounds)
105
+ {
106
+ variant: "ghost",
107
+ colorScheme: "light",
108
+ class:
61
109
  "text-gray-300 hover:text-gray-100 hover:bg-alpha-white-10 active:bg-alpha-white-20 focus-visible:ring-gray-50 focus-visible:ring-offset-gray-1000",
62
- // Ivory (light filled) - primary light (for dark backgrounds)
63
- ivory:
64
- "bg-gray-50 text-gray-1000 hover:bg-gray-100 active:bg-gray-200 focus-visible:ring-gray-50 focus-visible:ring-offset-gray-1000",
65
- // Ivory Outline - outlined light (for dark backgrounds)
66
- ivoryOutline:
67
- "border border-gray-50 text-gray-50 hover:bg-alpha-white-10 active:bg-alpha-white-20 focus-visible:ring-gray-50 focus-visible:ring-offset-gray-1000",
68
- // Ivory Outline Quiet - subtle light outline (for dark backgrounds)
69
- ivoryOutlineQuiet:
70
- "border border-alpha-white-20 text-alpha-white-60 hover:border-alpha-white-30 hover:text-alpha-white-80 active:bg-alpha-white-5 focus-visible:ring-gray-50 focus-visible:ring-offset-gray-1000",
71
110
  },
72
- size: {
73
- // Large (48x48) - uses primitive spacing tokens
74
- lg: "rounded-radius-12 size-spacing-48",
75
- // Medium (40x40) - default - uses primitive spacing tokens
76
- default: "rounded-radius-12 size-spacing-40",
77
- // Small (32x32) - uses primitive spacing tokens
78
- sm: "rounded-radius-10 size-spacing-32",
111
+ // Subtle + Dark (for light backgrounds)
112
+ {
113
+ variant: "subtle",
114
+ colorScheme: "dark",
115
+ class:
116
+ "border-alpha-black-20 text-alpha-black-60 hover:border-alpha-black-30 hover:text-alpha-black-80 active:bg-alpha-black-5 focus-visible:ring-gray-1000",
79
117
  },
80
- },
118
+ // Subtle + Light (for dark backgrounds)
119
+ {
120
+ variant: "subtle",
121
+ colorScheme: "light",
122
+ class:
123
+ "border-alpha-white-20 text-alpha-white-60 hover:border-alpha-white-30 hover:text-alpha-white-80 active:bg-alpha-white-5 focus-visible:ring-gray-50 focus-visible:ring-offset-gray-1000",
124
+ },
125
+ ],
81
126
  defaultVariants: {
82
- variant: "charcoal",
127
+ variant: "solid",
128
+ colorScheme: "dark",
83
129
  size: "default",
130
+ rounded: "default",
84
131
  },
85
132
  });
86
133
 
87
134
  export interface IconButtonProps
88
135
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
89
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
+ */
90
169
  asChild?: boolean;
91
170
  }
92
171
 
93
172
  const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
94
- ({ className, variant, size, asChild = false, ...props }, ref) => {
95
- // Development warning for missing accessible label
173
+ (
174
+ {
175
+ className,
176
+ variant,
177
+ colorScheme,
178
+ size,
179
+ rounded,
180
+ render,
181
+ asChild,
182
+ ...props
183
+ },
184
+ ref,
185
+ ) => {
186
+ // Development warnings
96
187
  React.useEffect(() => {
97
188
  if (import.meta.env?.DEV) {
189
+ // Warn about missing accessible label
98
190
  const hasAccessibleLabel =
99
191
  props["aria-label"] || props["aria-labelledby"] || props.title;
100
192
  if (!hasAccessibleLabel) {
@@ -102,17 +194,44 @@ const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
102
194
  "IconButton: Missing accessible label. Icon-only buttons must have an aria-label, aria-labelledby, or title attribute for screen reader users.",
103
195
  );
104
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
+ }
105
204
  }
106
- }, [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 mergedProps = {
214
+ className: iconButtonVariants({
215
+ variant,
216
+ colorScheme,
217
+ size,
218
+ rounded,
219
+ class: className,
220
+ }),
221
+ "data-variant": resolvedVariant,
222
+ "data-color-scheme": resolvedColorScheme,
223
+ "data-size": resolvedSize,
224
+ "data-rounded": resolvedRounded,
225
+ ...props,
226
+ };
227
+
228
+ const element = useRender({
229
+ render: render ?? <button type="button" />,
230
+ ref,
231
+ props: mergedProps,
232
+ });
107
233
 
108
- const Comp = asChild ? Slot : "button";
109
- return (
110
- <Comp
111
- className={iconButtonVariants({ variant, size, class: className })}
112
- ref={ref}
113
- {...props}
114
- />
115
- );
234
+ return element;
116
235
  },
117
236
  );
118
237
  IconButton.displayName = "IconButton";
@@ -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