@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.
- package/dist/component-registry.md +46 -19
- package/dist/components/atoms/accordion/accordion.d.ts +9 -9
- package/dist/components/atoms/background/background.d.ts +23 -0
- package/dist/components/atoms/button/icon-button.d.ts +44 -16
- package/dist/components/sections/banner/banner.d.ts +9 -9
- package/dist/components/sections/faq-section/faq-section.d.ts +1 -1
- package/dist/components/sections/hero/hero.d.ts +115 -18
- package/dist/components/sections/tout/tout.d.ts +8 -8
- package/dist/components/sections/two-column-section/two-column-section.d.ts +7 -21
- package/dist/index.js +2177 -2027
- package/dist/index.js.map +1 -1
- package/dist/tokens.css +16 -16
- package/package.json +1 -1
- package/src/components/atoms/accordion/accordion.test.tsx +231 -0
- package/src/components/atoms/accordion/accordion.tsx +21 -19
- package/src/components/atoms/background/background.test.tsx +213 -0
- package/src/components/atoms/background/background.tsx +104 -27
- package/src/components/atoms/button/button.tsx +11 -1
- package/src/components/atoms/button/icon-button.test.tsx +254 -0
- package/src/components/atoms/button/icon-button.tsx +108 -16
- 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.test.tsx +4 -2
- package/src/components/organisms/navbar/navbar.tsx +2 -0
- package/src/components/sections/banner/banner.stories.tsx +5 -1
- package/src/components/sections/banner/banner.tsx +10 -10
- package/src/components/sections/faq-section/faq-section.stories.tsx +7 -7
- package/src/components/sections/faq-section/faq-section.tsx +3 -3
- package/src/components/sections/hero/hero.tsx +33 -51
- package/src/components/sections/tout/tout.stories.tsx +31 -7
- package/src/components/sections/tout/tout.tsx +6 -8
- package/src/components/sections/two-column-section/two-column-section.stories.tsx +11 -11
- package/src/components/sections/two-column-section/two-column-section.tsx +16 -10
- 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
|
-
>(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
autoPlay
|
|
131
|
-
loop
|
|
132
|
-
muted
|
|
133
|
-
playsInline
|
|
134
|
-
poster
|
|
135
|
-
className
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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-
|
|
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
|
-
|
|
180
|
+
render,
|
|
181
|
+
asChild,
|
|
147
182
|
...props
|
|
148
183
|
},
|
|
149
184
|
ref,
|
|
150
185
|
) => {
|
|
151
|
-
// Development
|
|
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
|
-
|
|
260
|
+
// Default: render as button
|
|
165
261
|
return (
|
|
166
|
-
<
|
|
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 =
|
|
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
|