@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.
- package/CHANGELOG.md +10 -0
- package/dist/components/link.d.ts +11 -0
- package/dist/components/link.js.flow +13 -0
- package/dist/es/index.js +39 -13
- package/dist/index.js +39 -13
- package/package.json +3 -3
- package/src/__tests__/__snapshots__/custom-snapshot.test.tsx.snap +847 -176
- package/src/components/__tests__/link.test.tsx +90 -62
- package/src/components/link-core.tsx +43 -11
- package/src/components/link.tsx +11 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
|
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("
|
|
341
|
-
test("
|
|
340
|
+
describe("external link that opens in a new tab", () => {
|
|
341
|
+
test("target attribute passed down correctly", () => {
|
|
342
342
|
// Arrange
|
|
343
|
-
render(
|
|
343
|
+
render(
|
|
344
|
+
<Link href="/" target="_blank">
|
|
345
|
+
Click me!
|
|
346
|
+
</Link>,
|
|
347
|
+
);
|
|
344
348
|
|
|
345
349
|
// Act
|
|
346
|
-
|
|
347
|
-
const link = screen.getByText("Click me!");
|
|
350
|
+
const link = screen.getByRole("link");
|
|
348
351
|
|
|
349
352
|
// Assert
|
|
350
|
-
expect(link).
|
|
351
|
-
expect(link).toHaveStyle(`outline: 1px solid ${Color.blue}`);
|
|
353
|
+
expect(link).toHaveAttribute("target", "_blank");
|
|
352
354
|
});
|
|
353
355
|
|
|
354
|
-
test("
|
|
356
|
+
test("render external icon when `target=_blank`", () => {
|
|
355
357
|
// Arrange
|
|
356
358
|
render(
|
|
357
|
-
<Link href="/"
|
|
359
|
+
<Link href="/" target="_blank">
|
|
358
360
|
Click me!
|
|
359
361
|
</Link>,
|
|
360
362
|
);
|
|
361
363
|
|
|
362
364
|
// Act
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
+
const link = screen.getByRole("link");
|
|
366
|
+
const icon = screen.getByTestId("external-icon");
|
|
365
367
|
|
|
366
368
|
// Assert
|
|
367
|
-
expect(link).
|
|
368
|
-
expect(
|
|
369
|
+
expect(link.innerHTML).toEqual(expect.stringContaining("<svg"));
|
|
370
|
+
expect(icon).toBeInTheDocument();
|
|
369
371
|
});
|
|
370
372
|
|
|
371
|
-
test("
|
|
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
|
-
|
|
381
|
-
const link = screen.getByText("Click me!");
|
|
378
|
+
const icon = screen.queryByTestId("external-icon");
|
|
382
379
|
|
|
383
380
|
// Assert
|
|
384
|
-
expect(
|
|
385
|
-
expect(link).toHaveStyle(`outline: 1px solid ${Color.blue}`);
|
|
381
|
+
expect(icon).not.toBeInTheDocument();
|
|
386
382
|
});
|
|
383
|
+
});
|
|
387
384
|
|
|
388
|
-
|
|
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="/"
|
|
392
|
-
|
|
389
|
+
<Link href="/" startIcon={icons.add}>
|
|
390
|
+
Add new item
|
|
393
391
|
</Link>,
|
|
394
392
|
);
|
|
395
393
|
|
|
396
394
|
// Act
|
|
397
|
-
|
|
398
|
-
const
|
|
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(
|
|
402
|
-
expect(link).toHaveStyle(`outline: 1px solid ${Color.blue}`);
|
|
411
|
+
expect(icon).not.toBeInTheDocument();
|
|
403
412
|
});
|
|
404
413
|
|
|
405
|
-
test("
|
|
414
|
+
test("startIcon prop passed down correctly", () => {
|
|
406
415
|
// Arrange
|
|
407
416
|
render(
|
|
408
|
-
<Link href="/"
|
|
409
|
-
|
|
417
|
+
<Link href="/" startIcon={icons.add}>
|
|
418
|
+
Add new item
|
|
410
419
|
</Link>,
|
|
411
420
|
);
|
|
412
421
|
|
|
413
422
|
// Act
|
|
414
|
-
|
|
415
|
-
const
|
|
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(
|
|
419
|
-
|
|
428
|
+
expect(icon.innerHTML).toEqual(
|
|
429
|
+
expect.stringContaining(iconToExpect),
|
|
430
|
+
);
|
|
420
431
|
});
|
|
421
432
|
|
|
422
|
-
test("
|
|
433
|
+
test("render icon with link when endIcon prop is passed in", () => {
|
|
423
434
|
// Arrange
|
|
424
435
|
render(
|
|
425
|
-
<Link href="/"
|
|
426
|
-
Click
|
|
436
|
+
<Link href="/" endIcon={icons.caretRight}>
|
|
437
|
+
Click to go back
|
|
427
438
|
</Link>,
|
|
428
439
|
);
|
|
429
440
|
|
|
430
441
|
// Act
|
|
431
|
-
|
|
432
|
-
const
|
|
442
|
+
const link = screen.getByRole("link");
|
|
443
|
+
const icon = screen.getByTestId("end-icon");
|
|
433
444
|
|
|
434
445
|
// Assert
|
|
435
|
-
expect(link).
|
|
436
|
-
expect(
|
|
446
|
+
expect(link.innerHTML).toEqual(expect.stringContaining("<svg"));
|
|
447
|
+
expect(icon).toBeInTheDocument();
|
|
437
448
|
});
|
|
438
|
-
});
|
|
439
449
|
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
|
470
|
+
const externalIcon = screen.queryByTestId("external-icon");
|
|
451
471
|
|
|
452
472
|
// Assert
|
|
453
|
-
expect(
|
|
473
|
+
expect(externalIcon).not.toBeInTheDocument();
|
|
454
474
|
});
|
|
455
475
|
|
|
456
|
-
test("render
|
|
476
|
+
test("render endIcon instead of default externalIcon when `target='_blank'`", () => {
|
|
457
477
|
// Arrange
|
|
458
478
|
render(
|
|
459
|
-
<Link href="/" target="_blank">
|
|
460
|
-
|
|
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.
|
|
466
|
-
const
|
|
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(
|
|
490
|
+
expect(endIcon).toBeInTheDocument();
|
|
471
491
|
});
|
|
472
492
|
|
|
473
|
-
test("
|
|
493
|
+
test("endIcon prop passed down correctly", () => {
|
|
474
494
|
// Arrange
|
|
475
|
-
render(
|
|
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.
|
|
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).
|
|
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
|
-
|
|
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.
|
|
85
|
+
style={iconStyles.endIcon}
|
|
83
86
|
testId="external-icon"
|
|
84
87
|
/>
|
|
85
88
|
);
|
|
86
89
|
|
|
87
90
|
const linkContent = (
|
|
88
91
|
<>
|
|
89
|
-
{
|
|
90
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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,
|
package/src/components/link.tsx
CHANGED
|
@@ -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 = {
|