@nationaldesignstudio/react 0.0.19 → 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.
@@ -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
 
@@ -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,23 +194,44 @@ 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 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
+ });
163
233
 
164
- const Comp = asChild ? Slot : "button";
165
- return (
166
- <Comp
167
- className={iconButtonVariants({
168
- variant,
169
- colorScheme,
170
- size,
171
- rounded,
172
- class: className,
173
- })}
174
- ref={ref}
175
- {...props}
176
- />
177
- );
234
+ return element;
178
235
  },
179
236
  );
180
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
 
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import { Dialog } from "@base-ui-components/react/dialog";
2
4
  import { Slot } from "@radix-ui/react-slot";
3
5
  import * as React from "react";
@@ -38,9 +38,15 @@ function ThemeDemo({ title }: { title?: string }) {
38
38
 
39
39
  {/* Button variants */}
40
40
  <div className="flex flex-wrap gap-3 mb-6">
41
- <Button variant="solid" colorScheme="dark">Solid Dark</Button>
42
- <Button variant="solid" colorScheme="light">Solid Light</Button>
43
- <Button variant="outline" colorScheme="dark">Outline</Button>
41
+ <Button variant="solid" colorScheme="dark">
42
+ Solid Dark
43
+ </Button>
44
+ <Button variant="solid" colorScheme="light">
45
+ Solid Light
46
+ </Button>
47
+ <Button variant="outline" colorScheme="dark">
48
+ Outline
49
+ </Button>
44
50
  </div>
45
51
 
46
52
  {/* Card component */}