@khanacademy/wonder-blocks-button 4.0.13 → 4.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.
@@ -2,6 +2,7 @@ import * as React from "react";
2
2
  import {MemoryRouter, Route, Switch} from "react-router-dom";
3
3
  import {render, screen, waitFor} from "@testing-library/react";
4
4
  import userEvent from "@testing-library/user-event";
5
+ import {icons} from "@khanacademy/wonder-blocks-icon";
5
6
 
6
7
  import Button from "../button";
7
8
 
@@ -827,4 +828,22 @@ describe("Button", () => {
827
828
  }).not.toThrow();
828
829
  });
829
830
  });
831
+
832
+ describe("button with icon", () => {
833
+ test("icon is displayed when button contains icon", () => {
834
+ // Arrange
835
+ render(
836
+ <Button testId={"button-focus-test"} icon={icons.add}>
837
+ Label
838
+ </Button>,
839
+ );
840
+
841
+ // Act
842
+ const icon = screen.getByTestId("button-focus-test-icon");
843
+
844
+ // Assert
845
+ expect(icon).toBeDefined();
846
+ expect(icon).toHaveAttribute("aria-hidden", "true");
847
+ });
848
+ });
830
849
  });
@@ -27,8 +27,14 @@ const StyledAnchor = addStyle("a");
27
27
  const StyledButton = addStyle("button");
28
28
  const StyledLink = addStyle(Link);
29
29
 
30
- export default class ButtonCore extends React.Component<Props> {
31
- renderInner(router: any): React.ReactNode {
30
+ const ButtonCore: React.ForwardRefExoticComponent<
31
+ Props &
32
+ React.RefAttributes<typeof Link | HTMLButtonElement | HTMLAnchorElement>
33
+ > = React.forwardRef<
34
+ typeof Link | HTMLButtonElement | HTMLAnchorElement,
35
+ Props
36
+ >((props: Props, ref) => {
37
+ const renderInner = (router: any): React.ReactNode => {
32
38
  const {
33
39
  children,
34
40
  skipClientNav,
@@ -37,10 +43,10 @@ export default class ButtonCore extends React.Component<Props> {
37
43
  focused,
38
44
  hovered,
39
45
  href = undefined,
40
- kind,
41
- light,
46
+ kind = "primary",
47
+ light = false,
42
48
  pressed,
43
- size,
49
+ size = "medium",
44
50
  style,
45
51
  testId,
46
52
  type = undefined,
@@ -49,7 +55,7 @@ export default class ButtonCore extends React.Component<Props> {
49
55
  id,
50
56
  waiting: _,
51
57
  ...restProps
52
- } = this.props;
58
+ } = props;
53
59
 
54
60
  const buttonColor =
55
61
  color === "destructive"
@@ -79,6 +85,12 @@ export default class ButtonCore extends React.Component<Props> {
79
85
  (pressed
80
86
  ? buttonStyles.active
81
87
  : (hovered || focused) && buttonStyles.focus),
88
+ kind === "tertiary" &&
89
+ !pressed &&
90
+ focused && [
91
+ buttonStyles.focus,
92
+ disabled && buttonStyles.disabledFocus,
93
+ ],
82
94
  size === "small" && sharedStyles.small,
83
95
  size === "large" && sharedStyles.large,
84
96
  ];
@@ -102,31 +114,17 @@ export default class ButtonCore extends React.Component<Props> {
102
114
  style={[
103
115
  sharedStyles.text,
104
116
  size === "large" && sharedStyles.largeText,
105
- icon && sharedStyles.textWithIcon,
106
117
  spinner && sharedStyles.hiddenText,
107
118
  kind === "tertiary" && sharedStyles.textWithFocus,
108
- // apply focus effect on the label instead
119
+ // apply press/hover effects on the label
109
120
  kind === "tertiary" &&
110
121
  !disabled &&
111
122
  (pressed
112
- ? buttonStyles.active
113
- : (hovered || focused) && buttonStyles.focus),
114
- kind === "tertiary" &&
115
- disabled &&
116
- focused && [
117
- buttonStyles.focus,
118
- buttonStyles.disabledFocus,
119
- ],
123
+ ? [buttonStyles.hover, buttonStyles.active]
124
+ : hovered && buttonStyles.hover),
120
125
  ]}
126
+ testId={testId ? `${testId}-inner-label` : undefined}
121
127
  >
122
- {icon && (
123
- <Icon
124
- size={iconSize}
125
- color="currentColor"
126
- icon={icon}
127
- style={sharedStyles.icon}
128
- />
129
- )}
130
128
  {children}
131
129
  </Label>
132
130
  );
@@ -139,6 +137,16 @@ export default class ButtonCore extends React.Component<Props> {
139
137
 
140
138
  const contents = (
141
139
  <React.Fragment>
140
+ {icon && (
141
+ <Icon
142
+ size={iconSize}
143
+ color="currentColor"
144
+ icon={icon}
145
+ style={sharedStyles.icon}
146
+ aria-hidden="true"
147
+ testId={testId ? `${testId}-icon` : undefined}
148
+ />
149
+ )}
142
150
  {label}
143
151
  {spinner && (
144
152
  <CircularSpinner
@@ -153,11 +161,19 @@ export default class ButtonCore extends React.Component<Props> {
153
161
 
154
162
  if (href && !disabled) {
155
163
  return router && !skipClientNav && isClientSideUrl(href) ? (
156
- <StyledLink {...commonProps} to={href}>
164
+ <StyledLink
165
+ {...commonProps}
166
+ to={href}
167
+ ref={ref as React.Ref<typeof Link>}
168
+ >
157
169
  {contents}
158
170
  </StyledLink>
159
171
  ) : (
160
- <StyledAnchor {...commonProps} href={href}>
172
+ <StyledAnchor
173
+ {...commonProps}
174
+ href={href}
175
+ ref={ref as React.Ref<HTMLAnchorElement>}
176
+ >
161
177
  {contents}
162
178
  </StyledAnchor>
163
179
  );
@@ -167,21 +183,22 @@ export default class ButtonCore extends React.Component<Props> {
167
183
  type={type || "button"}
168
184
  {...commonProps}
169
185
  aria-disabled={disabled}
186
+ ref={ref as React.Ref<HTMLButtonElement>}
170
187
  >
171
188
  {contents}
172
189
  </StyledButton>
173
190
  );
174
191
  }
175
- }
192
+ };
176
193
 
177
- render(): React.ReactNode {
178
- return (
179
- <__RouterContext.Consumer>
180
- {(router) => this.renderInner(router)}
181
- </__RouterContext.Consumer>
182
- );
183
- }
184
- }
194
+ return (
195
+ <__RouterContext.Consumer>
196
+ {(router) => renderInner(router)}
197
+ </__RouterContext.Consumer>
198
+ );
199
+ });
200
+
201
+ export default ButtonCore;
185
202
 
186
203
  const sharedStyles = StyleSheet.create({
187
204
  shared: {
@@ -236,9 +253,6 @@ const sharedStyles = StyleSheet.create({
236
253
  fontSize: 18,
237
254
  lineHeight: "20px",
238
255
  },
239
- textWithIcon: {
240
- display: "flex", // allows the text and icon to sit nicely together
241
- },
242
256
  textWithFocus: {
243
257
  position: "relative", // allows the tertiary button border to use the label width
244
258
  },
@@ -317,16 +331,14 @@ const _generateStyles = (
317
331
  borderColor: light ? white50 : offBlack50,
318
332
  borderStyle: "solid",
319
333
  borderWidth: 1,
320
- paddingLeft: iconWidth ? padding - 4 : padding,
334
+ paddingLeft: padding,
321
335
  paddingRight: padding,
322
336
  },
323
337
  focus: {
324
338
  background: light ? "transparent" : white,
325
339
  borderColor: light ? white : color,
326
340
  borderWidth: 2,
327
- // The left padding for the button with icon should have 4px
328
- // less padding
329
- paddingLeft: iconWidth ? padding - 5 : padding - 1,
341
+ paddingLeft: padding - 1,
330
342
  paddingRight: padding - 1,
331
343
  },
332
344
  active: {
@@ -336,9 +348,7 @@ const _generateStyles = (
336
348
  borderWidth: 2,
337
349
  // We need to reduce padding to offset the difference
338
350
  // caused by the border becoming thicker on focus.
339
- // The left padding for the button with icon should have 4px
340
- // less padding
341
- paddingLeft: iconWidth ? padding - 5 : padding - 1,
351
+ paddingLeft: padding - 1,
342
352
  paddingRight: padding - 1,
343
353
  },
344
354
  disabled: {
@@ -350,9 +360,7 @@ const _generateStyles = (
350
360
  borderWidth: 2,
351
361
  // We need to reduce padding to offset the difference
352
362
  // caused by the border becoming thicker on focus.
353
- // The left padding for the button with icon should have 4px
354
- // less padding
355
- paddingLeft: iconWidth ? padding - 5 : padding - 1,
363
+ paddingLeft: padding - 1,
356
364
  paddingRight: padding - 1,
357
365
  },
358
366
  },
@@ -365,29 +373,38 @@ const _generateStyles = (
365
373
  paddingLeft: 0,
366
374
  paddingRight: 0,
367
375
  },
368
- focus: {
376
+ hover: {
369
377
  ":after": {
370
378
  content: "''",
371
379
  position: "absolute",
372
380
  height: 2,
373
- width: `calc(100% - ${iconWidth}px)`,
381
+ width: "100%",
374
382
  right: 0,
375
383
  bottom: 0,
376
384
  background: light ? white : color,
377
385
  borderRadius: 2,
378
386
  },
379
387
  },
380
- active: {
381
- color: light ? fadedColor : activeColor,
388
+ focus: {
382
389
  ":after": {
383
390
  content: "''",
391
+ // Since we are using a pseudo element, we need to manually
392
+ // calculate the width/height and use absolute position to
393
+ // prevent other elements from being shifted around.
384
394
  position: "absolute",
385
- height: 2,
386
- width: `calc(100% - ${iconWidth}px)`,
387
- right: 0,
388
- bottom: -1,
395
+ width: `calc(100% + ${Spacing.xxxSmall_4}px)`,
396
+ height: `calc(100% - ${Spacing.xxxSmall_4}px)`,
397
+ borderStyle: "solid",
398
+ borderColor: light ? white : color,
399
+ borderWidth: Spacing.xxxxSmall_2,
400
+ borderRadius: Spacing.xxxSmall_4,
401
+ },
402
+ },
403
+ active: {
404
+ color: light ? fadedColor : activeColor,
405
+ ":after": {
406
+ height: 1,
389
407
  background: light ? fadedColor : activeColor,
390
- borderRadius: 2,
391
408
  },
392
409
  },
393
410
  disabled: {
@@ -396,7 +413,7 @@ const _generateStyles = (
396
413
  },
397
414
  disabledFocus: {
398
415
  ":after": {
399
- background: light ? white : offBlack32,
416
+ borderColor: light ? white50 : offBlack32,
400
417
  },
401
418
  },
402
419
  };
@@ -8,6 +8,7 @@ import type {
8
8
  } from "@khanacademy/wonder-blocks-clickable";
9
9
  import type {AriaProps, StyleType} from "@khanacademy/wonder-blocks-core";
10
10
  import type {IconAsset} from "@khanacademy/wonder-blocks-icon";
11
+ import {Link} from "react-router-dom";
11
12
  import ButtonCore from "./button-core";
12
13
 
13
14
  export type SharedProps =
@@ -32,11 +33,11 @@ export type SharedProps =
32
33
  *
33
34
  * TODO(kevinb): support spinner + light once we have designs
34
35
  */
35
- spinner: boolean;
36
+ spinner?: boolean;
36
37
  /**
37
38
  * The color of the button, either blue or red.
38
39
  */
39
- color: "default" | "destructive";
40
+ color?: "default" | "destructive";
40
41
  /**
41
42
  * The kind of the button, either primary, secondary, or tertiary.
42
43
  *
@@ -46,23 +47,23 @@ export type SharedProps =
46
47
  * - Secondary buttons have a border and no background color
47
48
  * - Tertiary buttons have no background or border
48
49
  */
49
- kind: "primary" | "secondary" | "tertiary";
50
+ kind?: "primary" | "secondary" | "tertiary";
50
51
  /**
51
52
  * Whether the button is on a dark/colored background.
52
53
  *
53
54
  * Sets primary button background color to white, and secondary and
54
55
  * tertiary button title to color.
55
56
  */
56
- light: boolean;
57
+ light?: boolean;
57
58
  /**
58
59
  * The size of the button. "medium" = height: 40; "small" = height: 32;
59
60
  * "large" = height: 56;
60
61
  */
61
- size: "medium" | "small" | "large";
62
+ size?: "medium" | "small" | "large";
62
63
  /**
63
64
  * Whether the button is disabled.
64
65
  */
65
- disabled: boolean;
66
+ disabled?: boolean;
66
67
  /**
67
68
  * An optional id attribute.
68
69
  */
@@ -171,15 +172,6 @@ export type SharedProps =
171
172
 
172
173
  type Props = SharedProps;
173
174
 
174
- type DefaultProps = {
175
- color: Props["color"];
176
- kind: Props["kind"];
177
- light: Props["light"];
178
- size: Props["size"];
179
- disabled: Props["disabled"];
180
- spinner: Props["spinner"];
181
- };
182
-
183
175
  /**
184
176
  * Reusable button component.
185
177
  *
@@ -200,33 +192,34 @@ type DefaultProps = {
200
192
  * </Button>
201
193
  * ```
202
194
  */
203
- export default class Button extends React.Component<Props> {
204
- static defaultProps: DefaultProps = {
205
- color: "default",
206
- kind: "primary",
207
- light: false,
208
- size: "medium",
209
- disabled: false,
210
- spinner: false,
211
- };
212
-
213
- renderClickableBehavior(router: any): React.ReactNode {
214
- const {
215
- href = undefined,
216
- type = undefined,
217
- children,
218
- skipClientNav,
219
- spinner,
220
- disabled,
221
- onClick,
222
- beforeNav = undefined,
223
- safeWithNav = undefined,
224
- tabIndex,
225
- target,
226
- rel,
227
- ...sharedButtonCoreProps
228
- } = this.props;
195
+ const Button: React.ForwardRefExoticComponent<
196
+ Props &
197
+ React.RefAttributes<typeof Link | HTMLButtonElement | HTMLAnchorElement>
198
+ > = React.forwardRef<
199
+ typeof Link | HTMLButtonElement | HTMLAnchorElement,
200
+ Props
201
+ >((props: Props, ref) => {
202
+ const {
203
+ href = undefined,
204
+ type = undefined,
205
+ children,
206
+ skipClientNav,
207
+ onClick,
208
+ beforeNav = undefined,
209
+ safeWithNav = undefined,
210
+ tabIndex,
211
+ target,
212
+ rel,
213
+ color = "default",
214
+ kind = "primary",
215
+ light = false,
216
+ size = "medium",
217
+ disabled = false,
218
+ spinner = false,
219
+ ...sharedButtonCoreProps
220
+ } = props;
229
221
 
222
+ const renderClickableBehavior = (router: any): React.ReactNode => {
230
223
  const ClickableBehavior = getClickableBehavior(
231
224
  href,
232
225
  skipClientNav,
@@ -244,11 +237,16 @@ export default class Button extends React.Component<Props> {
244
237
  {...restChildProps}
245
238
  disabled={disabled}
246
239
  spinner={spinner || state.waiting}
240
+ color={color}
241
+ kind={kind}
242
+ light={light}
243
+ size={size}
247
244
  skipClientNav={skipClientNav}
248
245
  href={href}
249
246
  target={target}
250
247
  type={type}
251
248
  tabIndex={tabIndex}
249
+ ref={ref}
252
250
  >
253
251
  {children}
254
252
  </ButtonCore>
@@ -286,13 +284,13 @@ export default class Button extends React.Component<Props> {
286
284
  </ClickableBehavior>
287
285
  );
288
286
  }
289
- }
287
+ };
290
288
 
291
- render(): React.ReactNode {
292
- return (
293
- <__RouterContext.Consumer>
294
- {(router) => this.renderClickableBehavior(router)}
295
- </__RouterContext.Consumer>
296
- );
297
- }
298
- }
289
+ return (
290
+ <__RouterContext.Consumer>
291
+ {(router) => renderClickableBehavior(router)}
292
+ </__RouterContext.Consumer>
293
+ );
294
+ });
295
+
296
+ export default Button;