@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.
- package/dist/components/atoms/accordion/accordion.d.ts +2 -2
- package/dist/components/atoms/background/background.d.ts +23 -0
- package/dist/components/atoms/button/icon-button.d.ts +44 -16
- package/dist/index.js +2140 -2014
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- 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 +85 -27
- package/src/components/atoms/button/button.tsx +10 -0
- package/src/components/atoms/button/icon-button.test.tsx +254 -0
- package/src/components/atoms/button/icon-button.tsx +79 -22
- 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/navbar/navbar.tsx +2 -0
- package/src/stories/ThemeProvider.stories.tsx +9 -3
package/package.json
CHANGED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { page, userEvent } from "vitest/browser";
|
|
3
|
+
import { render } from "vitest-browser-react";
|
|
4
|
+
import { Accordion, AccordionItem } from "./accordion";
|
|
5
|
+
|
|
6
|
+
describe("Accordion", () => {
|
|
7
|
+
describe("Rendering", () => {
|
|
8
|
+
test("renders accordion with items", async () => {
|
|
9
|
+
render(
|
|
10
|
+
<Accordion>
|
|
11
|
+
<AccordionItem id="item-1" title="Question 1">
|
|
12
|
+
Answer 1
|
|
13
|
+
</AccordionItem>
|
|
14
|
+
<AccordionItem id="item-2" title="Question 2">
|
|
15
|
+
Answer 2
|
|
16
|
+
</AccordionItem>
|
|
17
|
+
</Accordion>,
|
|
18
|
+
);
|
|
19
|
+
await expect
|
|
20
|
+
.element(page.getByRole("button", { name: "Question 1" }))
|
|
21
|
+
.toBeInTheDocument();
|
|
22
|
+
await expect
|
|
23
|
+
.element(page.getByRole("button", { name: "Question 2" }))
|
|
24
|
+
.toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("renders with default expanded item", async () => {
|
|
28
|
+
render(
|
|
29
|
+
<Accordion defaultExpanded="item-1">
|
|
30
|
+
<AccordionItem id="item-1" title="Question 1">
|
|
31
|
+
Answer 1
|
|
32
|
+
</AccordionItem>
|
|
33
|
+
<AccordionItem id="item-2" title="Question 2">
|
|
34
|
+
Answer 2
|
|
35
|
+
</AccordionItem>
|
|
36
|
+
</Accordion>,
|
|
37
|
+
);
|
|
38
|
+
// The first item should be expanded (has data-open attribute)
|
|
39
|
+
await expect
|
|
40
|
+
.element(page.getByRole("button", { name: "Question 1" }))
|
|
41
|
+
.toHaveAttribute("data-open");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("renders with multiple default expanded items", async () => {
|
|
45
|
+
render(
|
|
46
|
+
<Accordion allowMultiple defaultExpanded={["item-1", "item-2"]}>
|
|
47
|
+
<AccordionItem id="item-1" title="Question 1">
|
|
48
|
+
Answer 1
|
|
49
|
+
</AccordionItem>
|
|
50
|
+
<AccordionItem id="item-2" title="Question 2">
|
|
51
|
+
Answer 2
|
|
52
|
+
</AccordionItem>
|
|
53
|
+
</Accordion>,
|
|
54
|
+
);
|
|
55
|
+
await expect
|
|
56
|
+
.element(page.getByRole("button", { name: "Question 1" }))
|
|
57
|
+
.toHaveAttribute("data-open");
|
|
58
|
+
await expect
|
|
59
|
+
.element(page.getByRole("button", { name: "Question 2" }))
|
|
60
|
+
.toHaveAttribute("data-open");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("Keyboard Navigation", () => {
|
|
65
|
+
test("items are focusable via Tab", async () => {
|
|
66
|
+
render(
|
|
67
|
+
<Accordion>
|
|
68
|
+
<AccordionItem id="item-1" title="Question 1">
|
|
69
|
+
Answer 1
|
|
70
|
+
</AccordionItem>
|
|
71
|
+
<AccordionItem id="item-2" title="Question 2">
|
|
72
|
+
Answer 2
|
|
73
|
+
</AccordionItem>
|
|
74
|
+
</Accordion>,
|
|
75
|
+
);
|
|
76
|
+
await userEvent.keyboard("{Tab}");
|
|
77
|
+
await expect
|
|
78
|
+
.element(page.getByRole("button", { name: "Question 1" }))
|
|
79
|
+
.toHaveFocus();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("Enter key expands/collapses item", async () => {
|
|
83
|
+
render(
|
|
84
|
+
<Accordion>
|
|
85
|
+
<AccordionItem id="item-1" title="Question 1">
|
|
86
|
+
Answer 1
|
|
87
|
+
</AccordionItem>
|
|
88
|
+
</Accordion>,
|
|
89
|
+
);
|
|
90
|
+
const trigger = page.getByRole("button", { name: "Question 1" });
|
|
91
|
+
trigger.element().focus();
|
|
92
|
+
await userEvent.keyboard("{Enter}");
|
|
93
|
+
await expect.element(trigger).toHaveAttribute("data-open");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("Space key expands/collapses item", async () => {
|
|
97
|
+
render(
|
|
98
|
+
<Accordion>
|
|
99
|
+
<AccordionItem id="item-1" title="Question 1">
|
|
100
|
+
Answer 1
|
|
101
|
+
</AccordionItem>
|
|
102
|
+
</Accordion>,
|
|
103
|
+
);
|
|
104
|
+
const trigger = page.getByRole("button", { name: "Question 1" });
|
|
105
|
+
trigger.element().focus();
|
|
106
|
+
await userEvent.keyboard(" ");
|
|
107
|
+
await expect.element(trigger).toHaveAttribute("data-open");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("Click Interactions", () => {
|
|
112
|
+
test("clicking trigger expands item", async () => {
|
|
113
|
+
render(
|
|
114
|
+
<Accordion>
|
|
115
|
+
<AccordionItem id="item-1" title="Question 1">
|
|
116
|
+
Answer 1
|
|
117
|
+
</AccordionItem>
|
|
118
|
+
</Accordion>,
|
|
119
|
+
);
|
|
120
|
+
const trigger = page.getByRole("button", { name: "Question 1" });
|
|
121
|
+
await trigger.click();
|
|
122
|
+
await expect.element(trigger).toHaveAttribute("data-open");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("clicking expanded trigger collapses item", async () => {
|
|
126
|
+
render(
|
|
127
|
+
<Accordion defaultExpanded="item-1">
|
|
128
|
+
<AccordionItem id="item-1" title="Question 1">
|
|
129
|
+
Answer 1
|
|
130
|
+
</AccordionItem>
|
|
131
|
+
</Accordion>,
|
|
132
|
+
);
|
|
133
|
+
const trigger = page.getByRole("button", { name: "Question 1" });
|
|
134
|
+
await expect.element(trigger).toHaveAttribute("data-open");
|
|
135
|
+
await trigger.click();
|
|
136
|
+
await expect.element(trigger).not.toHaveAttribute("data-open");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("single mode collapses other items when opening new one", async () => {
|
|
140
|
+
render(
|
|
141
|
+
<Accordion defaultExpanded="item-1">
|
|
142
|
+
<AccordionItem id="item-1" title="Question 1">
|
|
143
|
+
Answer 1
|
|
144
|
+
</AccordionItem>
|
|
145
|
+
<AccordionItem id="item-2" title="Question 2">
|
|
146
|
+
Answer 2
|
|
147
|
+
</AccordionItem>
|
|
148
|
+
</Accordion>,
|
|
149
|
+
);
|
|
150
|
+
const trigger1 = page.getByRole("button", { name: "Question 1" });
|
|
151
|
+
const trigger2 = page.getByRole("button", { name: "Question 2" });
|
|
152
|
+
|
|
153
|
+
await expect.element(trigger1).toHaveAttribute("data-open");
|
|
154
|
+
await trigger2.click();
|
|
155
|
+
await expect.element(trigger2).toHaveAttribute("data-open");
|
|
156
|
+
await expect.element(trigger1).not.toHaveAttribute("data-open");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("multiple mode allows multiple items open", async () => {
|
|
160
|
+
render(
|
|
161
|
+
<Accordion allowMultiple defaultExpanded="item-1">
|
|
162
|
+
<AccordionItem id="item-1" title="Question 1">
|
|
163
|
+
Answer 1
|
|
164
|
+
</AccordionItem>
|
|
165
|
+
<AccordionItem id="item-2" title="Question 2">
|
|
166
|
+
Answer 2
|
|
167
|
+
</AccordionItem>
|
|
168
|
+
</Accordion>,
|
|
169
|
+
);
|
|
170
|
+
const trigger1 = page.getByRole("button", { name: "Question 1" });
|
|
171
|
+
const trigger2 = page.getByRole("button", { name: "Question 2" });
|
|
172
|
+
|
|
173
|
+
await expect.element(trigger1).toHaveAttribute("data-open");
|
|
174
|
+
await trigger2.click();
|
|
175
|
+
await expect.element(trigger1).toHaveAttribute("data-open");
|
|
176
|
+
await expect.element(trigger2).toHaveAttribute("data-open");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("Variants", () => {
|
|
181
|
+
test("applies light variant by default", async () => {
|
|
182
|
+
render(
|
|
183
|
+
<Accordion>
|
|
184
|
+
<AccordionItem id="item-1" title="Question 1">
|
|
185
|
+
Answer 1
|
|
186
|
+
</AccordionItem>
|
|
187
|
+
</Accordion>,
|
|
188
|
+
);
|
|
189
|
+
const trigger = page.getByRole("button", { name: "Question 1" });
|
|
190
|
+
// Light variant has gray-800 text
|
|
191
|
+
await expect.element(trigger).toHaveClass(/text-gray-800/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("applies dark variant classes", async () => {
|
|
195
|
+
render(
|
|
196
|
+
<Accordion variant="dark">
|
|
197
|
+
<AccordionItem id="item-1" title="Question 1">
|
|
198
|
+
Answer 1
|
|
199
|
+
</AccordionItem>
|
|
200
|
+
</Accordion>,
|
|
201
|
+
);
|
|
202
|
+
const trigger = page.getByRole("button", { name: "Question 1" });
|
|
203
|
+
// Dark variant has gray-100 text
|
|
204
|
+
await expect.element(trigger).toHaveClass(/text-gray-100/);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("Content", () => {
|
|
209
|
+
test("displays title text in trigger", async () => {
|
|
210
|
+
render(
|
|
211
|
+
<Accordion>
|
|
212
|
+
<AccordionItem id="item-1" title="Custom Title">
|
|
213
|
+
Content here
|
|
214
|
+
</AccordionItem>
|
|
215
|
+
</Accordion>,
|
|
216
|
+
);
|
|
217
|
+
await expect.element(page.getByText("Custom Title")).toBeInTheDocument();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("displays content when expanded", async () => {
|
|
221
|
+
render(
|
|
222
|
+
<Accordion defaultExpanded="item-1">
|
|
223
|
+
<AccordionItem id="item-1" title="Question 1">
|
|
224
|
+
Answer content here
|
|
225
|
+
</AccordionItem>
|
|
226
|
+
</Accordion>,
|
|
227
|
+
);
|
|
228
|
+
await expect
|
|
229
|
+
.element(page.getByText("Answer content here"))
|
|
230
|
+
.toBeInTheDocument();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -18,7 +18,7 @@ const accordionVariants = tv({
|
|
|
18
18
|
},
|
|
19
19
|
},
|
|
20
20
|
defaultVariants: {
|
|
21
|
-
variant: "
|
|
21
|
+
variant: "light",
|
|
22
22
|
},
|
|
23
23
|
});
|
|
24
24
|
|
|
@@ -31,7 +31,7 @@ const accordionItemVariants = tv({
|
|
|
31
31
|
},
|
|
32
32
|
},
|
|
33
33
|
defaultVariants: {
|
|
34
|
-
variant: "
|
|
34
|
+
variant: "light",
|
|
35
35
|
},
|
|
36
36
|
});
|
|
37
37
|
|
|
@@ -48,7 +48,7 @@ const accordionTriggerVariants = tv({
|
|
|
48
48
|
},
|
|
49
49
|
},
|
|
50
50
|
defaultVariants: {
|
|
51
|
-
variant: "
|
|
51
|
+
variant: "light",
|
|
52
52
|
},
|
|
53
53
|
});
|
|
54
54
|
|
|
@@ -62,7 +62,7 @@ const accordionPanelVariants = tv({
|
|
|
62
62
|
},
|
|
63
63
|
},
|
|
64
64
|
defaultVariants: {
|
|
65
|
-
variant: "
|
|
65
|
+
variant: "light",
|
|
66
66
|
},
|
|
67
67
|
});
|
|
68
68
|
|
|
@@ -73,7 +73,7 @@ const accordionPanelVariants = tv({
|
|
|
73
73
|
const AccordionContext = React.createContext<{
|
|
74
74
|
variant: "dark" | "light";
|
|
75
75
|
}>({
|
|
76
|
-
variant: "
|
|
76
|
+
variant: "light",
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
// =============================================================================
|
|
@@ -100,8 +100,8 @@ export interface AccordionProps
|
|
|
100
100
|
* Built on Base UI's Accordion primitive.
|
|
101
101
|
*
|
|
102
102
|
* Variants:
|
|
103
|
-
* -
|
|
104
|
-
* -
|
|
103
|
+
* - light: Light theme styling (default)
|
|
104
|
+
* - dark: Dark theme styling
|
|
105
105
|
*
|
|
106
106
|
* @example
|
|
107
107
|
* ```tsx
|
|
@@ -134,7 +134,7 @@ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
|
|
|
134
134
|
}, [defaultExpanded]);
|
|
135
135
|
|
|
136
136
|
return (
|
|
137
|
-
<AccordionContext.Provider value={{ variant
|
|
137
|
+
<AccordionContext.Provider value={{ variant }}>
|
|
138
138
|
<BaseAccordion.Root
|
|
139
139
|
ref={ref}
|
|
140
140
|
className={accordionVariants({ variant, class: className })}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { page } from "vitest/browser";
|
|
3
|
+
import { render } from "vitest-browser-react";
|
|
4
|
+
import {
|
|
5
|
+
Background,
|
|
6
|
+
BackgroundGradient,
|
|
7
|
+
BackgroundImage,
|
|
8
|
+
BackgroundOverlay,
|
|
9
|
+
BackgroundVideo,
|
|
10
|
+
} from "./background";
|
|
11
|
+
|
|
12
|
+
describe("Background", () => {
|
|
13
|
+
describe("Background Container", () => {
|
|
14
|
+
test("renders as a div with absolute positioning", async () => {
|
|
15
|
+
render(<Background data-testid="bg-container">Content</Background>);
|
|
16
|
+
const container = page.getByTestId("bg-container");
|
|
17
|
+
await expect.element(container).toBeInTheDocument();
|
|
18
|
+
await expect.element(container).toHaveClass(/absolute/);
|
|
19
|
+
await expect.element(container).toHaveClass(/inset-0/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("has aria-hidden attribute", async () => {
|
|
23
|
+
render(<Background data-testid="bg-container" />);
|
|
24
|
+
await expect
|
|
25
|
+
.element(page.getByTestId("bg-container"))
|
|
26
|
+
.toHaveAttribute("aria-hidden", "true");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("accepts custom className", async () => {
|
|
30
|
+
render(<Background data-testid="bg" className="custom-class" />);
|
|
31
|
+
await expect.element(page.getByTestId("bg")).toHaveClass(/custom-class/);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("BackgroundImage", () => {
|
|
36
|
+
test("renders an img element with src", async () => {
|
|
37
|
+
render(<BackgroundImage src="/test-image.jpg" data-testid="bg-img" />);
|
|
38
|
+
const img = page.getByTestId("bg-img");
|
|
39
|
+
await expect.element(img).toBeInTheDocument();
|
|
40
|
+
await expect.element(img).toHaveAttribute("src", "/test-image.jpg");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("has object-cover class for proper fitting", async () => {
|
|
44
|
+
render(<BackgroundImage src="/test.jpg" data-testid="bg-img" />);
|
|
45
|
+
await expect
|
|
46
|
+
.element(page.getByTestId("bg-img"))
|
|
47
|
+
.toHaveClass(/object-cover/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("sets default alt to empty string for decorative images", async () => {
|
|
51
|
+
render(<BackgroundImage src="/test.jpg" data-testid="bg-img" />);
|
|
52
|
+
await expect
|
|
53
|
+
.element(page.getByTestId("bg-img"))
|
|
54
|
+
.toHaveAttribute("alt", "");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("accepts custom alt text", async () => {
|
|
58
|
+
render(
|
|
59
|
+
<BackgroundImage
|
|
60
|
+
src="/test.jpg"
|
|
61
|
+
alt="Custom alt text"
|
|
62
|
+
data-testid="bg-img"
|
|
63
|
+
/>,
|
|
64
|
+
);
|
|
65
|
+
await expect
|
|
66
|
+
.element(page.getByTestId("bg-img"))
|
|
67
|
+
.toHaveAttribute("alt", "Custom alt text");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("applies custom position via style", async () => {
|
|
71
|
+
render(
|
|
72
|
+
<BackgroundImage
|
|
73
|
+
src="/test.jpg"
|
|
74
|
+
position="top left"
|
|
75
|
+
data-testid="bg-img"
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
const img = page.getByTestId("bg-img");
|
|
79
|
+
// Check the style attribute contains the position
|
|
80
|
+
await expect.element(img).toHaveStyle({ objectPosition: "top left" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("supports render prop for custom element", async () => {
|
|
84
|
+
render(
|
|
85
|
+
<BackgroundImage
|
|
86
|
+
src="/test.jpg"
|
|
87
|
+
// biome-ignore lint/a11y/useAltText: Test case for custom element
|
|
88
|
+
render={<img className="custom-img-class" />}
|
|
89
|
+
data-testid="bg-img"
|
|
90
|
+
/>,
|
|
91
|
+
);
|
|
92
|
+
await expect
|
|
93
|
+
.element(page.getByTestId("bg-img"))
|
|
94
|
+
.toHaveClass(/custom-img-class/);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("BackgroundVideo", () => {
|
|
99
|
+
test("renders a video element", async () => {
|
|
100
|
+
render(<BackgroundVideo src="/test-video.mp4" data-testid="bg-video" />);
|
|
101
|
+
const video = page.getByTestId("bg-video");
|
|
102
|
+
await expect.element(video).toBeInTheDocument();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("has autoplay, loop, and muted by default", async () => {
|
|
106
|
+
render(<BackgroundVideo src="/test.mp4" data-testid="bg-video" />);
|
|
107
|
+
const video = page.getByTestId("bg-video");
|
|
108
|
+
await expect.element(video).toHaveAttribute("autoplay");
|
|
109
|
+
await expect.element(video).toHaveAttribute("loop");
|
|
110
|
+
await expect.element(video).toHaveAttribute("muted");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("has object-cover class", async () => {
|
|
114
|
+
render(<BackgroundVideo src="/test.mp4" data-testid="bg-video" />);
|
|
115
|
+
await expect
|
|
116
|
+
.element(page.getByTestId("bg-video"))
|
|
117
|
+
.toHaveClass(/object-cover/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("accepts poster prop", async () => {
|
|
121
|
+
render(
|
|
122
|
+
<BackgroundVideo
|
|
123
|
+
src="/test.mp4"
|
|
124
|
+
poster="/poster.jpg"
|
|
125
|
+
data-testid="bg-video"
|
|
126
|
+
/>,
|
|
127
|
+
);
|
|
128
|
+
await expect
|
|
129
|
+
.element(page.getByTestId("bg-video"))
|
|
130
|
+
.toHaveAttribute("poster", "/poster.jpg");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("BackgroundOverlay", () => {
|
|
135
|
+
test("renders with default opacity", async () => {
|
|
136
|
+
render(<BackgroundOverlay data-testid="bg-overlay" />);
|
|
137
|
+
const overlay = page.getByTestId("bg-overlay");
|
|
138
|
+
await expect.element(overlay).toBeInTheDocument();
|
|
139
|
+
await expect.element(overlay).toHaveStyle({ opacity: "0.4" });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("accepts custom opacity", async () => {
|
|
143
|
+
render(<BackgroundOverlay opacity={0.7} data-testid="bg-overlay" />);
|
|
144
|
+
await expect
|
|
145
|
+
.element(page.getByTestId("bg-overlay"))
|
|
146
|
+
.toHaveStyle({ opacity: "0.7" });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("has aria-hidden attribute", async () => {
|
|
150
|
+
render(<BackgroundOverlay data-testid="bg-overlay" />);
|
|
151
|
+
await expect
|
|
152
|
+
.element(page.getByTestId("bg-overlay"))
|
|
153
|
+
.toHaveAttribute("aria-hidden", "true");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("BackgroundGradient", () => {
|
|
158
|
+
test("renders with default gradient direction", async () => {
|
|
159
|
+
render(<BackgroundGradient data-testid="bg-gradient" />);
|
|
160
|
+
const gradient = page.getByTestId("bg-gradient");
|
|
161
|
+
await expect.element(gradient).toBeInTheDocument();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("has aria-hidden attribute", async () => {
|
|
165
|
+
render(<BackgroundGradient data-testid="bg-gradient" />);
|
|
166
|
+
await expect
|
|
167
|
+
.element(page.getByTestId("bg-gradient"))
|
|
168
|
+
.toHaveAttribute("aria-hidden", "true");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("accepts custom gradient via gradient prop", async () => {
|
|
172
|
+
render(
|
|
173
|
+
<BackgroundGradient
|
|
174
|
+
gradient="linear-gradient(45deg, red, blue)"
|
|
175
|
+
data-testid="bg-gradient"
|
|
176
|
+
/>,
|
|
177
|
+
);
|
|
178
|
+
await expect
|
|
179
|
+
.element(page.getByTestId("bg-gradient"))
|
|
180
|
+
.toHaveStyle({ backgroundImage: "linear-gradient(45deg, red, blue)" });
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("Compound Component", () => {
|
|
185
|
+
test("Background.Image is accessible via dot notation", async () => {
|
|
186
|
+
render(
|
|
187
|
+
<Background data-testid="bg-container">
|
|
188
|
+
<Background.Image src="/test.jpg" data-testid="bg-img" />
|
|
189
|
+
</Background>,
|
|
190
|
+
);
|
|
191
|
+
await expect
|
|
192
|
+
.element(page.getByTestId("bg-container"))
|
|
193
|
+
.toBeInTheDocument();
|
|
194
|
+
await expect.element(page.getByTestId("bg-img")).toBeInTheDocument();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("can compose multiple background layers", async () => {
|
|
198
|
+
render(
|
|
199
|
+
<Background data-testid="bg-container">
|
|
200
|
+
<Background.Image src="/test.jpg" data-testid="bg-img" />
|
|
201
|
+
<Background.Overlay data-testid="bg-overlay" />
|
|
202
|
+
<Background.Gradient data-testid="bg-gradient" />
|
|
203
|
+
</Background>,
|
|
204
|
+
);
|
|
205
|
+
await expect
|
|
206
|
+
.element(page.getByTestId("bg-container"))
|
|
207
|
+
.toBeInTheDocument();
|
|
208
|
+
await expect.element(page.getByTestId("bg-img")).toBeInTheDocument();
|
|
209
|
+
await expect.element(page.getByTestId("bg-overlay")).toBeInTheDocument();
|
|
210
|
+
await expect.element(page.getByTestId("bg-gradient")).toBeInTheDocument();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRender } from "@base-ui-components/react/use-render";
|
|
1
4
|
import * as React from "react";
|
|
2
5
|
import { tv } from "tailwind-variants";
|
|
3
6
|
|
|
@@ -56,28 +59,59 @@ export interface BackgroundImageProps
|
|
|
56
59
|
* Object position (default: "center")
|
|
57
60
|
*/
|
|
58
61
|
position?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Custom render prop for element composition.
|
|
64
|
+
* Accepts a React element or render function.
|
|
65
|
+
* @example
|
|
66
|
+
* ```tsx
|
|
67
|
+
* // Element pattern
|
|
68
|
+
* <BackgroundImage render={<img className="custom" />} src="/bg.jpg" />
|
|
69
|
+
*
|
|
70
|
+
* // Callback pattern
|
|
71
|
+
* <BackgroundImage render={(props) => <img {...props} />} src="/bg.jpg" />
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
render?:
|
|
75
|
+
| React.ReactElement
|
|
76
|
+
| ((
|
|
77
|
+
props: React.ImgHTMLAttributes<HTMLImageElement>,
|
|
78
|
+
) => React.ReactElement);
|
|
59
79
|
}
|
|
60
80
|
|
|
61
81
|
/**
|
|
62
82
|
* Background image layer using an actual img element with object-cover.
|
|
63
83
|
* Supports native lazy loading, srcset, and better accessibility.
|
|
84
|
+
* Supports render prop for element composition.
|
|
64
85
|
*/
|
|
65
86
|
const BackgroundImage = React.forwardRef<
|
|
66
87
|
HTMLImageElement,
|
|
67
88
|
BackgroundImageProps
|
|
68
|
-
>(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
>(
|
|
90
|
+
(
|
|
91
|
+
{ className, src, position = "center", alt = "", style, render, ...props },
|
|
92
|
+
ref,
|
|
93
|
+
) => {
|
|
94
|
+
const mergedProps = {
|
|
95
|
+
src,
|
|
96
|
+
alt,
|
|
97
|
+
className: backgroundImageVariants({ class: className }),
|
|
98
|
+
style: {
|
|
99
|
+
objectPosition: position,
|
|
100
|
+
...style,
|
|
101
|
+
},
|
|
102
|
+
...props,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const element = useRender({
|
|
106
|
+
// biome-ignore lint/a11y/useAltText: alt is provided via mergedProps
|
|
107
|
+
render: render ?? <img />,
|
|
108
|
+
ref,
|
|
109
|
+
props: mergedProps,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return element;
|
|
113
|
+
},
|
|
114
|
+
);
|
|
81
115
|
BackgroundImage.displayName = "Background.Image";
|
|
82
116
|
|
|
83
117
|
// =============================================================================
|
|
@@ -102,10 +136,23 @@ export interface BackgroundVideoProps
|
|
|
102
136
|
* Poster image URL shown before video loads
|
|
103
137
|
*/
|
|
104
138
|
poster?: string;
|
|
139
|
+
/**
|
|
140
|
+
* Custom render prop for element composition.
|
|
141
|
+
* @example
|
|
142
|
+
* ```tsx
|
|
143
|
+
* <BackgroundVideo render={<video className="custom" />} src="/bg.mp4" />
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
render?:
|
|
147
|
+
| React.ReactElement
|
|
148
|
+
| ((
|
|
149
|
+
props: React.VideoHTMLAttributes<HTMLVideoElement>,
|
|
150
|
+
) => React.ReactElement);
|
|
105
151
|
}
|
|
106
152
|
|
|
107
153
|
/**
|
|
108
154
|
* Background video layer using HTML5 video element.
|
|
155
|
+
* Supports render prop for element composition.
|
|
109
156
|
*/
|
|
110
157
|
const BackgroundVideo = React.forwardRef<
|
|
111
158
|
HTMLVideoElement,
|
|
@@ -121,23 +168,34 @@ const BackgroundVideo = React.forwardRef<
|
|
|
121
168
|
loop = true,
|
|
122
169
|
muted = true,
|
|
123
170
|
playsInline = true,
|
|
171
|
+
render,
|
|
124
172
|
...props
|
|
125
173
|
},
|
|
126
174
|
ref,
|
|
127
|
-
) =>
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
175
|
+
) => {
|
|
176
|
+
const mergedProps = {
|
|
177
|
+
autoPlay,
|
|
178
|
+
loop,
|
|
179
|
+
muted,
|
|
180
|
+
playsInline,
|
|
181
|
+
poster,
|
|
182
|
+
className: backgroundVideoVariants({ class: className }),
|
|
183
|
+
...props,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const element = useRender({
|
|
187
|
+
render: render ?? (
|
|
188
|
+
// biome-ignore lint/a11y/useMediaCaption: Background videos are decorative and shouldn't have captions
|
|
189
|
+
<video>
|
|
190
|
+
<source src={src} type={type} />
|
|
191
|
+
</video>
|
|
192
|
+
),
|
|
193
|
+
ref,
|
|
194
|
+
props: mergedProps,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return element;
|
|
198
|
+
},
|
|
141
199
|
);
|
|
142
200
|
BackgroundVideo.displayName = "Background.Video";
|
|
143
201
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
Button as BaseButton,
|
|
3
5
|
type ButtonProps as BaseButtonProps,
|
|
@@ -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
|
);
|