@khanacademy/wonder-blocks-button 6.0.0 → 6.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.
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Tests for Wonder Blocks Button with icons.
3
+ * The rest of the button tests can be found in button.test.tsx.
4
+ */
5
+
6
+ import * as React from "react";
7
+ import {render, screen} from "@testing-library/react";
8
+ import userEvent from "@testing-library/user-event";
9
+ import plus from "@phosphor-icons/core/regular/plus.svg";
10
+
11
+ import {ThemeSwitcherContext, tokens} from "@khanacademy/wonder-blocks-theming";
12
+
13
+ import Button from "../button";
14
+
15
+ describe("button with icon", () => {
16
+ test("start icon should be hidden from Screen Readers", () => {
17
+ // Arrange
18
+ render(
19
+ <Button testId={"button-focus-test"} startIcon={plus}>
20
+ Label
21
+ </Button>,
22
+ );
23
+
24
+ // Act
25
+ const icon = screen.getByTestId("button-focus-test-start-icon");
26
+
27
+ // Assert
28
+ expect(icon).toHaveAttribute("aria-hidden", "true");
29
+ });
30
+
31
+ test("end icon should be hidden from Screen Readers", () => {
32
+ // Arrange
33
+ render(
34
+ <Button testId={"button-focus-test"} endIcon={plus}>
35
+ Label
36
+ </Button>,
37
+ );
38
+
39
+ // Act
40
+ const icon = screen.getByTestId("button-focus-test-end-icon");
41
+
42
+ // Assert
43
+ expect(icon).toHaveAttribute("aria-hidden", "true");
44
+ });
45
+
46
+ /**
47
+ * Primary button
48
+ */
49
+
50
+ test("icon is displayed when button contains startIcon", () => {
51
+ // Arrange
52
+ render(
53
+ <Button testId={"button-focus-test"} startIcon={plus}>
54
+ Label
55
+ </Button>,
56
+ );
57
+
58
+ // Act
59
+ const icon = screen.getByTestId("button-focus-test-start-icon");
60
+
61
+ // Assert
62
+ expect(icon).toBeInTheDocument();
63
+ expect(icon).toHaveAttribute("aria-hidden", "true");
64
+ });
65
+
66
+ test("icon is displayed when button contains endIcon", () => {
67
+ // Arrange
68
+ render(
69
+ <Button testId={"button-focus-test"} endIcon={plus}>
70
+ Label
71
+ </Button>,
72
+ );
73
+
74
+ // Act
75
+ const icon = screen.getByTestId("button-focus-test-end-icon");
76
+
77
+ // Assert
78
+ expect(icon).toBeInTheDocument();
79
+ expect(icon).toHaveAttribute("aria-hidden", "true");
80
+ });
81
+
82
+ test("both icons are displayed when button contains startIcon and endIcon", () => {
83
+ // Arrange
84
+ render(
85
+ <Button
86
+ testId={"button-focus-test"}
87
+ startIcon={plus}
88
+ endIcon={plus}
89
+ >
90
+ Label
91
+ </Button>,
92
+ );
93
+
94
+ // Act
95
+ const startIcon = screen.getByTestId("button-focus-test-start-icon");
96
+ const endIcon = screen.getByTestId("button-focus-test-end-icon");
97
+
98
+ // Assert
99
+ expect(startIcon).toBeInTheDocument();
100
+ expect(endIcon).toBeInTheDocument();
101
+ });
102
+
103
+ /**
104
+ * Secondary button
105
+ */
106
+
107
+ test("icon is displayed when secondary button contains startIcon", () => {
108
+ // Arrange
109
+ render(
110
+ <Button
111
+ kind="secondary"
112
+ testId={"button-icon-test"}
113
+ startIcon={plus}
114
+ >
115
+ Label
116
+ </Button>,
117
+ );
118
+
119
+ // Act
120
+ const icon = screen.getByTestId("button-icon-test-start-icon");
121
+
122
+ // Assert
123
+ expect(icon).toBeInTheDocument();
124
+ expect(icon).toHaveAttribute("aria-hidden", "true");
125
+ });
126
+
127
+ test("icon is displayed when secondary button contains endIcon", () => {
128
+ // Arrange
129
+ render(
130
+ <Button kind="secondary" testId={"button-icon-test"} endIcon={plus}>
131
+ Label
132
+ </Button>,
133
+ );
134
+
135
+ // Act
136
+ const icon = screen.getByTestId("button-icon-test-end-icon");
137
+
138
+ // Assert
139
+ expect(icon).toBeInTheDocument();
140
+ expect(icon).toHaveAttribute("aria-hidden", "true");
141
+ });
142
+
143
+ test("default theme secondary button icon has no hover style", () => {
144
+ // Arrange
145
+ render(
146
+ <Button kind="secondary" testId={"button-icon-test"} endIcon={plus}>
147
+ Label
148
+ </Button>,
149
+ );
150
+
151
+ // Act
152
+ const button = screen.getByTestId("button-icon-test");
153
+ const iconWrapper = screen.getByTestId(
154
+ "button-icon-test-end-icon-wrapper",
155
+ );
156
+ userEvent.hover(button);
157
+
158
+ // Assert
159
+ expect(iconWrapper).toHaveStyle(`backgroundColor: transparent`);
160
+ });
161
+
162
+ test("Khanmigo secondary button icon has hover style", () => {
163
+ // Arrange
164
+ render(
165
+ <ThemeSwitcherContext.Provider value="khanmigo">
166
+ <Button
167
+ kind="secondary"
168
+ testId={"button-icon-test"}
169
+ endIcon={plus}
170
+ >
171
+ Label
172
+ </Button>
173
+ </ThemeSwitcherContext.Provider>,
174
+ );
175
+
176
+ // Act
177
+ const button = screen.getByTestId("button-icon-test");
178
+ const iconWrapper = screen.getByTestId(
179
+ "button-icon-test-end-icon-wrapper",
180
+ );
181
+ userEvent.hover(button);
182
+
183
+ // Assert
184
+ expect(iconWrapper).toHaveStyle(
185
+ `backgroundColor: ${tokens.color.fadedBlue16}`,
186
+ );
187
+ });
188
+
189
+ /**
190
+ * Tertiary button
191
+ */
192
+
193
+ test("icon is displayed when tertiary button contains startIcon", () => {
194
+ // Arrange
195
+ render(
196
+ <Button
197
+ kind="tertiary"
198
+ testId={"button-focus-test"}
199
+ startIcon={plus}
200
+ >
201
+ Label
202
+ </Button>,
203
+ );
204
+
205
+ // Act
206
+ const icon = screen.getByTestId("button-focus-test-start-icon");
207
+
208
+ // Assert
209
+ expect(icon).toBeInTheDocument();
210
+ expect(icon).toHaveAttribute("aria-hidden", "true");
211
+ });
212
+
213
+ test("icon is displayed when tertiary button contains endIcon", () => {
214
+ // Arrange
215
+ render(
216
+ <Button kind="tertiary" testId={"button-focus-test"} endIcon={plus}>
217
+ Label
218
+ </Button>,
219
+ );
220
+
221
+ // Act
222
+ const icon = screen.getByTestId("button-focus-test-end-icon");
223
+
224
+ // Assert
225
+ expect(icon).toBeInTheDocument();
226
+ expect(icon).toHaveAttribute("aria-hidden", "true");
227
+ });
228
+
229
+ test("default theme tertiary button icon has no hover style", () => {
230
+ // Arrange
231
+ render(
232
+ <Button kind="tertiary" testId={"button-icon-test"} endIcon={plus}>
233
+ Label
234
+ </Button>,
235
+ );
236
+
237
+ // Act
238
+ const button = screen.getByTestId("button-icon-test");
239
+ const iconWrapper = screen.getByTestId(
240
+ "button-icon-test-end-icon-wrapper",
241
+ );
242
+ userEvent.hover(button);
243
+
244
+ // Assert
245
+ expect(iconWrapper).toHaveStyle(`backgroundColor: transparent`);
246
+ });
247
+
248
+ test("Khanmigo tertiary button icon has hover style", () => {
249
+ // Arrange
250
+ render(
251
+ <ThemeSwitcherContext.Provider value="khanmigo">
252
+ <Button
253
+ kind="tertiary"
254
+ testId={"button-icon-test"}
255
+ endIcon={plus}
256
+ >
257
+ Label
258
+ </Button>
259
+ </ThemeSwitcherContext.Provider>,
260
+ );
261
+
262
+ // Act
263
+ const button = screen.getByTestId("button-icon-test");
264
+ const iconWrapper = screen.getByTestId(
265
+ "button-icon-test-end-icon-wrapper",
266
+ );
267
+ userEvent.hover(button);
268
+
269
+ // Assert
270
+ expect(iconWrapper).toHaveStyle(
271
+ `backgroundColor: ${tokens.color.fadedBlue16}`,
272
+ );
273
+ });
274
+ });
@@ -1,8 +1,13 @@
1
+ /**
2
+ * Test for Wonder Blocks Button component.
3
+ *
4
+ * The test for buttons with icons are in a separate file
5
+ * (button-with-icon.test.tsx) since this one is already too long.
6
+ */
1
7
  import * as React from "react";
2
8
  import {MemoryRouter, Route, Switch} from "react-router-dom";
3
9
  import {render, screen, waitFor} from "@testing-library/react";
4
10
  import userEvent from "@testing-library/user-event";
5
- import plus from "@phosphor-icons/core/regular/plus.svg";
6
11
 
7
12
  import Button from "../button";
8
13
 
@@ -828,91 +833,4 @@ describe("Button", () => {
828
833
  }).not.toThrow();
829
834
  });
830
835
  });
831
-
832
- describe("button with icon", () => {
833
- test("icon is displayed when button contains startIcon", () => {
834
- // Arrange
835
- render(
836
- <Button testId={"button-focus-test"} startIcon={plus}>
837
- Label
838
- </Button>,
839
- );
840
-
841
- // Act
842
- const icon = screen.getByTestId("button-focus-test-start-icon");
843
-
844
- // Assert
845
- expect(icon).toBeInTheDocument();
846
- expect(icon).toHaveAttribute("aria-hidden", "true");
847
- });
848
-
849
- test("icon is displayed when button contains endIcon", () => {
850
- // Arrange
851
- render(
852
- <Button testId={"button-focus-test"} endIcon={plus}>
853
- Label
854
- </Button>,
855
- );
856
-
857
- // Act
858
- const icon = screen.getByTestId("button-focus-test-end-icon");
859
-
860
- // Assert
861
- expect(icon).toBeInTheDocument();
862
- expect(icon).toHaveAttribute("aria-hidden", "true");
863
- });
864
-
865
- test("both icons are displayed when button contains startIcon and endIcon", () => {
866
- // Arrange
867
- render(
868
- <Button
869
- testId={"button-focus-test"}
870
- startIcon={plus}
871
- endIcon={plus}
872
- >
873
- Label
874
- </Button>,
875
- );
876
-
877
- // Act
878
- const startIcon = screen.getByTestId(
879
- "button-focus-test-start-icon",
880
- );
881
- const endIcon = screen.getByTestId("button-focus-test-end-icon");
882
-
883
- // Assert
884
- expect(startIcon).toBeInTheDocument();
885
- expect(endIcon).toBeInTheDocument();
886
- });
887
-
888
- test("start icon should be hidden from Screen Readers", () => {
889
- // Arrange
890
- render(
891
- <Button testId={"button-focus-test"} startIcon={plus}>
892
- Label
893
- </Button>,
894
- );
895
-
896
- // Act
897
- const icon = screen.getByTestId("button-focus-test-start-icon");
898
-
899
- // Assert
900
- expect(icon).toHaveAttribute("aria-hidden", "true");
901
- });
902
-
903
- test("end icon should be hidden from Screen Readers", () => {
904
- // Arrange
905
- render(
906
- <Button testId={"button-focus-test"} endIcon={plus}>
907
- Label
908
- </Button>,
909
- );
910
-
911
- // Act
912
- const icon = screen.getByTestId("button-focus-test-end-icon");
913
-
914
- // Assert
915
- expect(icon).toHaveAttribute("aria-hidden", "true");
916
- });
917
- });
918
836
  });
@@ -4,7 +4,7 @@ import {Link} from "react-router-dom";
4
4
  import {__RouterContext} from "react-router";
5
5
 
6
6
  import {LabelLarge, LabelSmall} from "@khanacademy/wonder-blocks-typography";
7
- import {addStyle} from "@khanacademy/wonder-blocks-core";
7
+ import {addStyle, View} from "@khanacademy/wonder-blocks-core";
8
8
  import {CircularSpinner} from "@khanacademy/wonder-blocks-progress-spinner";
9
9
  import {isClientSideUrl} from "@khanacademy/wonder-blocks-clickable";
10
10
  import {
@@ -138,12 +138,24 @@ const ButtonCore: React.ForwardRefExoticComponent<
138
138
  const contents = (
139
139
  <React.Fragment>
140
140
  {startIcon && (
141
- <ButtonIcon
142
- size={iconSize}
143
- icon={startIcon}
144
- style={sharedStyles.startIcon}
145
- testId={testId ? `${testId}-start-icon` : undefined}
146
- />
141
+ <View
142
+ // The start icon doesn't have the circle around it
143
+ // in the Khanmigo theme, but we wrap it with
144
+ // iconWrapper anyway to give it the same spacing
145
+ // as the end icon so the button is symmetrical.
146
+ style={sharedStyles.iconWrapper}
147
+ >
148
+ <ButtonIcon
149
+ size={iconSize}
150
+ icon={startIcon}
151
+ style={[
152
+ sharedStyles.startIcon,
153
+ kind === "tertiary" &&
154
+ sharedStyles.tertiaryStartIcon,
155
+ ]}
156
+ testId={testId ? `${testId}-start-icon` : undefined}
157
+ />
158
+ </View>
147
159
  )}
148
160
  {label}
149
161
  {spinner && (
@@ -155,12 +167,27 @@ const ButtonCore: React.ForwardRefExoticComponent<
155
167
  />
156
168
  )}
157
169
  {endIcon && (
158
- <ButtonIcon
159
- size={iconSize}
160
- icon={endIcon}
161
- style={sharedStyles.endIcon}
162
- testId={testId ? `${testId}-end-icon` : undefined}
163
- />
170
+ <View
171
+ testId={
172
+ testId ? `${testId}-end-icon-wrapper` : undefined
173
+ }
174
+ style={[
175
+ styles.endIcon,
176
+ sharedStyles.iconWrapper,
177
+ sharedStyles.endIconWrapper,
178
+ kind === "tertiary" &&
179
+ sharedStyles.endIconWrapperTertiary,
180
+ (focused || hovered) &&
181
+ kind !== "primary" &&
182
+ sharedStyles.iconWrapperSecondaryHovered,
183
+ ]}
184
+ >
185
+ <ButtonIcon
186
+ size={iconSize}
187
+ icon={endIcon}
188
+ testId={testId ? `${testId}-end-icon` : undefined}
189
+ />
190
+ </View>
164
191
  )}
165
192
  </React.Fragment>
166
193
  );
@@ -232,10 +259,6 @@ const themedSharedStyles: ThemedStylesFn<ButtonThemeContract> = (theme) => ({
232
259
  WebkitTapHighlightColor: "rgba(0,0,0,0)",
233
260
  },
234
261
  },
235
- withIcon: {
236
- // The left padding for the button with icon should have 4px less padding
237
- paddingLeft: theme.padding.medium,
238
- },
239
262
  disabled: {
240
263
  cursor: "auto",
241
264
  },
@@ -258,7 +281,7 @@ const themedSharedStyles: ThemedStylesFn<ButtonThemeContract> = (theme) => ({
258
281
  },
259
282
  largeText: {
260
283
  fontSize: theme.font.size.large,
261
- lineHeight: theme.font.lineHeight.large,
284
+ lineHeight: `${theme.font.lineHeight.large}px`,
262
285
  },
263
286
  textWithFocus: {
264
287
  position: "relative", // allows the tertiary button border to use the label width
@@ -270,16 +293,42 @@ const themedSharedStyles: ThemedStylesFn<ButtonThemeContract> = (theme) => ({
270
293
  position: "absolute",
271
294
  },
272
295
  startIcon: {
273
- marginInlineEnd: theme.padding.small,
296
+ marginRight: theme.padding.small,
297
+ marginLeft: theme.margin.icon.offset,
298
+ },
299
+ tertiaryStartIcon: {
300
+ // Undo the negative padding from startIcon since tertiary
301
+ // buttons don't have extra padding.
302
+ marginLeft: 0,
274
303
  },
275
304
  endIcon: {
276
- marginInlineStart: theme.padding.small,
305
+ marginLeft: theme.padding.small,
306
+ },
307
+ iconWrapper: {
308
+ borderRadius: theme.border.radius.icon,
309
+ padding: theme.padding.xsmall,
310
+ // View has a default minWidth of 0, which causes the label text
311
+ // to encroach on the icon when it needs to truncate. We can fix
312
+ // this by setting the minWidth to auto.
313
+ minWidth: "auto",
314
+ },
315
+ iconWrapperSecondaryHovered: {
316
+ backgroundColor: theme.color.bg.icon.secondaryHover,
317
+ color: theme.color.text.icon.secondaryHover,
318
+ },
319
+ endIconWrapper: {
320
+ marginLeft: theme.padding.small,
321
+ marginRight: theme.margin.icon.offset,
322
+ },
323
+ endIconWrapperTertiary: {
324
+ marginRight: 0,
277
325
  },
278
326
  });
279
327
 
280
328
  const styles: Record<string, any> = {};
281
329
 
282
- const _generateStyles = (
330
+ // export for testing only
331
+ export const _generateStyles = (
283
332
  buttonColor = "default",
284
333
  kind: "primary" | "secondary" | "tertiary",
285
334
  light: boolean,
@@ -42,6 +42,13 @@ const theme = {
42
42
  tertiary: {
43
43
  hover: tokens.color.white,
44
44
  },
45
+
46
+ /**
47
+ * Icons
48
+ */
49
+ icon: {
50
+ secondaryHover: "transparent",
51
+ },
45
52
  },
46
53
  text: {
47
54
  /**
@@ -51,6 +58,7 @@ const theme = {
51
58
  disabled: tokens.color.offBlack32,
52
59
  // kind="primary", light=false | kind="secondary, tertiary", light=true
53
60
  inverse: tokens.color.white,
61
+
54
62
  /**
55
63
  * Kind
56
64
  */
@@ -60,6 +68,13 @@ const theme = {
60
68
  secondary: {
61
69
  inverse: tokens.color.white50,
62
70
  },
71
+
72
+ /**
73
+ * Icons
74
+ */
75
+ icon: {
76
+ secondaryHover: tokens.color.blue,
77
+ },
63
78
  },
64
79
  border: {
65
80
  /**
@@ -101,6 +116,11 @@ const theme = {
101
116
  small: tokens.border.radius.medium_4,
102
117
  // large button
103
118
  large: tokens.border.radius.large_6,
119
+
120
+ /**
121
+ * Icons
122
+ */
123
+ icon: tokens.border.radius.full,
104
124
  },
105
125
  },
106
126
  size: {
@@ -112,8 +132,14 @@ const theme = {
112
132
  large: 56,
113
133
  },
114
134
  },
135
+ margin: {
136
+ icon: {
137
+ offset: -tokens.spacing.xxxxSmall_2,
138
+ },
139
+ },
115
140
  padding: {
116
- small: tokens.spacing.xSmall_8,
141
+ xsmall: tokens.spacing.xxxxSmall_2,
142
+ small: tokens.spacing.xxSmall_6,
117
143
  medium: tokens.spacing.small_12,
118
144
  large: tokens.spacing.medium_16,
119
145
  xLarge: tokens.spacing.xLarge_32,
@@ -15,6 +15,9 @@ const theme = mergeTheme(defaultTheme, {
15
15
  },
16
16
  focus: tokens.color.offWhite,
17
17
  },
18
+ icon: {
19
+ secondaryHover: tokens.color.fadedBlue16,
20
+ },
18
21
  },
19
22
  border: {
20
23
  secondary: {
@@ -22,6 +25,11 @@ const theme = mergeTheme(defaultTheme, {
22
25
  critical: tokens.color.fadedRed,
23
26
  },
24
27
  },
28
+ text: {
29
+ icon: {
30
+ secondaryHover: tokens.color.blue,
31
+ },
32
+ },
25
33
  },
26
34
  border: {
27
35
  radius: {
@@ -33,6 +41,12 @@ const theme = mergeTheme(defaultTheme, {
33
41
  focused: tokens.border.width.hairline,
34
42
  },
35
43
  },
44
+ margin: {
45
+ icon: {
46
+ // Bring the icons closer to the edges of the button.
47
+ offset: -tokens.spacing.xSmall_8,
48
+ },
49
+ },
36
50
  font: {
37
51
  weight: {
38
52
  default: tokens.font.weight.regular,
@@ -1,6 +1,7 @@
1
1
  import * as React from "react";
2
2
  import {
3
3
  createThemeContext,
4
+ Themes,
4
5
  ThemeSwitcherContext,
5
6
  } from "@khanacademy/wonder-blocks-theming";
6
7
 
@@ -11,16 +12,16 @@ type Props = {
11
12
  children: React.ReactNode;
12
13
  };
13
14
 
15
+ export type ButtonThemeContract = typeof defaultTheme;
16
+
14
17
  /**
15
18
  * The themes available to the Button component.
16
19
  */
17
- const themes = {
20
+ const themes: Themes<ButtonThemeContract> = {
18
21
  default: defaultTheme,
19
22
  khanmigo: khanmigoTheme,
20
23
  };
21
24
 
22
- export type ButtonThemeContract = typeof defaultTheme;
23
-
24
25
  /**
25
26
  * The context that provides the theme to the Button component.
26
27
  * This is generally consumed via the `useScopedTheme` hook.
@@ -33,7 +34,7 @@ export const ButtonThemeContext = createThemeContext(defaultTheme);
33
34
  export default function ThemedButton(props: Props) {
34
35
  const currentTheme = React.useContext(ThemeSwitcherContext);
35
36
 
36
- const theme = themes[currentTheme as keyof typeof themes] || defaultTheme;
37
+ const theme = themes[currentTheme] || defaultTheme;
37
38
  return (
38
39
  <ButtonThemeContext.Provider value={theme}>
39
40
  {props.children}