@khanacademy/wonder-blocks-link 4.0.8 → 4.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.
@@ -3,7 +3,7 @@ import {MemoryRouter, Route, Switch} from "react-router-dom";
3
3
  import {fireEvent, render, screen, waitFor} from "@testing-library/react";
4
4
  import userEvent from "@testing-library/user-event";
5
5
 
6
- import Color from "@khanacademy/wonder-blocks-color";
6
+ import {icons} from "@khanacademy/wonder-blocks-icon";
7
7
 
8
8
  import Link from "../link";
9
9
 
@@ -337,103 +337,176 @@ describe("Link", () => {
337
337
  });
338
338
  });
339
339
 
340
- describe("focus style", () => {
341
- test("blue outline around primary links on focus", () => {
340
+ describe("external link that opens in a new tab", () => {
341
+ test("target attribute passed down correctly", () => {
342
342
  // Arrange
343
- render(<Link href="/">Click me!</Link>);
343
+ render(
344
+ <Link href="/" target="_blank">
345
+ Click me!
346
+ </Link>,
347
+ );
344
348
 
345
349
  // Act
346
- userEvent.tab();
347
- const link = screen.getByText("Click me!");
350
+ const link = screen.getByRole("link");
348
351
 
349
352
  // Assert
350
- expect(link).toHaveFocus();
351
- expect(link).toHaveStyle(`outline: 1px solid ${Color.blue}`);
353
+ expect(link).toHaveAttribute("target", "_blank");
352
354
  });
353
355
 
354
- test("blue outline around secondary links on focus", () => {
356
+ test("render external icon when `target=_blank`", () => {
355
357
  // Arrange
356
358
  render(
357
- <Link href="/" kind="secondary">
359
+ <Link href="/" target="_blank">
358
360
  Click me!
359
361
  </Link>,
360
362
  );
361
363
 
362
364
  // Act
363
- userEvent.tab();
364
- const link = screen.getByText("Click me!");
365
+ const link = screen.getByRole("link");
366
+ const icon = screen.getByTestId("external-icon");
367
+
368
+ // Assert
369
+ expect(link.innerHTML).toEqual(expect.stringContaining("<svg"));
370
+ expect(icon).toBeInTheDocument();
371
+ });
372
+
373
+ test("does not render external icon when there is no target", () => {
374
+ // Arrange
375
+ render(<Link href="/">Click me!</Link>);
376
+
377
+ // Act
378
+ const icon = screen.queryByTestId("external-icon");
365
379
 
366
380
  // Assert
367
- expect(link).toHaveFocus();
368
- expect(link).toHaveStyle(`outline: 1px solid ${Color.blue}`);
381
+ expect(icon).not.toBeInTheDocument();
369
382
  });
383
+ });
370
384
 
371
- test("blue outline around primary inline links on focus", () => {
385
+ describe("start and end icons", () => {
386
+ test("render icon with link when startIcon prop is passed in", () => {
372
387
  // Arrange
373
388
  render(
374
- <Link href="/" kind="primary" inline={true}>
375
- Click me!
389
+ <Link href="/" startIcon={icons.add}>
390
+ Add new item
376
391
  </Link>,
377
392
  );
378
393
 
379
394
  // Act
380
- userEvent.tab();
381
- const link = screen.getByText("Click me!");
395
+ const link = screen.getByRole("link");
396
+ const icon = screen.getByTestId("start-icon");
382
397
 
383
398
  // Assert
384
- expect(link).toHaveFocus();
385
- expect(link).toHaveStyle(`outline: 1px solid ${Color.blue}`);
399
+ expect(link.innerHTML).toEqual(expect.stringContaining("<svg"));
400
+ expect(icon).toBeInTheDocument();
386
401
  });
387
402
 
388
- test("blue outline around secondary inline links on focus", () => {
403
+ test("does not render icon when startIcon prop is not passed in", () => {
404
+ // Arrange
405
+ render(<Link href="/">Click me!</Link>);
406
+
407
+ // Act
408
+ const icon = screen.queryByTestId("start-icon");
409
+
410
+ // Assert
411
+ expect(icon).not.toBeInTheDocument();
412
+ });
413
+
414
+ test("startIcon prop passed down correctly", () => {
389
415
  // Arrange
390
416
  render(
391
- <Link href="/" kind="secondary" inline={true}>
392
- Click me!
417
+ <Link href="/" startIcon={icons.add}>
418
+ Add new item
393
419
  </Link>,
394
420
  );
395
421
 
396
422
  // Act
397
- userEvent.tab();
398
- const link = screen.getByText("Click me!");
423
+ const icon = screen.getByTestId("start-icon");
424
+ const iconToExpect =
425
+ "M11 11V7a1 1 0 0 1 2 0v4h4a1 1 0 0 1 0 2h-4v4a1 1 0 0 1-2 0v-4H7a1 1 0 0 1 0-2h4zm1 13C5.373 24 0 18.627 0 12S5.373 0 12 0s12 5.373 12 12-5.373 12-12 12zm0-2c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z";
399
426
 
400
427
  // Assert
401
- expect(link).toHaveFocus();
402
- expect(link).toHaveStyle(`outline: 1px solid ${Color.blue}`);
428
+ expect(icon.innerHTML).toEqual(
429
+ expect.stringContaining(iconToExpect),
430
+ );
403
431
  });
404
432
 
405
- test("white outline around light links on focus", () => {
433
+ test("render icon with link when endIcon prop is passed in", () => {
406
434
  // Arrange
407
435
  render(
408
- <Link href="/" light={true}>
409
- Click me!
436
+ <Link href="/" endIcon={icons.caretRight}>
437
+ Click to go back
410
438
  </Link>,
411
439
  );
412
440
 
413
441
  // Act
414
- userEvent.tab();
415
- const link = screen.getByText("Click me!");
442
+ const link = screen.getByRole("link");
443
+ const icon = screen.getByTestId("end-icon");
416
444
 
417
445
  // Assert
418
- expect(link).toHaveFocus();
419
- expect(link).toHaveStyle(`outline: 1px solid ${Color.white}`);
446
+ expect(link.innerHTML).toEqual(expect.stringContaining("<svg"));
447
+ expect(icon).toBeInTheDocument();
420
448
  });
421
449
 
422
- test("white outline around inline light links on focus", () => {
450
+ test("does not render icon when endIcon prop is not passed in", () => {
451
+ // Arrange
452
+ render(<Link href="/">Click me!</Link>);
453
+
454
+ // Act
455
+ const icon = screen.queryByTestId("end-icon");
456
+
457
+ // Assert
458
+ expect(icon).not.toBeInTheDocument();
459
+ });
460
+
461
+ test("does not render externalIcon when endIcon is passed in and `target='_blank'`", () => {
423
462
  // Arrange
424
463
  render(
425
- <Link href="/" light={true} inline={true}>
426
- Click me!
464
+ <Link href="/" endIcon={icons.caretRight} target="_blank">
465
+ Open a new tab
427
466
  </Link>,
428
467
  );
429
468
 
430
469
  // Act
431
- userEvent.tab();
432
- const link = screen.getByText("Click me!");
470
+ const externalIcon = screen.queryByTestId("external-icon");
433
471
 
434
472
  // Assert
435
- expect(link).toHaveFocus();
436
- expect(link).toHaveStyle(`outline: 1px solid ${Color.white}`);
473
+ expect(externalIcon).not.toBeInTheDocument();
474
+ });
475
+
476
+ test("render endIcon instead of default externalIcon when `target='_blank'`", () => {
477
+ // Arrange
478
+ render(
479
+ <Link href="/" endIcon={icons.caretRight} target="_blank">
480
+ Open a new tab
481
+ </Link>,
482
+ );
483
+
484
+ // Act
485
+ const link = screen.getByRole("link");
486
+ const endIcon = screen.getByTestId("end-icon");
487
+
488
+ // Assert
489
+ expect(link.innerHTML).toEqual(expect.stringContaining("<svg"));
490
+ expect(endIcon).toBeInTheDocument();
491
+ });
492
+
493
+ test("endIcon prop passed down correctly", () => {
494
+ // Arrange
495
+ render(
496
+ <Link href="/" endIcon={icons.caretRight}>
497
+ Click to go back
498
+ </Link>,
499
+ );
500
+
501
+ // Act
502
+ const icon = screen.getByTestId("end-icon");
503
+ const iconToExpect =
504
+ "M8.586 8L5.293 4.707a1 1 0 0 1 1.414-1.414l4 4a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414-1.414L8.586 8z";
505
+
506
+ // Assert
507
+ expect(icon.innerHTML).toEqual(
508
+ expect.stringContaining(iconToExpect),
509
+ );
437
510
  });
438
511
  });
439
512
  });
@@ -6,12 +6,15 @@ import {__RouterContext} from "react-router";
6
6
  import {addStyle} from "@khanacademy/wonder-blocks-core";
7
7
  import Color, {mix, fade} from "@khanacademy/wonder-blocks-color";
8
8
  import {isClientSideUrl} from "@khanacademy/wonder-blocks-clickable";
9
+ import Icon from "@khanacademy/wonder-blocks-icon";
10
+ import Spacing from "@khanacademy/wonder-blocks-spacing";
9
11
 
10
12
  import type {
11
13
  ChildrenProps,
12
14
  ClickableState,
13
15
  } from "@khanacademy/wonder-blocks-clickable";
14
16
  import type {StyleDeclaration} from "aphrodite";
17
+ import type {IconAsset} from "@khanacademy/wonder-blocks-icon";
15
18
  import type {SharedProps} from "./link";
16
19
 
17
20
  type Props = SharedProps &
@@ -20,8 +23,8 @@ type Props = SharedProps &
20
23
  href: string;
21
24
  };
22
25
 
23
- const StyledAnchor = addStyle<"a">("a");
24
- const StyledLink = addStyle<typeof Link>(Link);
26
+ const StyledAnchor = addStyle("a");
27
+ const StyledLink = addStyle(Link);
25
28
 
26
29
  export default class LinkCore extends React.Component<Props> {
27
30
  renderInner(router: any): React.ReactNode {
@@ -39,6 +42,9 @@ export default class LinkCore extends React.Component<Props> {
39
42
  style,
40
43
  testId,
41
44
  waiting: _,
45
+ target,
46
+ startIcon,
47
+ endIcon,
42
48
  ...restProps
43
49
  } = this.props;
44
50
 
@@ -49,7 +55,7 @@ export default class LinkCore extends React.Component<Props> {
49
55
 
50
56
  const defaultStyles = [
51
57
  sharedStyles.shared,
52
- !(hovered || focused || pressed) && restingStyles,
58
+ restingStyles,
53
59
  pressed && linkStyles.active,
54
60
  // A11y: The focus ring should always be present when the
55
61
  // the link has focus, even the link is being hovered over.
@@ -63,16 +69,57 @@ export default class LinkCore extends React.Component<Props> {
63
69
  const commonProps = {
64
70
  "data-test-id": testId,
65
71
  style: [defaultStyles, style],
72
+ target,
66
73
  ...restProps,
67
74
  } as const;
68
75
 
76
+ // Default external icon
77
+ const externalIconPath: IconAsset = {
78
+ small: "M14 6.5C14 6.63261 13.9473 6.75979 13.8536 6.85355C13.7598 6.94732 13.6326 7 13.5 7C13.3674 7 13.2402 6.94732 13.1464 6.85355C13.0527 6.75979 13 6.63261 13 6.5V3.7075L8.85437 7.85375C8.76055 7.94757 8.63331 8.00028 8.50062 8.00028C8.36794 8.00028 8.2407 7.94757 8.14688 7.85375C8.05306 7.75993 8.00035 7.63268 8.00035 7.5C8.00035 7.36732 8.05306 7.24007 8.14688 7.14625L12.2925 3H9.5C9.36739 3 9.24021 2.94732 9.14645 2.85355C9.05268 2.75979 9 2.63261 9 2.5C9 2.36739 9.05268 2.24021 9.14645 2.14645C9.24021 2.05268 9.36739 2 9.5 2H13.5C13.6326 2 13.7598 2.05268 13.8536 2.14645C13.9473 2.24021 14 2.36739 14 2.5V6.5ZM11.5 8C11.3674 8 11.2402 8.05268 11.1464 8.14645C11.0527 8.24021 11 8.36739 11 8.5V13H3V5H7.5C7.63261 5 7.75979 4.94732 7.85355 4.85355C7.94732 4.75979 8 4.63261 8 4.5C8 4.36739 7.94732 4.24021 7.85355 4.14645C7.75979 4.05268 7.63261 4 7.5 4H3C2.73478 4 2.48043 4.10536 2.29289 4.29289C2.10536 4.48043 2 4.73478 2 5V13C2 13.2652 2.10536 13.5196 2.29289 13.7071C2.48043 13.8946 2.73478 14 3 14H11C11.2652 14 11.5196 13.8946 11.7071 13.7071C11.8946 13.5196 12 13.2652 12 13V8.5C12 8.36739 11.9473 8.24021 11.8536 8.14645C11.7598 8.05268 11.6326 8 11.5 8Z",
79
+ };
80
+
81
+ const externalIcon = (
82
+ <Icon
83
+ icon={externalIconPath}
84
+ size="small"
85
+ style={iconStyles.endIcon}
86
+ testId="external-icon"
87
+ />
88
+ );
89
+
90
+ const linkContent = (
91
+ <>
92
+ {startIcon && (
93
+ <Icon
94
+ icon={startIcon}
95
+ size="small"
96
+ style={iconStyles.startIcon}
97
+ testId="start-icon"
98
+ aria-hidden="true"
99
+ />
100
+ )}
101
+ <span style={{verticalAlign: "middle"}}>{children}</span>
102
+ {endIcon ? (
103
+ <Icon
104
+ icon={endIcon}
105
+ size="small"
106
+ style={iconStyles.endIcon}
107
+ testId="end-icon"
108
+ aria-hidden="true"
109
+ />
110
+ ) : (
111
+ target === "_blank" && externalIcon
112
+ )}
113
+ </>
114
+ );
115
+
69
116
  return router && !skipClientNav && isClientSideUrl(href) ? (
70
117
  <StyledLink {...commonProps} to={href}>
71
- {children}
118
+ {linkContent}
72
119
  </StyledLink>
73
120
  ) : (
74
121
  <StyledAnchor {...commonProps} href={href}>
75
- {children}
122
+ {linkContent}
76
123
  </StyledAnchor>
77
124
  );
78
125
  }
@@ -88,12 +135,25 @@ export default class LinkCore extends React.Component<Props> {
88
135
 
89
136
  const styles: Record<string, any> = {};
90
137
 
138
+ const iconStyles = StyleSheet.create({
139
+ startIcon: {
140
+ marginInlineEnd: Spacing.xxxSmall_4,
141
+ verticalAlign: "middle",
142
+ },
143
+ endIcon: {
144
+ marginInlineStart: Spacing.xxxSmall_4,
145
+ verticalAlign: "middle",
146
+ },
147
+ });
148
+
91
149
  const sharedStyles = StyleSheet.create({
92
150
  shared: {
93
151
  cursor: "pointer",
94
152
  textDecoration: "none",
95
153
  outline: "none",
96
- display: "inline-flex",
154
+ verticalAlign: "bottom",
155
+ textUnderlineOffset: "3px",
156
+ alignItems: "center",
97
157
  },
98
158
  });
99
159
 
@@ -184,10 +244,14 @@ const _generateStyles = (
184
244
  ...defaultVisited,
185
245
  },
186
246
  focus: {
187
- color: defaultTextColor,
188
- outline: `1px solid ${light ? white : blue}`,
189
- borderRadius: 3,
190
- ...defaultVisited,
247
+ // Focus styles only show up with keyboard navigation.
248
+ // Mouse users don't see focus styles.
249
+ ":focus-visible": {
250
+ color: defaultTextColor,
251
+ outline: `1px solid ${light ? white : blue}`,
252
+ borderRadius: 3,
253
+ ...defaultVisited,
254
+ },
191
255
  },
192
256
  active: {
193
257
  color: activeColor,
@@ -4,6 +4,7 @@ import {getClickableBehavior} from "@khanacademy/wonder-blocks-clickable";
4
4
 
5
5
  import type {AriaProps, StyleType} from "@khanacademy/wonder-blocks-core";
6
6
  import type {Typography} from "@khanacademy/wonder-blocks-typography";
7
+ import type {IconAsset} from "@khanacademy/wonder-blocks-icon";
7
8
  import LinkCore from "./link-core";
8
9
 
9
10
  // TODO(FEI-5000): Convert back to conditional props after TS migration is complete.
@@ -136,6 +137,20 @@ export type SharedProps = AriaProps & {
136
137
  * navigation.
137
138
  */
138
139
  beforeNav?: () => Promise<unknown>;
140
+ /**
141
+ * An optional title attribute.
142
+ */
143
+ title?: string;
144
+ /**
145
+ * An optional icon displayed before the link label.
146
+ */
147
+ startIcon?: IconAsset;
148
+ /**
149
+ * An optional icon displayed after the link label.
150
+ * If `target="_blank"` and `endIcon` is passed in, `endIcon` will override
151
+ * the default `externalIcon`.
152
+ */
153
+ endIcon?: IconAsset;
139
154
  };
140
155
 
141
156
  type DefaultProps = {
package/tsconfig.json CHANGED
@@ -10,5 +10,7 @@
10
10
  {"path": "../wonder-blocks-color"},
11
11
  {"path": "../wonder-blocks-core"},
12
12
  {"path": "../wonder-blocks-typography"},
13
+ {"path": "../wonder-blocks-icon"},
14
+ {"path": "../wonder-blocks-spacing"},
13
15
  ]
14
16
  }