@khanacademy/wonder-blocks-icon-button 5.0.0 → 5.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.
@@ -162,4 +162,57 @@ describe("IconButton", () => {
162
162
  // Assert
163
163
  expect(screen.queryByText("Hello, world!")).not.toBeInTheDocument();
164
164
  });
165
+
166
+ test("disallow press/click when disabled is set", () => {
167
+ // Arrange
168
+ const onClickMock = jest.fn();
169
+ render(
170
+ <IconButton
171
+ icon={magnifyingGlassIcon}
172
+ aria-label="search"
173
+ testId="icon-button"
174
+ onClick={onClickMock}
175
+ disabled={true}
176
+ />,
177
+ );
178
+
179
+ // Act
180
+ userEvent.click(screen.getByRole("button"));
181
+
182
+ // Assert
183
+ expect(onClickMock).not.toBeCalled();
184
+ });
185
+
186
+ it("sets the 'target' prop on the underlying element", () => {
187
+ // Arrange
188
+ render(
189
+ <IconButton
190
+ icon={magnifyingGlassIcon}
191
+ href="https://www.khanacademy.org"
192
+ target="_blank"
193
+ />,
194
+ );
195
+
196
+ // Act
197
+ const link = screen.getByRole("link");
198
+ userEvent.click(link);
199
+
200
+ // Assert
201
+ expect(link).toHaveAttribute("target", "_blank");
202
+ });
203
+
204
+ it("renders an <a> if the href is '#'", () => {
205
+ // Arrange
206
+ render(
207
+ <MemoryRouter>
208
+ <IconButton icon={magnifyingGlassIcon} href="#" />,
209
+ </MemoryRouter>,
210
+ );
211
+
212
+ // Act
213
+ const link = screen.getByRole("link");
214
+
215
+ // Assert
216
+ expect(link.tagName).toBe("A");
217
+ });
165
218
  });
@@ -3,24 +3,20 @@ import {StyleSheet} from "aphrodite";
3
3
  import {Link} from "react-router-dom";
4
4
  import {__RouterContext} from "react-router";
5
5
 
6
- import Color, {
7
- SemanticColor,
8
- mix,
9
- fade,
10
- } from "@khanacademy/wonder-blocks-color";
11
6
  import {addStyle} from "@khanacademy/wonder-blocks-core";
12
7
  import {isClientSideUrl} from "@khanacademy/wonder-blocks-clickable";
13
8
  import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon";
9
+ import {useScopedTheme} from "@khanacademy/wonder-blocks-theming";
14
10
 
15
- import type {
16
- ChildrenProps,
17
- ClickableState,
18
- } from "@khanacademy/wonder-blocks-clickable";
19
11
  import type {IconButtonSize, SharedProps} from "./icon-button";
20
12
  import {
21
13
  iconSizeForButtonSize,
22
14
  targetPixelsForSize,
23
15
  } from "../util/icon-button-util";
16
+ import {
17
+ IconButtonThemeContext,
18
+ IconButtonThemeContract,
19
+ } from "../themes/themed-icon-button";
24
20
 
25
21
  /**
26
22
  * Returns the phosphor icon component based on the size. This is necessary
@@ -55,17 +51,15 @@ function IconChooser({
55
51
  }
56
52
  }
57
53
 
58
- type Props = SharedProps &
59
- ChildrenProps &
60
- ClickableState & {
61
- /**
62
- * URL to navigate to.
63
- *
64
- * Used to determine whether to render an `<a>` or `<button>` tag. Also
65
- * passed in as the `<a>` tag's `href` if present.
66
- */
67
- href?: string;
68
- };
54
+ type Props = SharedProps & {
55
+ /**
56
+ * URL to navigate to.
57
+ *
58
+ * Used to determine whether to render an `<a>` or `<button>` tag. Also
59
+ * passed in as the `<a>` tag's `href` if present.
60
+ */
61
+ href?: string;
62
+ };
69
63
 
70
64
  const StyledAnchor = addStyle("a");
71
65
  const StyledButton = addStyle("button");
@@ -81,37 +75,32 @@ const IconButtonCore: React.ForwardRefExoticComponent<
81
75
  const {
82
76
  color,
83
77
  disabled,
84
- focused,
85
- hovered,
86
78
  href,
87
79
  icon,
88
80
  kind = "primary",
89
81
  light = false,
90
- pressed,
91
82
  size = "medium",
92
83
  skipClientNav,
93
84
  style,
94
85
  testId,
95
- waiting: _,
96
86
  ...restProps
97
87
  } = props;
88
+ const {theme, themeName} = useScopedTheme(IconButtonThemeContext);
98
89
 
99
90
  const renderInner = (router: any): React.ReactNode => {
100
- const buttonColor =
101
- color === "destructive"
102
- ? SemanticColor.controlDestructive
103
- : SemanticColor.controlDefault;
104
-
105
- const buttonStyles = _generateStyles(buttonColor, kind, light, size);
91
+ const buttonStyles = _generateStyles(
92
+ color,
93
+ kind,
94
+ light,
95
+ size,
96
+ theme,
97
+ themeName,
98
+ );
106
99
 
107
100
  const defaultStyle = [
108
101
  sharedStyles.shared,
109
102
  buttonStyles.default,
110
103
  disabled && buttonStyles.disabled,
111
- !disabled &&
112
- (pressed
113
- ? buttonStyles.active
114
- : (hovered || focused) && buttonStyles.focus),
115
104
  ];
116
105
 
117
106
  const child = <IconChooser size={size} icon={icon} />;
@@ -145,7 +134,8 @@ const IconButtonCore: React.ForwardRefExoticComponent<
145
134
  <StyledButton
146
135
  type="button"
147
136
  {...commonProps}
148
- disabled={disabled}
137
+ onClick={disabled ? undefined : restProps.onClick}
138
+ aria-disabled={disabled}
149
139
  ref={ref as React.Ref<HTMLButtonElement>}
150
140
  >
151
141
  {child}
@@ -180,22 +170,91 @@ const sharedStyles = StyleSheet.create({
180
170
  // This removes the 300ms click delay on mobile browsers by indicating that
181
171
  // "double-tap to zoom" shouldn't be used on this element.
182
172
  touchAction: "manipulation",
183
- ":focus": {
184
- // Mobile: Removes a blue highlight style shown when the user clicks a button
185
- WebkitTapHighlightColor: "rgba(0,0,0,0)",
186
- },
187
173
  },
188
174
  });
189
175
 
190
176
  const styles: Record<string, any> = {};
191
177
 
192
- const _generateStyles = (
178
+ function getStylesByKind(
179
+ kind: "primary" | "secondary" | "tertiary",
180
+ theme: IconButtonThemeContract,
193
181
  color: string,
182
+ light: boolean,
183
+ buttonColor: string,
184
+ ) {
185
+ switch (kind) {
186
+ case "primary":
187
+ const primaryHoveredColor =
188
+ buttonColor === "destructive"
189
+ ? theme.color.stroke.primary.critical.hovered
190
+ : theme.color.stroke.primary.action.hovered;
191
+
192
+ return {
193
+ ":hover": {
194
+ backgroundColor: theme.color.bg.hovered,
195
+ color: light
196
+ ? theme.color.stroke.primary.inverse.hovered
197
+ : primaryHoveredColor,
198
+ outlineColor: light ? theme.color.stroke.inverse : color,
199
+ outlineOffset: 1,
200
+ outlineStyle: "solid",
201
+ outlineWidth: light
202
+ ? theme.border.width.hoveredInverse
203
+ : theme.border.width.hovered,
204
+ },
205
+ ":active": {
206
+ backgroundColor: theme.color.bg.active,
207
+ },
208
+ };
209
+ case "secondary":
210
+ case "tertiary":
211
+ return {
212
+ ":hover": {
213
+ backgroundColor:
214
+ buttonColor === "destructive"
215
+ ? theme.color.bg.filled.critical.hovered
216
+ : theme.color.bg.filled.action.hovered,
217
+ color:
218
+ buttonColor === "destructive"
219
+ ? theme.color.stroke.filled.critical.hovered
220
+ : theme.color.stroke.filled.action.hovered,
221
+ outlineWidth: theme.border.width.active,
222
+ },
223
+ ":active": {
224
+ backgroundColor:
225
+ buttonColor === "destructive"
226
+ ? theme.color.bg.filled.critical.active
227
+ : theme.color.bg.filled.action.active,
228
+ color:
229
+ buttonColor === "destructive"
230
+ ? theme.color.stroke.filled.critical.active
231
+ : theme.color.stroke.filled.action.active,
232
+ outlineWidth: theme.border.width.active,
233
+ },
234
+ };
235
+ default:
236
+ return {
237
+ ":focus-visible": {},
238
+ ":hover": {},
239
+ ":active": {},
240
+ };
241
+ }
242
+ }
243
+
244
+ const _generateStyles = (
245
+ buttonColor = "default",
194
246
  kind: "primary" | "secondary" | "tertiary",
195
247
  light: boolean,
196
248
  size: IconButtonSize,
249
+ theme: IconButtonThemeContract,
250
+ themeName: string,
197
251
  ) => {
198
- const buttonType = `${color}-${kind}-${light}-${size}`;
252
+ const color: string =
253
+ buttonColor === "destructive"
254
+ ? theme.color.stroke.critical.default
255
+ : theme.color.stroke.action.default;
256
+
257
+ const buttonType = `${color}-${kind}-${light}-${size}-${themeName}`;
199
258
  if (styles[buttonType]) {
200
259
  return styles[buttonType];
201
260
  }
@@ -204,48 +263,129 @@ const _generateStyles = (
204
263
  throw new Error("Light is only supported for primary IconButtons");
205
264
  }
206
265
 
207
- const {white, offBlack32, offBlack64, offBlack} = Color;
208
266
  const defaultColor = ((): string => {
209
267
  switch (kind) {
210
268
  case "primary":
211
- return light ? white : color;
269
+ return light
270
+ ? theme.color.stroke.primary.inverse.default
271
+ : color;
212
272
  case "secondary":
213
- return offBlack;
273
+ return theme.color.stroke.secondary.default;
214
274
  case "tertiary":
215
- return offBlack64;
275
+ return theme.color.stroke.tertiary.default;
216
276
  default:
217
277
  throw new Error("IconButton kind not recognized");
218
278
  }
219
279
  })();
220
280
  const pixelsForSize = targetPixelsForSize(size);
221
281
 
282
+ // Override styles for each kind of button. This is useful for merging
283
+ // pseudo-classes properly.
284
+ const kindOverrides = getStylesByKind(
285
+ kind,
286
+ theme,
287
+ color,
288
+ light,
289
+ buttonColor,
290
+ );
291
+
292
+ const activeInverseColor =
293
+ buttonColor === "destructive"
294
+ ? theme.color.stroke.critical.inverse
295
+ : theme.color.stroke.action.inverse;
296
+ const activeColor =
297
+ buttonColor === "destructive"
298
+ ? theme.color.stroke.critical.active
299
+ : theme.color.stroke.action.active;
300
+
301
+ // Shared by hover and focus states.
302
+ const defaultStrokeColor = light ? theme.color.stroke.inverse : color;
303
+
304
+ const disabledStrokeColor = light
305
+ ? theme.color.stroke.disabled.inverse
306
+ : theme.color.stroke.disabled.default;
307
+
308
+ const disabledStatesStyles = {
309
+ backgroundColor: theme.color.bg.disabled,
310
+ color: disabledStrokeColor,
311
+ outlineColor: disabledStrokeColor,
312
+ };
313
+
222
314
  const newStyles = {
223
315
  default: {
224
316
  height: pixelsForSize,
225
317
  width: pixelsForSize,
226
318
  color: defaultColor,
227
- },
228
- focus: {
229
- color: light ? white : color,
230
- borderWidth: 2,
231
- borderColor: light ? white : color,
232
- borderStyle: "solid",
233
- borderRadius: 4,
234
- },
235
- active: {
236
- color: light
237
- ? mix(fade(color, 0.32), white)
238
- : mix(offBlack32, color),
239
- borderWidth: 2,
240
- borderColor: light
241
- ? mix(fade(color, 0.32), white)
242
- : mix(offBlack32, color),
243
- borderStyle: "solid",
244
- borderRadius: 4,
319
+ borderRadius: theme.border.radius.default,
320
+
321
+ /**
322
+ * States
323
+ *
324
+ * Defined in the following order: hover, focus, active.
325
+ */
326
+ ":hover": {
327
+ boxShadow: "none",
328
+ color: defaultStrokeColor,
329
+ borderRadius: theme.border.radius.default,
330
+ outlineWidth: theme.border.width.default,
331
+ ...kindOverrides[":hover"],
332
+ },
333
+ // Provide basic, default focus styles on older browsers (e.g.
334
+ // Safari 14)
335
+ ":focus": {
336
+ boxShadow: `0 0 0 ${theme.border.width.default}px ${defaultStrokeColor}`,
337
+ borderRadius: theme.border.radius.default,
338
+ },
339
+ // Remove default focus styles for mouse users ONLY if
340
+ // :focus-visible is supported on this platform.
341
+ ":focus:not(:focus-visible)": {
342
+ boxShadow: "none",
343
+ },
344
+ // Provide focus styles for keyboard users on modern browsers.
345
+ ":focus-visible": {
346
+ // Reset default focus styles
347
+ boxShadow: "none",
348
+ // Apply modern focus styles
349
+ outlineWidth: theme.border.width.default,
350
+ outlineColor: defaultStrokeColor,
351
+ outlineOffset: 1,
352
+ outlineStyle: "solid",
353
+ borderRadius: theme.border.radius.default,
354
+ ...kindOverrides[":focus-visible"],
355
+ },
356
+ ":active": {
357
+ color: light ? activeInverseColor : activeColor,
358
+ outlineWidth: theme.border.width.default,
359
+ outlineColor: light ? activeInverseColor : activeColor,
360
+ outlineOffset: 1,
361
+ outlineStyle: "solid",
362
+ borderRadius: theme.border.radius.default,
363
+ ...kindOverrides[":active"],
364
+ },
245
365
  },
246
366
  disabled: {
247
- color: light ? mix(fade(white, 0.32), color) : offBlack32,
248
- cursor: "default",
367
+ color: disabledStrokeColor,
368
+ cursor: "not-allowed",
369
+ // NOTE: Even that browsers recommend to specify pseudo-classes in
370
+ // this order: link, visited, focus, hover, active, we need to
371
+ // specify focus after hover to override hover styles. By doing this
372
+ // we are able to remove the hover outline when the button is
373
+ // disabled.
374
+ // For order reference: https://css-tricks.com/snippets/css/link-pseudo-classes-in-order/
375
+ ":hover": {...disabledStatesStyles, outline: "none"},
376
+ ":active": {...disabledStatesStyles, outline: "none"},
377
+ // Provide basic, default focus styles on older browsers (e.g.
378
+ // Safari 14)
379
+ ":focus": {
380
+ boxShadow: `0 0 0 ${theme.border.width.default}px ${disabledStrokeColor}`,
381
+ borderRadius: theme.border.radius.default,
382
+ },
383
+ // Remove default focus styles for mouse users ONLY if
384
+ // :focus-visible is supported on this platform.
385
+ ":focus:not(:focus-visible)": {
386
+ boxShadow: "none",
387
+ },
388
+ ":focus-visible": disabledStatesStyles,
249
389
  },
250
390
  } as const;
251
391
 
@@ -1,11 +1,10 @@
1
1
  import * as React from "react";
2
- import {__RouterContext} from "react-router";
3
2
 
4
- import {getClickableBehavior} from "@khanacademy/wonder-blocks-clickable";
5
3
  import type {PhosphorIconAsset} from "@khanacademy/wonder-blocks-icon";
6
4
  import type {AriaProps, StyleType} from "@khanacademy/wonder-blocks-core";
7
5
  import {Link} from "react-router-dom";
8
6
  import IconButtonCore from "./icon-button-core";
7
+ import ThemedIconButton from "../themes/themed-icon-button";
9
8
 
10
9
  export type IconButtonSize = "xsmall" | "small" | "medium";
11
10
 
@@ -172,54 +171,28 @@ export const IconButton: React.ForwardRefExoticComponent<
172
171
  href,
173
172
  kind = "primary",
174
173
  light = false,
175
- onClick,
176
174
  size = "medium",
177
175
  skipClientNav,
178
176
  tabIndex,
179
177
  target,
180
178
  ...sharedProps
181
179
  } = props;
182
- const renderClickableBehavior = (router: any): React.ReactNode => {
183
- const ClickableBehavior = getClickableBehavior(
184
- href,
185
- skipClientNav,
186
- router,
187
- );
188
180
 
189
- return (
190
- <ClickableBehavior
181
+ return (
182
+ <ThemedIconButton>
183
+ <IconButtonCore
184
+ {...sharedProps}
185
+ color={color}
191
186
  disabled={disabled}
192
187
  href={href}
193
- onClick={onClick}
194
- role="button"
188
+ kind={kind}
189
+ light={light}
190
+ ref={ref}
191
+ skipClientNav={skipClientNav}
192
+ size={size}
195
193
  target={target}
196
- >
197
- {(state, {...childrenProps}) => {
198
- return (
199
- <IconButtonCore
200
- {...sharedProps}
201
- {...state}
202
- {...childrenProps}
203
- color={color}
204
- disabled={disabled}
205
- href={href}
206
- kind={kind}
207
- light={light}
208
- ref={ref}
209
- skipClientNav={skipClientNav}
210
- size={size}
211
- target={target}
212
- tabIndex={tabIndex}
213
- />
214
- );
215
- }}
216
- </ClickableBehavior>
217
- );
218
- };
219
-
220
- return (
221
- <__RouterContext.Consumer>
222
- {(router) => renderClickableBehavior(router)}
223
- </__RouterContext.Consumer>
194
+ tabIndex={tabIndex}
195
+ />
196
+ </ThemedIconButton>
224
197
  );
225
198
  });
@@ -0,0 +1,111 @@
1
+ import {tokens} from "@khanacademy/wonder-blocks-theming";
2
+
3
+ const theme = {
4
+ color: {
5
+ bg: {
6
+ /**
7
+ * Default
8
+ */
9
+ hovered: "transparent",
10
+ active: "transparent",
11
+ disabled: "transparent",
12
+ /**
13
+ * Kind
14
+ */
15
+ // Filled icon buttons (secondary, tertiary)
16
+ // NOTE: Transparent in the default theme, but we want to use the
17
+ // filled colors for Khanmigo.
18
+ filled: {
19
+ action: {
20
+ hovered: "transparent",
21
+ active: "transparent",
22
+ },
23
+ critical: {
24
+ hovered: "transparent",
25
+ active: "transparent",
26
+ },
27
+ },
28
+ },
29
+ // Shared colors for icon and borders
30
+ stroke: {
31
+ /**
32
+ * Default
33
+ */
34
+ disabled: {
35
+ default: tokens.color.offBlack32,
36
+ inverse: tokens.color.white50,
37
+ },
38
+ // focus, hover
39
+ inverse: tokens.color.white,
40
+
41
+ /**
42
+ * Color
43
+ */
44
+ // color="default"
45
+ action: {
46
+ default: tokens.color.blue,
47
+ active: tokens.color.activeBlue,
48
+ inverse: tokens.color.fadedBlue,
49
+ },
50
+ // color="destructive"
51
+ critical: {
52
+ default: tokens.color.red,
53
+ active: tokens.color.activeRed,
54
+ inverse: tokens.color.fadedRed,
55
+ },
56
+
57
+ /**
58
+ * Kind
59
+ */
60
+ primary: {
61
+ // primary + action
62
+ action: {
63
+ hovered: tokens.color.blue,
64
+ active: tokens.color.activeBlue,
65
+ },
66
+ // primary + critical
67
+ critical: {
68
+ hovered: tokens.color.red,
69
+ active: tokens.color.activeRed,
70
+ },
71
+ // on dark background
72
+ inverse: {
73
+ default: tokens.color.white,
74
+ hovered: tokens.color.white,
75
+ },
76
+ },
77
+ secondary: {
78
+ default: tokens.color.offBlack,
79
+ },
80
+ tertiary: {
81
+ default: tokens.color.offBlack64,
82
+ },
83
+ // Filled icon buttons (secondary, tertiary)
84
+ filled: {
85
+ // filled + action
86
+ action: {
87
+ hovered: tokens.color.blue,
88
+ active: tokens.color.activeBlue,
89
+ },
90
+ // filled + critical
91
+ critical: {
92
+ hovered: tokens.color.red,
93
+ active: tokens.color.activeRed,
94
+ },
95
+ },
96
+ },
97
+ },
98
+ border: {
99
+ width: {
100
+ default: tokens.border.width.thin,
101
+ active: tokens.border.width.none,
102
+ hovered: tokens.border.width.thin,
103
+ hoveredInverse: tokens.border.width.thin,
104
+ },
105
+ radius: {
106
+ default: tokens.border.radius.medium_4,
107
+ },
108
+ },
109
+ };
110
+
111
+ export default theme;
@@ -0,0 +1,76 @@
1
+ import {mergeTheme, tokens} from "@khanacademy/wonder-blocks-theming";
2
+ import defaultTheme from "./default";
3
+
4
+ /**
5
+ * The overrides for the Khanmigo theme.
6
+ */
7
+ const theme = mergeTheme(defaultTheme, {
8
+ color: {
9
+ bg: {
10
+ hovered: tokens.color.white,
11
+ active: tokens.color.white64,
12
+ // Filled icon buttons (secondary, tertiary)
13
+ filled: {
14
+ action: {
15
+ hovered: tokens.color.blue,
16
+ active: tokens.color.activeBlue,
17
+ },
18
+ critical: {
19
+ hovered: tokens.color.red,
20
+ active: tokens.color.activeRed,
21
+ },
22
+ },
23
+ },
24
+ stroke: {
25
+ /**
26
+ * Color
27
+ */
28
+ action: {
29
+ inverse: tokens.color.eggplant,
30
+ },
31
+ critical: {
32
+ inverse: tokens.color.eggplant,
33
+ },
34
+ /**
35
+ * Kind
36
+ */
37
+ primary: {
38
+ // primary + action
39
+ action: {
40
+ hovered: tokens.color.eggplant,
41
+ active: tokens.color.eggplant,
42
+ },
43
+ // primary + critical
44
+ critical: {
45
+ hovered: tokens.color.eggplant,
46
+ active: tokens.color.eggplant,
47
+ },
48
+ // on dark background
49
+ inverse: {
50
+ hovered: tokens.color.eggplant,
51
+ },
52
+ },
53
+ // Filled icon buttons (secondary, tertiary)
54
+ filled: {
55
+ // filled + action
56
+ action: {
57
+ hovered: tokens.color.white,
58
+ active: tokens.color.white,
59
+ },
60
+ // filled + critical
61
+ critical: {
62
+ hovered: tokens.color.white,
63
+ active: tokens.color.white,
64
+ },
65
+ },
66
+ },
67
+ },
68
+ border: {
69
+ width: {
70
+ hovered: tokens.border.width.none,
71
+ hoveredInverse: tokens.border.width.none,
72
+ },
73
+ },
74
+ });
75
+
76
+ export default theme;