@khanacademy/wonder-blocks-card 0.0.0-PR2799-20250925192443
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/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/dist/es/index.js +14 -0
- package/dist/index.js +42 -0
- package/package.json +31 -0
- package/src/__tests__/components/card.test.tsx +206 -0
- package/src/__tests__/components/dismiss-button.test.tsx +145 -0
- package/src/components/card.tsx +181 -0
- package/src/components/dismiss-button.tsx +61 -0
- package/src/index.ts +3 -0
- package/tsconfig-build.json +14 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2018 Khan Academy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/es/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { StyleSheet } from 'aphrodite';
|
|
4
|
+
import { View } from '@khanacademy/wonder-blocks-core';
|
|
5
|
+
import { sizing, semanticColor, border, boxShadow } from '@khanacademy/wonder-blocks-tokens';
|
|
6
|
+
import xIcon from '@phosphor-icons/core/bold/x-bold.svg';
|
|
7
|
+
import IconButton from '@khanacademy/wonder-blocks-icon-button';
|
|
8
|
+
import { focusStyles } from '@khanacademy/wonder-blocks-styles';
|
|
9
|
+
|
|
10
|
+
const DismissButton=props=>{const{onClick,style,testId}=props;return jsx(IconButton,{icon:xIcon,"aria-label":props["aria-label"]||"Close",onClick:onClick,kind:"tertiary",actionType:"neutral",style:[componentStyles.root,style],testId:testId})};const componentStyles=StyleSheet.create({root:{position:"absolute",insetInlineEnd:sizing.size_080,top:sizing.size_080,zIndex:1,":focus":focusStyles.focus[":focus-visible"]}});
|
|
11
|
+
|
|
12
|
+
const Card=React.forwardRef(function Card(props,ref){const{styles,labels,tag,testId,backgroundColor="default",borderRadius="radius_080",padding="size_160",children,onDismiss,inert}=props;const componentStyles=getComponentStyles({backgroundColor,borderRadius,padding});return jsxs(View,{"aria-label":labels?.cardAriaLabel,style:[componentStyles.root,styles?.root],ref:ref,tag:tag,testId:testId,inert:inert?"":undefined,children:[onDismiss?jsx(DismissButton,{"aria-label":labels?.dismissButtonAriaLabel||"Close",onClick:e=>onDismiss?.(e)}):null,children]})});const getComponentStyles=({backgroundColor,borderRadius,padding})=>{const backgroundColorStyle=backgroundColor&&backgroundColor==="subtle"||backgroundColor==="default"?semanticColor.core.background.base[backgroundColor]:undefined;return StyleSheet.create({root:{backgroundColor:backgroundColorStyle,borderColor:semanticColor.core.border.neutral.subtle,borderStyle:"solid",borderRadius:borderRadius&&border.radius[borderRadius],borderWidth:border.width.thin,boxShadow:boxShadow.low,padding:padding&&sizing[padding],maxWidth:"295px",position:"relative",width:"100%"}})};
|
|
13
|
+
|
|
14
|
+
export { Card };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
6
|
+
var React = require('react');
|
|
7
|
+
var aphrodite = require('aphrodite');
|
|
8
|
+
var wonderBlocksCore = require('@khanacademy/wonder-blocks-core');
|
|
9
|
+
var wonderBlocksTokens = require('@khanacademy/wonder-blocks-tokens');
|
|
10
|
+
var xIcon = require('@phosphor-icons/core/bold/x-bold.svg');
|
|
11
|
+
var IconButton = require('@khanacademy/wonder-blocks-icon-button');
|
|
12
|
+
var wonderBlocksStyles = require('@khanacademy/wonder-blocks-styles');
|
|
13
|
+
|
|
14
|
+
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
|
|
15
|
+
|
|
16
|
+
function _interopNamespace(e) {
|
|
17
|
+
if (e && e.__esModule) return e;
|
|
18
|
+
var n = Object.create(null);
|
|
19
|
+
if (e) {
|
|
20
|
+
Object.keys(e).forEach(function (k) {
|
|
21
|
+
if (k !== 'default') {
|
|
22
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
23
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
24
|
+
enumerable: true,
|
|
25
|
+
get: function () { return e[k]; }
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
n["default"] = e;
|
|
31
|
+
return Object.freeze(n);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
var React__namespace = /*#__PURE__*/_interopNamespace(React);
|
|
35
|
+
var xIcon__default = /*#__PURE__*/_interopDefaultLegacy(xIcon);
|
|
36
|
+
var IconButton__default = /*#__PURE__*/_interopDefaultLegacy(IconButton);
|
|
37
|
+
|
|
38
|
+
const DismissButton=props=>{const{onClick,style,testId}=props;return jsxRuntime.jsx(IconButton__default["default"],{icon:xIcon__default["default"],"aria-label":props["aria-label"]||"Close",onClick:onClick,kind:"tertiary",actionType:"neutral",style:[componentStyles.root,style],testId:testId})};const componentStyles=aphrodite.StyleSheet.create({root:{position:"absolute",insetInlineEnd:wonderBlocksTokens.sizing.size_080,top:wonderBlocksTokens.sizing.size_080,zIndex:1,":focus":wonderBlocksStyles.focusStyles.focus[":focus-visible"]}});
|
|
39
|
+
|
|
40
|
+
const Card=React__namespace.forwardRef(function Card(props,ref){const{styles,labels,tag,testId,backgroundColor="default",borderRadius="radius_080",padding="size_160",children,onDismiss,inert}=props;const componentStyles=getComponentStyles({backgroundColor,borderRadius,padding});return jsxRuntime.jsxs(wonderBlocksCore.View,{"aria-label":labels?.cardAriaLabel,style:[componentStyles.root,styles?.root],ref:ref,tag:tag,testId:testId,inert:inert?"":undefined,children:[onDismiss?jsxRuntime.jsx(DismissButton,{"aria-label":labels?.dismissButtonAriaLabel||"Close",onClick:e=>onDismiss?.(e)}):null,children]})});const getComponentStyles=({backgroundColor,borderRadius,padding})=>{const backgroundColorStyle=backgroundColor&&backgroundColor==="subtle"||backgroundColor==="default"?wonderBlocksTokens.semanticColor.core.background.base[backgroundColor]:undefined;return aphrodite.StyleSheet.create({root:{backgroundColor:backgroundColorStyle,borderColor:wonderBlocksTokens.semanticColor.core.border.neutral.subtle,borderStyle:"solid",borderRadius:borderRadius&&wonderBlocksTokens.border.radius[borderRadius],borderWidth:wonderBlocksTokens.border.width.thin,boxShadow:wonderBlocksTokens.boxShadow.low,padding:padding&&wonderBlocksTokens.sizing[padding],maxWidth:"295px",position:"relative",width:"100%"}})};
|
|
41
|
+
|
|
42
|
+
exports.Card = Card;
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@khanacademy/wonder-blocks-card",
|
|
3
|
+
"version": "0.0.0-PR2799-20250925192443",
|
|
4
|
+
"design": "v1",
|
|
5
|
+
"description": "Card component for Wonder Blocks.",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"module": "dist/es/index.js",
|
|
8
|
+
"source": "src/index.js",
|
|
9
|
+
"types": "dist/index.d.ts",
|
|
10
|
+
"author": "",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@khanacademy/wonder-blocks-core": "12.4.0",
|
|
17
|
+
"@khanacademy/wonder-blocks-icon-button": "10.5.2",
|
|
18
|
+
"@khanacademy/wonder-blocks-tokens": "14.0.0"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"aphrodite": "^1.2.5",
|
|
22
|
+
"react": "18.2.0",
|
|
23
|
+
"@phosphor-icons/core": "^2.0.2"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@khanacademy/wb-dev-build-settings": "3.2.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {render, screen} from "@testing-library/react";
|
|
3
|
+
import {userEvent} from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import Card from "../../components/card";
|
|
6
|
+
|
|
7
|
+
describe("Card", () => {
|
|
8
|
+
describe("Basic rendering", () => {
|
|
9
|
+
it("should render children correctly", () => {
|
|
10
|
+
// Arrange
|
|
11
|
+
render(
|
|
12
|
+
<Card>
|
|
13
|
+
<div data-testid="child-content">Card Content</div>
|
|
14
|
+
</Card>,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
// Act
|
|
18
|
+
const childContent = screen.getByTestId("child-content");
|
|
19
|
+
|
|
20
|
+
// Assert
|
|
21
|
+
expect(childContent).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should not render dismiss button by default", () => {
|
|
25
|
+
// Arrange
|
|
26
|
+
render(
|
|
27
|
+
<Card>
|
|
28
|
+
<div>Content</div>
|
|
29
|
+
</Card>,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Act
|
|
33
|
+
const dismissButton = screen.queryByRole("button");
|
|
34
|
+
|
|
35
|
+
// Assert
|
|
36
|
+
expect(dismissButton).not.toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("Dismiss button functionality", () => {
|
|
41
|
+
it("should render dismiss button when onDismiss is present", () => {
|
|
42
|
+
// Arrange
|
|
43
|
+
render(
|
|
44
|
+
<Card onDismiss={() => {}}>
|
|
45
|
+
<div>Content</div>
|
|
46
|
+
</Card>,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Act
|
|
50
|
+
const dismissButton = screen.getByRole("button");
|
|
51
|
+
|
|
52
|
+
// Assert
|
|
53
|
+
expect(dismissButton).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should not render dismiss button there is no onDismiss prop", () => {
|
|
57
|
+
// Arrange
|
|
58
|
+
render(
|
|
59
|
+
<Card>
|
|
60
|
+
<div>Content</div>
|
|
61
|
+
</Card>,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Act
|
|
65
|
+
const dismissButton = screen.queryByRole("button");
|
|
66
|
+
|
|
67
|
+
// Assert
|
|
68
|
+
expect(dismissButton).not.toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should call onDismiss when dismiss button is clicked", async () => {
|
|
72
|
+
// Arrange
|
|
73
|
+
const mockOnDismiss = jest.fn();
|
|
74
|
+
render(
|
|
75
|
+
<Card
|
|
76
|
+
labels={{dismissButtonAriaLabel: "Close it!"}}
|
|
77
|
+
onDismiss={mockOnDismiss}
|
|
78
|
+
>
|
|
79
|
+
<div>Content</div>
|
|
80
|
+
</Card>,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Act
|
|
84
|
+
const dismissButton = screen.getByRole("button", {
|
|
85
|
+
name: "Close it!",
|
|
86
|
+
});
|
|
87
|
+
await userEvent.click(dismissButton);
|
|
88
|
+
|
|
89
|
+
// Assert
|
|
90
|
+
expect(mockOnDismiss).toHaveBeenCalledTimes(1);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("Accessibility", () => {
|
|
95
|
+
it("should use default aria-label on dismiss button when dismissButtonAriaLabel is not provided", () => {
|
|
96
|
+
// Arrange
|
|
97
|
+
render(
|
|
98
|
+
<Card onDismiss={() => {}}>
|
|
99
|
+
<div>Content</div>
|
|
100
|
+
</Card>,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Act
|
|
104
|
+
const dismissButton = screen.getByRole("button", {name: "Close"});
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
107
|
+
expect(dismissButton).toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should pass custom aria-label to dismiss button", () => {
|
|
111
|
+
// Arrange
|
|
112
|
+
render(
|
|
113
|
+
<Card
|
|
114
|
+
onDismiss={() => {}}
|
|
115
|
+
labels={{dismissButtonAriaLabel: "Custom Close"}}
|
|
116
|
+
>
|
|
117
|
+
<div>Content</div>
|
|
118
|
+
</Card>,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Act
|
|
122
|
+
const dismissButton = screen.getByRole("button", {
|
|
123
|
+
name: "Custom Close",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Assert
|
|
127
|
+
expect(dismissButton).toBeInTheDocument();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("should render with a custom tag", () => {
|
|
131
|
+
// Arrange
|
|
132
|
+
render(
|
|
133
|
+
<Card tag="section" labels={{cardAriaLabel: "Card Section"}}>
|
|
134
|
+
<h2>Heading</h2>
|
|
135
|
+
<p>Description</p>
|
|
136
|
+
</Card>,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Act
|
|
140
|
+
const section = screen.getByRole("region");
|
|
141
|
+
|
|
142
|
+
// Assert
|
|
143
|
+
expect(section).toBeInTheDocument();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("should apply labels.cardAriaLabel for a custom tag", () => {
|
|
147
|
+
// Arrange
|
|
148
|
+
render(
|
|
149
|
+
<Card
|
|
150
|
+
tag="section"
|
|
151
|
+
labels={{cardAriaLabel: "Custom section label"}}
|
|
152
|
+
>
|
|
153
|
+
<h2>Heading</h2>
|
|
154
|
+
<p>Description</p>
|
|
155
|
+
</Card>,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Act
|
|
159
|
+
const section = screen.getByRole("region", {
|
|
160
|
+
name: "Custom section label",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Assert
|
|
164
|
+
expect(section).toBeInTheDocument();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("should apply the inert attribute", () => {
|
|
168
|
+
// Arrange
|
|
169
|
+
render(
|
|
170
|
+
<Card inert testId="card">
|
|
171
|
+
<h2>Heading</h2>
|
|
172
|
+
<p>Description</p>
|
|
173
|
+
<button>Button</button>
|
|
174
|
+
</Card>,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Act
|
|
178
|
+
const section = screen.getByTestId("card");
|
|
179
|
+
|
|
180
|
+
// Assert
|
|
181
|
+
expect(section).toHaveAttribute("inert");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("Complex content scenarios", () => {
|
|
186
|
+
it("should work with fragment children", () => {
|
|
187
|
+
// Arrange
|
|
188
|
+
render(
|
|
189
|
+
<Card>
|
|
190
|
+
<>
|
|
191
|
+
<span data-testid="fragment-child-1">First</span>
|
|
192
|
+
<span data-testid="fragment-child-2">Second</span>
|
|
193
|
+
</>
|
|
194
|
+
</Card>,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Act
|
|
198
|
+
const firstChild = screen.getByTestId("fragment-child-1");
|
|
199
|
+
const secondChild = screen.getByTestId("fragment-child-2");
|
|
200
|
+
|
|
201
|
+
// Assert
|
|
202
|
+
expect(firstChild).toBeInTheDocument();
|
|
203
|
+
expect(secondChild).toBeInTheDocument();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {render, screen} from "@testing-library/react";
|
|
3
|
+
import {userEvent} from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import {DismissButton} from "../../components/dismiss-button";
|
|
6
|
+
|
|
7
|
+
describe("DismissButton", () => {
|
|
8
|
+
describe("Basic rendering", () => {
|
|
9
|
+
it("should render as a button", () => {
|
|
10
|
+
// Arrange & Act
|
|
11
|
+
render(<DismissButton />);
|
|
12
|
+
|
|
13
|
+
// Assert
|
|
14
|
+
expect(screen.getByRole("button")).toBeInTheDocument();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("Accessibility", () => {
|
|
19
|
+
it("should use default aria-label when not provided", () => {
|
|
20
|
+
// Arrange & Act
|
|
21
|
+
render(<DismissButton />);
|
|
22
|
+
|
|
23
|
+
// Assert
|
|
24
|
+
expect(
|
|
25
|
+
screen.getByRole("button", {name: "Close"}),
|
|
26
|
+
).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should use custom aria-label when provided", () => {
|
|
30
|
+
// Arrange & Act
|
|
31
|
+
render(<DismissButton aria-label="Dismiss notification" />);
|
|
32
|
+
|
|
33
|
+
// Assert
|
|
34
|
+
expect(
|
|
35
|
+
screen.getByRole("button", {name: "Dismiss notification"}),
|
|
36
|
+
).toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should be keyboard accessible", () => {
|
|
40
|
+
// Arrange
|
|
41
|
+
render(<DismissButton aria-label="Close" />);
|
|
42
|
+
|
|
43
|
+
// Act
|
|
44
|
+
const button = screen.getByRole("button");
|
|
45
|
+
button.focus();
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
expect(button).toHaveFocus();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("Event handling", () => {
|
|
53
|
+
it("should call onClick when button is clicked", async () => {
|
|
54
|
+
// Arrange
|
|
55
|
+
const mockOnClick = jest.fn();
|
|
56
|
+
render(<DismissButton onClick={mockOnClick} aria-label="Close" />);
|
|
57
|
+
|
|
58
|
+
// Act
|
|
59
|
+
const button = screen.getByRole("button", {name: "Close"});
|
|
60
|
+
await userEvent.click(button);
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
63
|
+
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should call onClick with event parameter", async () => {
|
|
67
|
+
// Arrange
|
|
68
|
+
const mockOnClick = jest.fn();
|
|
69
|
+
render(<DismissButton onClick={mockOnClick} aria-label="Close" />);
|
|
70
|
+
|
|
71
|
+
// Act
|
|
72
|
+
const button = screen.getByRole("button", {name: "Close"});
|
|
73
|
+
await userEvent.click(button);
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
76
|
+
expect(mockOnClick).toHaveBeenCalledWith(
|
|
77
|
+
expect.objectContaining({
|
|
78
|
+
type: "click",
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should handle missing onClick prop gracefully", async () => {
|
|
84
|
+
// Arrange
|
|
85
|
+
render(<DismissButton aria-label="Close" />);
|
|
86
|
+
|
|
87
|
+
// Act & Assert
|
|
88
|
+
const button = screen.getByRole("button", {name: "Close"});
|
|
89
|
+
await expect(userEvent.click(button)).resolves.not.toThrow();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should support keyboard activation with Enter", async () => {
|
|
93
|
+
// Arrange
|
|
94
|
+
const mockOnClick = jest.fn();
|
|
95
|
+
render(<DismissButton onClick={mockOnClick} aria-label="Close" />);
|
|
96
|
+
|
|
97
|
+
// Act
|
|
98
|
+
const button = screen.getByRole("button", {name: "Close"});
|
|
99
|
+
button.focus();
|
|
100
|
+
await userEvent.keyboard("{Enter}");
|
|
101
|
+
|
|
102
|
+
// Assert
|
|
103
|
+
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should support keyboard activation with Space", async () => {
|
|
107
|
+
// Arrange
|
|
108
|
+
const mockOnClick = jest.fn();
|
|
109
|
+
render(<DismissButton onClick={mockOnClick} aria-label="Close" />);
|
|
110
|
+
|
|
111
|
+
// Act
|
|
112
|
+
const button = screen.getByRole("button", {name: "Close"});
|
|
113
|
+
button.focus();
|
|
114
|
+
await userEvent.keyboard(" ");
|
|
115
|
+
|
|
116
|
+
// Assert
|
|
117
|
+
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("Test ID support", () => {
|
|
122
|
+
it("should set data-testid attribute when testId is provided", () => {
|
|
123
|
+
// Arrange
|
|
124
|
+
const testId = "dismiss-button-test";
|
|
125
|
+
render(<DismissButton testId={testId} aria-label="Close" />);
|
|
126
|
+
|
|
127
|
+
// Act
|
|
128
|
+
const button = screen.getByTestId(testId);
|
|
129
|
+
|
|
130
|
+
// Assert
|
|
131
|
+
expect(button).toHaveAttribute("data-testid", testId);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should not have data-testid attribute when testId is not provided", () => {
|
|
135
|
+
// Arrange
|
|
136
|
+
render(<DismissButton aria-label="Close" />);
|
|
137
|
+
|
|
138
|
+
// Act
|
|
139
|
+
const button = screen.getByRole("button");
|
|
140
|
+
|
|
141
|
+
// Assert
|
|
142
|
+
expect(button).not.toHaveAttribute("data-testid");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import {StyleSheet} from "aphrodite";
|
|
4
|
+
import {StyleType, View} from "@khanacademy/wonder-blocks-core";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
boxShadow,
|
|
8
|
+
border,
|
|
9
|
+
semanticColor,
|
|
10
|
+
sizing,
|
|
11
|
+
} from "@khanacademy/wonder-blocks-tokens";
|
|
12
|
+
|
|
13
|
+
import {DismissButton} from "./dismiss-button";
|
|
14
|
+
|
|
15
|
+
type AllowedStyleProps = {
|
|
16
|
+
/**
|
|
17
|
+
* The background color of the card, as a string identifier that matches a semanticColor token.
|
|
18
|
+
* This can be one of:
|
|
19
|
+
* - `"subtle"`, matching `semanticColor.core.background.base.subtle`: a light gray background, useful for cards that need to stand out from the page background.
|
|
20
|
+
* - `"default"`, matching `semanticColor.core.background.base.default`: a white background, useful for cards that are placed on a light gray page background.
|
|
21
|
+
*
|
|
22
|
+
* Default: `"default"`
|
|
23
|
+
*/
|
|
24
|
+
backgroundColor?: "subtle" | "default";
|
|
25
|
+
/**
|
|
26
|
+
* The border radius of the card, as a string identifier that matches a border.radius token.
|
|
27
|
+
* This can be one of:
|
|
28
|
+
* - `"radius_080"`, matching `border.radius.radius_080`: a moderate border radius, useful for cards that need to have a slightly rounded appearance.
|
|
29
|
+
* - `"radius_120"`, matching `border.radius.radius_120`: a more pronounced border radius, useful for cards that need to have a more rounded appearance.
|
|
30
|
+
*
|
|
31
|
+
* Default: `"radius_080"`
|
|
32
|
+
*/
|
|
33
|
+
borderRadius?: "radius_080" | "radius_120";
|
|
34
|
+
/**
|
|
35
|
+
* The padding inside the card, as a string identifier that matches a sizing token.
|
|
36
|
+
* This can be one of:
|
|
37
|
+
* - `"size_0"`, matching `sizing.size_0`: no padding, useful for cards that need to have content flush with the edges.
|
|
38
|
+
* - `"size_160"`, matching `sizing.size_160`: moderate padding, useful for cards that need to have some space between the content and the edges.
|
|
39
|
+
* - `"size_240"`, matching `sizing.size_240`: more padding, useful for cards that need to have more space between the content and the edges.
|
|
40
|
+
*
|
|
41
|
+
* Default: `"size_160"`
|
|
42
|
+
*/
|
|
43
|
+
padding?: "size_0" | "size_160" | "size_240";
|
|
44
|
+
};
|
|
45
|
+
type Props = {
|
|
46
|
+
/**
|
|
47
|
+
* Optional styles to be applied to the root element and the dismiss button.
|
|
48
|
+
*/
|
|
49
|
+
styles?: {
|
|
50
|
+
root?: StyleType;
|
|
51
|
+
dismissButton?: StyleType;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* A ref that will be passed to the root element (i.e. the card container).
|
|
55
|
+
*/
|
|
56
|
+
ref?: React.Ref<any>;
|
|
57
|
+
/**
|
|
58
|
+
* The HTML tag to use for the card container. By default, this is a `<div>`, but
|
|
59
|
+
* if the card is being used as a landmark region, you may want to set this to
|
|
60
|
+
* `<section>` and provide an appropriate `aria-label` via the `labels` prop.
|
|
61
|
+
*/
|
|
62
|
+
tag?: keyof JSX.IntrinsicElements;
|
|
63
|
+
/**
|
|
64
|
+
* The content for the card.
|
|
65
|
+
*/
|
|
66
|
+
children: React.ReactNode;
|
|
67
|
+
/**
|
|
68
|
+
* A set of localizable labels for this component, including a dismiss button
|
|
69
|
+
* and the card itself, if marked as an HTML region.
|
|
70
|
+
*/
|
|
71
|
+
labels?: {
|
|
72
|
+
dismissButtonAriaLabel?: string;
|
|
73
|
+
cardAriaLabel?: string;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* A callback function to handle dismissing the card. When this prop is present,
|
|
77
|
+
* a dismiss button with an X icon will be rendered.
|
|
78
|
+
*/
|
|
79
|
+
onDismiss?: (e?: React.SyntheticEvent) => void;
|
|
80
|
+
/**
|
|
81
|
+
* An optional attribute to remove this component from the accessibility tree
|
|
82
|
+
* and keyboard tab order, such as for inactive cards in a stack.
|
|
83
|
+
*/
|
|
84
|
+
inert?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* The test ID used to locate this component in automated tests.
|
|
87
|
+
*/
|
|
88
|
+
testId?: string;
|
|
89
|
+
} & AllowedStyleProps;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The Card component is a flexible, reusable UI building block designed to
|
|
93
|
+
* encapsulate content within a structured, visually distinct container.
|
|
94
|
+
* Its primary goal is to present grouped or related information in a way that
|
|
95
|
+
* is visually consistent, easily scannable, and modular across different
|
|
96
|
+
* parts of the application.
|
|
97
|
+
*
|
|
98
|
+
* Cards provide a defined surface area with clear visual boundaries
|
|
99
|
+
* (via border-radius and box-shadow elevation tokens), making them ideal for
|
|
100
|
+
* use cases that involve displaying comparable content items side-by-side or
|
|
101
|
+
* in structured layouts such as grids, lists, or dashboards.
|
|
102
|
+
*
|
|
103
|
+
* ### Usage
|
|
104
|
+
*
|
|
105
|
+
* ```jsx
|
|
106
|
+
* import {Card} from "@khanacademy/wonder-blocks-card";
|
|
107
|
+
*
|
|
108
|
+
* <Card>
|
|
109
|
+
* <Heading>This is a basic card.</Heading>
|
|
110
|
+
* </Card>
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
const Card = React.forwardRef(function Card(
|
|
114
|
+
props: Props,
|
|
115
|
+
ref: React.ForwardedRef<any>,
|
|
116
|
+
) {
|
|
117
|
+
const {
|
|
118
|
+
styles,
|
|
119
|
+
labels,
|
|
120
|
+
tag,
|
|
121
|
+
testId,
|
|
122
|
+
backgroundColor = "default",
|
|
123
|
+
borderRadius = "radius_080",
|
|
124
|
+
padding = "size_160", // TODO: figure out conversion to px
|
|
125
|
+
children,
|
|
126
|
+
onDismiss,
|
|
127
|
+
inert,
|
|
128
|
+
} = props;
|
|
129
|
+
|
|
130
|
+
const componentStyles = getComponentStyles({
|
|
131
|
+
backgroundColor,
|
|
132
|
+
borderRadius,
|
|
133
|
+
padding,
|
|
134
|
+
});
|
|
135
|
+
return (
|
|
136
|
+
<View
|
|
137
|
+
aria-label={labels?.cardAriaLabel}
|
|
138
|
+
style={[componentStyles.root, styles?.root]}
|
|
139
|
+
ref={ref}
|
|
140
|
+
tag={tag}
|
|
141
|
+
testId={testId}
|
|
142
|
+
{...{inert: inert ? "" : undefined}}
|
|
143
|
+
>
|
|
144
|
+
{onDismiss ? (
|
|
145
|
+
<DismissButton
|
|
146
|
+
aria-label={labels?.dismissButtonAriaLabel || "Close"}
|
|
147
|
+
onClick={(e) => onDismiss?.(e)}
|
|
148
|
+
/>
|
|
149
|
+
) : null}
|
|
150
|
+
{children}
|
|
151
|
+
</View>
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const getComponentStyles = ({
|
|
156
|
+
backgroundColor,
|
|
157
|
+
borderRadius,
|
|
158
|
+
padding,
|
|
159
|
+
}: AllowedStyleProps) => {
|
|
160
|
+
const backgroundColorStyle =
|
|
161
|
+
(backgroundColor && backgroundColor === "subtle") ||
|
|
162
|
+
backgroundColor === "default"
|
|
163
|
+
? semanticColor.core.background.base[backgroundColor]
|
|
164
|
+
: undefined;
|
|
165
|
+
return StyleSheet.create({
|
|
166
|
+
root: {
|
|
167
|
+
backgroundColor: backgroundColorStyle,
|
|
168
|
+
borderColor: semanticColor.core.border.neutral.subtle,
|
|
169
|
+
borderStyle: "solid",
|
|
170
|
+
borderRadius: borderRadius && border.radius[borderRadius],
|
|
171
|
+
borderWidth: border.width.thin,
|
|
172
|
+
boxShadow: boxShadow.low,
|
|
173
|
+
padding: padding && sizing[padding], // TODO: figure out conversion to px
|
|
174
|
+
maxWidth: "295px", // TODO: figure out max/min widths
|
|
175
|
+
position: "relative",
|
|
176
|
+
width: "100%",
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export default Card;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {StyleSheet} from "aphrodite";
|
|
3
|
+
import xIcon from "@phosphor-icons/core/bold/x-bold.svg";
|
|
4
|
+
import IconButton from "@khanacademy/wonder-blocks-icon-button";
|
|
5
|
+
import type {StyleType} from "@khanacademy/wonder-blocks-core";
|
|
6
|
+
import {focusStyles} from "@khanacademy/wonder-blocks-styles";
|
|
7
|
+
import {sizing} from "@khanacademy/wonder-blocks-tokens";
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
/** Optional click handler */
|
|
11
|
+
onClick?: (e?: React.SyntheticEvent) => unknown;
|
|
12
|
+
/** Screen reader label for close button */
|
|
13
|
+
"aria-label"?: string;
|
|
14
|
+
/** Optional custom styles. */
|
|
15
|
+
style?: StyleType;
|
|
16
|
+
/**
|
|
17
|
+
* Test ID used for e2e testing.
|
|
18
|
+
*
|
|
19
|
+
* In this case, this component is internal, so `testId` is composed with
|
|
20
|
+
* the `testId` passed down from the Dialog variant + a suffix to scope it
|
|
21
|
+
* to this component.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* For testId="some-random-id"
|
|
25
|
+
* The result will be: `some-random-id-modal-panel`
|
|
26
|
+
*/
|
|
27
|
+
testId?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// TODO[WB-2090]: Update to shared CloseButton component
|
|
31
|
+
export const DismissButton = (props: Props) => {
|
|
32
|
+
const {onClick, style, testId} = props;
|
|
33
|
+
return (
|
|
34
|
+
<IconButton
|
|
35
|
+
icon={xIcon}
|
|
36
|
+
aria-label={props["aria-label"] || "Close"}
|
|
37
|
+
onClick={onClick}
|
|
38
|
+
kind="tertiary"
|
|
39
|
+
actionType="neutral"
|
|
40
|
+
style={[componentStyles.root, style]}
|
|
41
|
+
testId={testId}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const componentStyles = StyleSheet.create({
|
|
47
|
+
root: {
|
|
48
|
+
position: "absolute",
|
|
49
|
+
// insetInlineEnd supports both RTL and LTR layouts
|
|
50
|
+
insetInlineEnd: sizing.size_080,
|
|
51
|
+
top: sizing.size_080,
|
|
52
|
+
// This is to allow the button to be tab-ordered before the component
|
|
53
|
+
// content but still be above the content.
|
|
54
|
+
zIndex: 1,
|
|
55
|
+
|
|
56
|
+
// NOTE: IconButton uses :focus-visible, which is not supported for
|
|
57
|
+
// programmatic focus. This is a workaround to make sure the focus
|
|
58
|
+
// outline is visible when this control is focused.
|
|
59
|
+
":focus": focusStyles.focus[":focus-visible"],
|
|
60
|
+
},
|
|
61
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"exclude": ["dist"],
|
|
3
|
+
"extends": "../tsconfig-shared.json",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"outDir": "./dist",
|
|
6
|
+
"rootDir": "src",
|
|
7
|
+
},
|
|
8
|
+
"references": [
|
|
9
|
+
{"path": "../wonder-blocks-core/tsconfig-build.json"},
|
|
10
|
+
{"path": "../wonder-blocks-icon/tsconfig-build.json"},
|
|
11
|
+
{"path": "../wonder-blocks-icon-button/tsconfig-build.json"},
|
|
12
|
+
{"path": "../wonder-blocks-tokens/tsconfig-build.json"},
|
|
13
|
+
]
|
|
14
|
+
}
|