@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.
- package/dist/component-registry.md +181 -29
- package/dist/components/atoms/accordion/accordion.d.ts +2 -2
- package/dist/components/atoms/background/background.d.ts +158 -0
- package/dist/components/atoms/button/button.d.ts +64 -82
- package/dist/components/atoms/button/icon-button.d.ts +128 -66
- package/dist/components/organisms/card/card.d.ts +130 -4
- package/dist/components/organisms/us-gov-banner/us-gov-banner.d.ts +120 -2
- package/dist/components/sections/hero/hero.d.ts +166 -150
- package/dist/components/sections/quote-block/quote-block.d.ts +152 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4068 -6052
- package/dist/index.js.map +1 -1
- package/dist/lib/utils.d.ts +1 -2
- package/dist/tokens.css +207 -16
- package/package.json +2 -4
- package/src/components/atoms/accordion/accordion.test.tsx +233 -0
- package/src/components/atoms/accordion/accordion.tsx +8 -8
- package/src/components/atoms/background/background.test.tsx +213 -0
- package/src/components/atoms/background/background.tsx +435 -0
- package/src/components/atoms/background/index.ts +22 -0
- package/src/components/atoms/button/button.stories.tsx +81 -32
- package/src/components/atoms/button/button.tsx +101 -49
- package/src/components/atoms/button/icon-button.stories.tsx +179 -28
- package/src/components/atoms/button/icon-button.test.tsx +254 -0
- package/src/components/atoms/button/icon-button.tsx +178 -59
- package/src/components/atoms/pager-control/pager-control.tsx +32 -3
- package/src/components/dev-tools/dev-toolbar/dev-toolbar.tsx +2 -0
- package/src/components/organisms/card/card.tsx +82 -24
- package/src/components/organisms/card/index.ts +7 -0
- package/src/components/organisms/navbar/navbar.tsx +2 -0
- package/src/components/organisms/us-gov-banner/index.ts +5 -1
- package/src/components/organisms/us-gov-banner/us-gov-banner.tsx +72 -16
- package/src/components/sections/hero/hero.stories.tsx +124 -1
- package/src/components/sections/hero/hero.test.tsx +21 -18
- package/src/components/sections/hero/hero.tsx +188 -301
- package/src/components/sections/hero/index.ts +13 -0
- package/src/components/sections/quote-block/index.ts +5 -0
- package/src/components/sections/quote-block/quote-block.tsx +216 -0
- package/src/index.ts +40 -0
- package/src/lib/utils.ts +1 -6
- 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
|
-
|
|
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
|
-
* -
|
|
30
|
-
* -
|
|
31
|
-
* -
|
|
32
|
-
* -
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* -
|
|
36
|
-
* -
|
|
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
|
-
* -
|
|
40
|
-
* - default: Medium (
|
|
41
|
-
* -
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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: "
|
|
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
|
-
(
|
|
95
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
}, [
|
|
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 ===
|
|
300
|
+
const isActive = index === safeActiveIndex;
|
|
272
301
|
|
|
273
302
|
if (isActive) {
|
|
274
303
|
// Active dot with progress fill
|