@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 ADDED
@@ -0,0 +1,7 @@
1
+ # @khanacademy/wonder-blocks-card
2
+
3
+ ## 0.0.0-PR2799-20250925192443
4
+
5
+ ### Minor Changes
6
+
7
+ - 7506aef: Add new Card component
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.
@@ -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,3 @@
1
+ import Card from "./components/card";
2
+
3
+ export {Card};
@@ -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
+ }