@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.
- package/CHANGELOG.md +25 -0
- package/dist/components/link.d.ts +15 -0
- package/dist/components/link.js.flow +18 -0
- package/dist/es/index.js +58 -12
- package/dist/index.js +60 -12
- package/package.json +5 -3
- package/src/__tests__/__snapshots__/custom-snapshot.test.tsx.snap +910 -176
- package/src/components/__tests__/link.test.tsx +115 -42
- package/src/components/link-core.tsx +74 -10
- package/src/components/link.tsx +15 -0
- package/tsconfig.json +2 -0
- package/tsconfig.tsbuildinfo +1 -1
- /package/src/components/__tests__/{link.flowtest.tsx → link.typestest.tsx} +0 -0
|
@@ -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,103 +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");
|
|
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(
|
|
368
|
-
expect(link).toHaveStyle(`outline: 1px solid ${Color.blue}`);
|
|
381
|
+
expect(icon).not.toBeInTheDocument();
|
|
369
382
|
});
|
|
383
|
+
});
|
|
370
384
|
|
|
371
|
-
|
|
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="/"
|
|
375
|
-
|
|
389
|
+
<Link href="/" startIcon={icons.add}>
|
|
390
|
+
Add new item
|
|
376
391
|
</Link>,
|
|
377
392
|
);
|
|
378
393
|
|
|
379
394
|
// Act
|
|
380
|
-
|
|
381
|
-
const
|
|
395
|
+
const link = screen.getByRole("link");
|
|
396
|
+
const icon = screen.getByTestId("start-icon");
|
|
382
397
|
|
|
383
398
|
// Assert
|
|
384
|
-
expect(link).
|
|
385
|
-
expect(
|
|
399
|
+
expect(link.innerHTML).toEqual(expect.stringContaining("<svg"));
|
|
400
|
+
expect(icon).toBeInTheDocument();
|
|
386
401
|
});
|
|
387
402
|
|
|
388
|
-
test("
|
|
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="/"
|
|
392
|
-
|
|
417
|
+
<Link href="/" startIcon={icons.add}>
|
|
418
|
+
Add new item
|
|
393
419
|
</Link>,
|
|
394
420
|
);
|
|
395
421
|
|
|
396
422
|
// Act
|
|
397
|
-
|
|
398
|
-
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";
|
|
399
426
|
|
|
400
427
|
// Assert
|
|
401
|
-
expect(
|
|
402
|
-
|
|
428
|
+
expect(icon.innerHTML).toEqual(
|
|
429
|
+
expect.stringContaining(iconToExpect),
|
|
430
|
+
);
|
|
403
431
|
});
|
|
404
432
|
|
|
405
|
-
test("
|
|
433
|
+
test("render icon with link when endIcon prop is passed in", () => {
|
|
406
434
|
// Arrange
|
|
407
435
|
render(
|
|
408
|
-
<Link href="/"
|
|
409
|
-
Click
|
|
436
|
+
<Link href="/" endIcon={icons.caretRight}>
|
|
437
|
+
Click to go back
|
|
410
438
|
</Link>,
|
|
411
439
|
);
|
|
412
440
|
|
|
413
441
|
// Act
|
|
414
|
-
|
|
415
|
-
const
|
|
442
|
+
const link = screen.getByRole("link");
|
|
443
|
+
const icon = screen.getByTestId("end-icon");
|
|
416
444
|
|
|
417
445
|
// Assert
|
|
418
|
-
expect(link).
|
|
419
|
-
expect(
|
|
446
|
+
expect(link.innerHTML).toEqual(expect.stringContaining("<svg"));
|
|
447
|
+
expect(icon).toBeInTheDocument();
|
|
420
448
|
});
|
|
421
449
|
|
|
422
|
-
test("
|
|
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="/"
|
|
426
|
-
|
|
464
|
+
<Link href="/" endIcon={icons.caretRight} target="_blank">
|
|
465
|
+
Open a new tab
|
|
427
466
|
</Link>,
|
|
428
467
|
);
|
|
429
468
|
|
|
430
469
|
// Act
|
|
431
|
-
|
|
432
|
-
const link = screen.getByText("Click me!");
|
|
470
|
+
const externalIcon = screen.queryByTestId("external-icon");
|
|
433
471
|
|
|
434
472
|
// Assert
|
|
435
|
-
expect(
|
|
436
|
-
|
|
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
|
|
24
|
-
const StyledLink = addStyle
|
|
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
|
-
|
|
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
|
-
{
|
|
118
|
+
{linkContent}
|
|
72
119
|
</StyledLink>
|
|
73
120
|
) : (
|
|
74
121
|
<StyledAnchor {...commonProps} href={href}>
|
|
75
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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,
|
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.
|
|
@@ -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 = {
|