@khanacademy/wonder-blocks-link 4.1.0 → 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,148 +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");
365
367
 
366
368
  // Assert
367
- expect(link).toHaveFocus();
368
- expect(link).toHaveStyle(`outline: 1px solid ${Color.blue}`);
369
+ expect(link.innerHTML).toEqual(expect.stringContaining("<svg"));
370
+ expect(icon).toBeInTheDocument();
369
371
  });
370
372
 
371
- test("blue outline around primary inline links on focus", () => {
373
+ test("does not render external icon when there is no target", () => {
372
374
  // Arrange
373
- render(
374
- <Link href="/" kind="primary" inline={true}>
375
- Click me!
376
- </Link>,
377
- );
375
+ render(<Link href="/">Click me!</Link>);
378
376
 
379
377
  // Act
380
- userEvent.tab();
381
- const link = screen.getByText("Click me!");
378
+ const icon = screen.queryByTestId("external-icon");
382
379
 
383
380
  // Assert
384
- expect(link).toHaveFocus();
385
- expect(link).toHaveStyle(`outline: 1px solid ${Color.blue}`);
381
+ expect(icon).not.toBeInTheDocument();
386
382
  });
383
+ });
387
384
 
388
- test("blue outline around secondary inline links on focus", () => {
385
+ describe("start and end icons", () => {
386
+ test("render icon with link when startIcon prop is passed in", () => {
389
387
  // Arrange
390
388
  render(
391
- <Link href="/" kind="secondary" inline={true}>
392
- Click me!
389
+ <Link href="/" startIcon={icons.add}>
390
+ Add new item
393
391
  </Link>,
394
392
  );
395
393
 
396
394
  // Act
397
- userEvent.tab();
398
- const link = screen.getByText("Click me!");
395
+ const link = screen.getByRole("link");
396
+ const icon = screen.getByTestId("start-icon");
397
+
398
+ // Assert
399
+ expect(link.innerHTML).toEqual(expect.stringContaining("<svg"));
400
+ expect(icon).toBeInTheDocument();
401
+ });
402
+
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");
399
409
 
400
410
  // Assert
401
- expect(link).toHaveFocus();
402
- expect(link).toHaveStyle(`outline: 1px solid ${Color.blue}`);
411
+ expect(icon).not.toBeInTheDocument();
403
412
  });
404
413
 
405
- test("white outline around light links on focus", () => {
414
+ test("startIcon prop passed down correctly", () => {
406
415
  // Arrange
407
416
  render(
408
- <Link href="/" light={true}>
409
- Click me!
417
+ <Link href="/" startIcon={icons.add}>
418
+ Add new item
410
419
  </Link>,
411
420
  );
412
421
 
413
422
  // Act
414
- userEvent.tab();
415
- 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";
416
426
 
417
427
  // Assert
418
- expect(link).toHaveFocus();
419
- expect(link).toHaveStyle(`outline: 1px solid ${Color.white}`);
428
+ expect(icon.innerHTML).toEqual(
429
+ expect.stringContaining(iconToExpect),
430
+ );
420
431
  });
421
432
 
422
- test("white outline around inline light links on focus", () => {
433
+ test("render icon with link when endIcon prop is passed in", () => {
423
434
  // Arrange
424
435
  render(
425
- <Link href="/" light={true} inline={true}>
426
- Click me!
436
+ <Link href="/" endIcon={icons.caretRight}>
437
+ Click to go back
427
438
  </Link>,
428
439
  );
429
440
 
430
441
  // Act
431
- userEvent.tab();
432
- const link = screen.getByText("Click me!");
442
+ const link = screen.getByRole("link");
443
+ const icon = screen.getByTestId("end-icon");
433
444
 
434
445
  // Assert
435
- expect(link).toHaveFocus();
436
- expect(link).toHaveStyle(`outline: 1px solid ${Color.white}`);
446
+ expect(link.innerHTML).toEqual(expect.stringContaining("<svg"));
447
+ expect(icon).toBeInTheDocument();
437
448
  });
438
- });
439
449
 
440
- describe("external link that opens in a new tab", () => {
441
- test("target attribute passed down correctly", () => {
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'`", () => {
442
462
  // Arrange
443
463
  render(
444
- <Link href="/" target="_blank">
445
- Click me!
464
+ <Link href="/" endIcon={icons.caretRight} target="_blank">
465
+ Open a new tab
446
466
  </Link>,
447
467
  );
448
468
 
449
469
  // Act
450
- const link = screen.getByText("Click me!");
470
+ const externalIcon = screen.queryByTestId("external-icon");
451
471
 
452
472
  // Assert
453
- expect(link).toHaveAttribute("target", "_blank");
473
+ expect(externalIcon).not.toBeInTheDocument();
454
474
  });
455
475
 
456
- test("render external icon when `target=_blank`", () => {
476
+ test("render endIcon instead of default externalIcon when `target='_blank'`", () => {
457
477
  // Arrange
458
478
  render(
459
- <Link href="/" target="_blank">
460
- Click me!
479
+ <Link href="/" endIcon={icons.caretRight} target="_blank">
480
+ Open a new tab
461
481
  </Link>,
462
482
  );
463
483
 
464
484
  // Act
465
- const link = screen.getByText("Click me!");
466
- const icon = screen.getByTestId("external-icon");
485
+ const link = screen.getByRole("link");
486
+ const endIcon = screen.getByTestId("end-icon");
467
487
 
468
488
  // Assert
469
489
  expect(link.innerHTML).toEqual(expect.stringContaining("<svg"));
470
- expect(icon).toBeInTheDocument();
490
+ expect(endIcon).toBeInTheDocument();
471
491
  });
472
492
 
473
- test("does not render external icon when there is no target", () => {
493
+ test("endIcon prop passed down correctly", () => {
474
494
  // Arrange
475
- render(<Link href="/">Click me!</Link>);
495
+ render(
496
+ <Link href="/" endIcon={icons.caretRight}>
497
+ Click to go back
498
+ </Link>,
499
+ );
476
500
 
477
501
  // Act
478
- const icon = screen.queryByTestId("external-icon");
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";
479
505
 
480
506
  // Assert
481
- expect(icon).not.toBeInTheDocument();
507
+ expect(icon.innerHTML).toEqual(
508
+ expect.stringContaining(iconToExpect),
509
+ );
482
510
  });
483
511
  });
484
512
  });
@@ -43,6 +43,8 @@ export default class LinkCore extends React.Component<Props> {
43
43
  testId,
44
44
  waiting: _,
45
45
  target,
46
+ startIcon,
47
+ endIcon,
46
48
  ...restProps
47
49
  } = this.props;
48
50
 
@@ -53,7 +55,7 @@ export default class LinkCore extends React.Component<Props> {
53
55
 
54
56
  const defaultStyles = [
55
57
  sharedStyles.shared,
56
- !(hovered || focused || pressed) && restingStyles,
58
+ restingStyles,
57
59
  pressed && linkStyles.active,
58
60
  // A11y: The focus ring should always be present when the
59
61
  // the link has focus, even the link is being hovered over.
@@ -71,6 +73,7 @@ export default class LinkCore extends React.Component<Props> {
71
73
  ...restProps,
72
74
  } as const;
73
75
 
76
+ // Default external icon
74
77
  const externalIconPath: IconAsset = {
75
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",
76
79
  };
@@ -79,15 +82,34 @@ export default class LinkCore extends React.Component<Props> {
79
82
  <Icon
80
83
  icon={externalIconPath}
81
84
  size="small"
82
- style={iconStyles.icon}
85
+ style={iconStyles.endIcon}
83
86
  testId="external-icon"
84
87
  />
85
88
  );
86
89
 
87
90
  const linkContent = (
88
91
  <>
89
- {children}
90
- {target === "_blank" && externalIcon}
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
+ )}
91
113
  </>
92
114
  );
93
115
 
@@ -114,8 +136,13 @@ export default class LinkCore extends React.Component<Props> {
114
136
  const styles: Record<string, any> = {};
115
137
 
116
138
  const iconStyles = StyleSheet.create({
117
- icon: {
118
- marginLeft: Spacing.xxxSmall_4,
139
+ startIcon: {
140
+ marginInlineEnd: Spacing.xxxSmall_4,
141
+ verticalAlign: "middle",
142
+ },
143
+ endIcon: {
144
+ marginInlineStart: Spacing.xxxSmall_4,
145
+ verticalAlign: "middle",
119
146
  },
120
147
  });
121
148
 
@@ -124,7 +151,8 @@ const sharedStyles = StyleSheet.create({
124
151
  cursor: "pointer",
125
152
  textDecoration: "none",
126
153
  outline: "none",
127
- display: "inline-flex",
154
+ verticalAlign: "bottom",
155
+ textUnderlineOffset: "3px",
128
156
  alignItems: "center",
129
157
  },
130
158
  });
@@ -216,10 +244,14 @@ const _generateStyles = (
216
244
  ...defaultVisited,
217
245
  },
218
246
  focus: {
219
- color: defaultTextColor,
220
- outline: `1px solid ${light ? white : blue}`,
221
- borderRadius: 3,
222
- ...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
+ },
223
255
  },
224
256
  active: {
225
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.
@@ -140,6 +141,16 @@ export type SharedProps = AriaProps & {
140
141
  * An optional title attribute.
141
142
  */
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;
143
154
  };
144
155
 
145
156
  type DefaultProps = {