@khanacademy/wonder-blocks-button 6.0.1 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 {
@@ -47,6 +47,7 @@ const ButtonCore: React.ForwardRefExoticComponent<
47
47
  hovered,
48
48
  href = undefined,
49
49
  kind = "primary",
50
+ labelStyle,
50
51
  light = false,
51
52
  pressed,
52
53
  size = "medium",
@@ -110,6 +111,7 @@ const ButtonCore: React.ForwardRefExoticComponent<
110
111
  style={[
111
112
  sharedStyles.text,
112
113
  size === "large" && sharedStyles.largeText,
114
+ labelStyle,
113
115
  spinner && sharedStyles.hiddenText,
114
116
  kind === "tertiary" && sharedStyles.textWithFocus,
115
117
  // apply press/hover effects on the label
@@ -138,12 +140,24 @@ const ButtonCore: React.ForwardRefExoticComponent<
138
140
  const contents = (
139
141
  <React.Fragment>
140
142
  {startIcon && (
141
- <ButtonIcon
142
- size={iconSize}
143
- icon={startIcon}
144
- style={sharedStyles.startIcon}
145
- testId={testId ? `${testId}-start-icon` : undefined}
146
- />
143
+ <View
144
+ // The start icon doesn't have the circle around it
145
+ // in the Khanmigo theme, but we wrap it with
146
+ // iconWrapper anyway to give it the same spacing
147
+ // as the end icon so the button is symmetrical.
148
+ style={sharedStyles.iconWrapper}
149
+ >
150
+ <ButtonIcon
151
+ size={iconSize}
152
+ icon={startIcon}
153
+ style={[
154
+ sharedStyles.startIcon,
155
+ kind === "tertiary" &&
156
+ sharedStyles.tertiaryStartIcon,
157
+ ]}
158
+ testId={testId ? `${testId}-start-icon` : undefined}
159
+ />
160
+ </View>
147
161
  )}
148
162
  {label}
149
163
  {spinner && (
@@ -155,12 +169,27 @@ const ButtonCore: React.ForwardRefExoticComponent<
155
169
  />
156
170
  )}
157
171
  {endIcon && (
158
- <ButtonIcon
159
- size={iconSize}
160
- icon={endIcon}
161
- style={sharedStyles.endIcon}
162
- testId={testId ? `${testId}-end-icon` : undefined}
163
- />
172
+ <View
173
+ testId={
174
+ testId ? `${testId}-end-icon-wrapper` : undefined
175
+ }
176
+ style={[
177
+ styles.endIcon,
178
+ sharedStyles.iconWrapper,
179
+ sharedStyles.endIconWrapper,
180
+ kind === "tertiary" &&
181
+ sharedStyles.endIconWrapperTertiary,
182
+ (focused || hovered) &&
183
+ kind !== "primary" &&
184
+ sharedStyles.iconWrapperSecondaryHovered,
185
+ ]}
186
+ >
187
+ <ButtonIcon
188
+ size={iconSize}
189
+ icon={endIcon}
190
+ testId={testId ? `${testId}-end-icon` : undefined}
191
+ />
192
+ </View>
164
193
  )}
165
194
  </React.Fragment>
166
195
  );
@@ -232,10 +261,6 @@ const themedSharedStyles: ThemedStylesFn<ButtonThemeContract> = (theme) => ({
232
261
  WebkitTapHighlightColor: "rgba(0,0,0,0)",
233
262
  },
234
263
  },
235
- withIcon: {
236
- // The left padding for the button with icon should have 4px less padding
237
- paddingLeft: theme.padding.medium,
238
- },
239
264
  disabled: {
240
265
  cursor: "auto",
241
266
  },
@@ -258,7 +283,7 @@ const themedSharedStyles: ThemedStylesFn<ButtonThemeContract> = (theme) => ({
258
283
  },
259
284
  largeText: {
260
285
  fontSize: theme.font.size.large,
261
- lineHeight: theme.font.lineHeight.large,
286
+ lineHeight: `${theme.font.lineHeight.large}px`,
262
287
  },
263
288
  textWithFocus: {
264
289
  position: "relative", // allows the tertiary button border to use the label width
@@ -270,16 +295,42 @@ const themedSharedStyles: ThemedStylesFn<ButtonThemeContract> = (theme) => ({
270
295
  position: "absolute",
271
296
  },
272
297
  startIcon: {
273
- marginInlineEnd: theme.padding.small,
298
+ marginRight: theme.padding.small,
299
+ marginLeft: theme.margin.icon.offset,
300
+ },
301
+ tertiaryStartIcon: {
302
+ // Undo the negative padding from startIcon since tertiary
303
+ // buttons don't have extra padding.
304
+ marginLeft: 0,
274
305
  },
275
306
  endIcon: {
276
- marginInlineStart: theme.padding.small,
307
+ marginLeft: theme.padding.small,
308
+ },
309
+ iconWrapper: {
310
+ borderRadius: theme.border.radius.icon,
311
+ padding: theme.padding.xsmall,
312
+ // View has a default minWidth of 0, which causes the label text
313
+ // to encroach on the icon when it needs to truncate. We can fix
314
+ // this by setting the minWidth to auto.
315
+ minWidth: "auto",
316
+ },
317
+ iconWrapperSecondaryHovered: {
318
+ backgroundColor: theme.color.bg.icon.secondaryHover,
319
+ color: theme.color.text.icon.secondaryHover,
320
+ },
321
+ endIconWrapper: {
322
+ marginLeft: theme.padding.small,
323
+ marginRight: theme.margin.icon.offset,
324
+ },
325
+ endIconWrapperTertiary: {
326
+ marginRight: 0,
277
327
  },
278
328
  });
279
329
 
280
330
  const styles: Record<string, any> = {};
281
331
 
282
- const _generateStyles = (
332
+ // export for testing only
333
+ export const _generateStyles = (
283
334
  buttonColor = "default",
284
335
  kind: "primary" | "secondary" | "tertiary",
285
336
  light: boolean,
@@ -356,7 +407,6 @@ const _generateStyles = (
356
407
  },
357
408
  };
358
409
  } else if (kind === "secondary") {
359
- const horizontalPadding = padding - (theme.border.width.focused - 1);
360
410
  const secondaryBorderColor =
361
411
  buttonColor === "destructive"
362
412
  ? theme.color.border.secondary.critical
@@ -372,11 +422,12 @@ const _generateStyles = (
372
422
  ? theme.color.bg.secondary.inverse
373
423
  : theme.color.bg.secondary.default,
374
424
  color: light ? theme.color.text.inverse : color,
375
- borderColor: light
425
+ outlineColor: light
376
426
  ? theme.color.border.secondary.inverse
377
427
  : secondaryBorderColor,
378
- borderStyle: "solid",
379
- borderWidth: theme.border.width.secondary,
428
+ outlineStyle: "solid",
429
+ outlineWidth: theme.border.width.secondary,
430
+ outlineOffset: 1,
380
431
  paddingLeft: padding,
381
432
  paddingRight: padding,
382
433
  },
@@ -384,37 +435,29 @@ const _generateStyles = (
384
435
  background: light
385
436
  ? theme.color.bg.secondary.inverse
386
437
  : theme.color.bg.secondary.focus,
387
- borderColor: light ? theme.color.border.primary.inverse : color,
388
- borderWidth: theme.border.width.focused,
389
- paddingLeft: horizontalPadding,
390
- paddingRight: horizontalPadding,
438
+ outlineColor: light
439
+ ? theme.color.border.primary.inverse
440
+ : color,
441
+ outlineWidth: theme.border.width.focused,
391
442
  },
392
443
 
393
444
  active: {
394
445
  background: light ? activeColor : secondaryActiveColor,
395
446
  color: light ? fadedColor : activeColor,
396
- borderColor: light ? fadedColor : activeColor,
397
- borderWidth: theme.border.width.focused,
398
- // We need to reduce padding to offset the difference
399
- // caused by the border becoming thicker on focus.
400
- paddingLeft: horizontalPadding,
401
- paddingRight: horizontalPadding,
447
+ outlineColor: light ? fadedColor : activeColor,
448
+ outlineWidth: theme.border.width.focused,
402
449
  },
403
450
  disabled: {
404
451
  color: light
405
452
  ? theme.color.text.secondary.inverse
406
453
  : theme.color.text.disabled,
407
- borderColor: light ? fadedColor : theme.color.border.disabled,
454
+ outlineColor: light ? fadedColor : theme.color.border.disabled,
408
455
  cursor: "default",
409
456
  ":focus": {
410
- borderColor: light
457
+ outlineColor: light
411
458
  ? theme.color.border.secondary.inverse
412
459
  : theme.color.border.disabled,
413
- borderWidth: theme.border.width.disabled,
414
- // We need to reduce padding to offset the difference
415
- // caused by the border becoming thicker on focus.
416
- paddingLeft: padding - 1,
417
- paddingRight: padding - 1,
460
+ outlineWidth: theme.border.width.disabled,
418
461
  },
419
462
  },
420
463
  };
@@ -439,25 +482,12 @@ const _generateStyles = (
439
482
  },
440
483
  },
441
484
  focus: {
442
- ":after": {
443
- content: "''",
444
- // Since we are using a pseudo element, we need to manually
445
- // calculate the width/height and use absolute position to
446
- // prevent other elements from being shifted around.
447
- position: "absolute",
448
- // Keeps the button at the same size when applying the
449
- // borderWidth property, so we can apply the correct value
450
- // per theme for each side (left and right).
451
- width: `calc(100% + ${theme.border.width.focused * 2}px)`,
452
- // Same as above, but for the height (top and bottom).
453
- height: `calc(100% - ${theme.border.width.focused * 2}px)`,
454
- borderStyle: "solid",
455
- borderColor: light
456
- ? theme.color.border.tertiary.inverse
457
- : color,
458
- borderWidth: theme.border.width.focused,
459
- borderRadius: theme.border.radius.default,
460
- },
485
+ outlineStyle: "solid",
486
+ outlineColor: light
487
+ ? theme.color.border.tertiary.inverse
488
+ : color,
489
+ outlineWidth: theme.border.width.focused,
490
+ borderRadius: theme.border.radius.default,
461
491
  },
462
492
  active: {
463
493
  color: light ? fadedColor : activeColor,
@@ -471,11 +501,9 @@ const _generateStyles = (
471
501
  cursor: "default",
472
502
  },
473
503
  disabledFocus: {
474
- ":after": {
475
- borderColor: light
476
- ? theme.color.border.tertiary.inverse
477
- : theme.color.border.disabled,
478
- },
504
+ outlineColor: light
505
+ ? theme.color.border.tertiary.inverse
506
+ : theme.color.border.disabled,
479
507
  },
480
508
  };
481
509
  } else {
@@ -110,6 +110,10 @@ export type SharedProps =
110
110
  * page reload.
111
111
  */
112
112
  skipClientNav?: boolean;
113
+ /**
114
+ * Optional custom styles for the inner label.
115
+ */
116
+ labelStyle?: StyleType;
113
117
  /**
114
118
  * Optional custom styles.
115
119
  */