@windstream/react-shared-components 0.2.23 → 0.2.24
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/dist/contentful/index.d.ts +1 -0
- package/dist/contentful/index.esm.js +2 -2
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +2 -2
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +14 -2
- package/dist/index.d.ts +13 -1
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/next/index.esm.js +1 -1
- package/dist/next/index.esm.js.map +1 -1
- package/dist/next/index.js +1 -1
- package/dist/next/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/link/index.test.tsx +103 -4
- package/src/components/link/index.tsx +9 -1
- package/src/components/link/types.ts +12 -0
- package/src/contentful/blocks/breadcrumbs/index.tsx +3 -2
- package/src/contentful/blocks/find-kinetic/index.test.tsx +35 -0
- package/src/contentful/blocks/find-kinetic/index.tsx +33 -9
- package/src/contentful/blocks/find-kinetic/types.ts +1 -0
package/package.json
CHANGED
|
@@ -7,10 +7,23 @@ import "@testing-library/jest-dom";
|
|
|
7
7
|
|
|
8
8
|
// Mock next/link
|
|
9
9
|
jest.mock("next/link", () => {
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
const ReactModule = require("react");
|
|
11
|
+
const MockLink = ReactModule.forwardRef(
|
|
12
|
+
(
|
|
13
|
+
{ children, href, className, onClick, ...rest }: any,
|
|
14
|
+
ref: React.Ref<HTMLAnchorElement>
|
|
15
|
+
) => (
|
|
16
|
+
<a
|
|
17
|
+
ref={ref}
|
|
18
|
+
href={href}
|
|
19
|
+
className={className}
|
|
20
|
+
onClick={onClick}
|
|
21
|
+
data-mock="next-link"
|
|
22
|
+
{...rest}
|
|
23
|
+
>
|
|
24
|
+
{children}
|
|
25
|
+
</a>
|
|
26
|
+
)
|
|
14
27
|
);
|
|
15
28
|
MockLink.displayName = "MockNextLink";
|
|
16
29
|
return MockLink;
|
|
@@ -50,6 +63,67 @@ describe("Link", () => {
|
|
|
50
63
|
});
|
|
51
64
|
});
|
|
52
65
|
|
|
66
|
+
describe("preserveTrailingSlash", () => {
|
|
67
|
+
it("renders a plain <a> (bypassing next/link) when preserveTrailingSlash is true and href ends with '/'", () => {
|
|
68
|
+
render(
|
|
69
|
+
<Link href="/local/al/" preserveTrailingSlash>
|
|
70
|
+
Alabama
|
|
71
|
+
</Link>
|
|
72
|
+
);
|
|
73
|
+
const anchor = screen.getByText("Alabama").closest("a");
|
|
74
|
+
expect(anchor).toHaveAttribute("href", "/local/al/");
|
|
75
|
+
// plain <a> path does not carry the next/link mock marker
|
|
76
|
+
expect(anchor).not.toHaveAttribute("data-mock", "next-link");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("still renders via next/link when preserveTrailingSlash is false even if href ends with '/'", () => {
|
|
80
|
+
render(<Link href="/local/al/">Alabama</Link>);
|
|
81
|
+
const anchor = screen.getByText("Alabama").closest("a");
|
|
82
|
+
expect(anchor).toHaveAttribute("data-mock", "next-link");
|
|
83
|
+
expect(anchor).toHaveAttribute("href", "/local/al/");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("renders via next/link when preserveTrailingSlash is true but href has no trailing slash", () => {
|
|
87
|
+
render(
|
|
88
|
+
<Link href="/local/al" preserveTrailingSlash>
|
|
89
|
+
Alabama
|
|
90
|
+
</Link>
|
|
91
|
+
);
|
|
92
|
+
const anchor = screen.getByText("Alabama").closest("a");
|
|
93
|
+
expect(anchor).toHaveAttribute("data-mock", "next-link");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does not bypass next/link for protocol-relative hrefs even with a trailing slash", () => {
|
|
97
|
+
render(
|
|
98
|
+
<Link href="//example.com/" preserveTrailingSlash>
|
|
99
|
+
Protocol Relative
|
|
100
|
+
</Link>
|
|
101
|
+
);
|
|
102
|
+
const anchor = screen.getByText("Protocol Relative").closest("a");
|
|
103
|
+
expect(anchor).toHaveAttribute("data-mock", "next-link");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("applies style on both render paths (plain <a> and next/link)", () => {
|
|
107
|
+
const { rerender } = render(
|
|
108
|
+
<Link href="/local/al/" preserveTrailingSlash style={{ color: "red" }}>
|
|
109
|
+
Alabama
|
|
110
|
+
</Link>
|
|
111
|
+
);
|
|
112
|
+
let anchor = screen.getByText("Alabama").closest("a")!;
|
|
113
|
+
expect(anchor).not.toHaveAttribute("data-mock", "next-link");
|
|
114
|
+
expect(anchor.style.color).toBe("red");
|
|
115
|
+
|
|
116
|
+
rerender(
|
|
117
|
+
<Link href="/internal" style={{ color: "red" }}>
|
|
118
|
+
Internal
|
|
119
|
+
</Link>
|
|
120
|
+
);
|
|
121
|
+
anchor = screen.getByText("Internal").closest("a")!;
|
|
122
|
+
expect(anchor).toHaveAttribute("data-mock", "next-link");
|
|
123
|
+
expect(anchor.style.color).toBe("red");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
53
127
|
describe("Variants", () => {
|
|
54
128
|
it("applies unstyled variant with no extra classes", () => {
|
|
55
129
|
render(
|
|
@@ -104,6 +178,20 @@ describe("Link", () => {
|
|
|
104
178
|
expect(el).toHaveAttribute("tabindex", "-1");
|
|
105
179
|
});
|
|
106
180
|
|
|
181
|
+
it("applies disabled accessibility attributes on internal links", () => {
|
|
182
|
+
render(
|
|
183
|
+
<Link href="/test" disabled={true}>
|
|
184
|
+
Disabled
|
|
185
|
+
</Link>
|
|
186
|
+
);
|
|
187
|
+
const el = screen.getByText("Disabled").closest("a");
|
|
188
|
+
// disabled links render as a plain <a>, not via next/link
|
|
189
|
+
expect(el).not.toHaveAttribute("data-mock", "next-link");
|
|
190
|
+
expect(el).toHaveAttribute("aria-disabled", "true");
|
|
191
|
+
expect(el).toHaveAttribute("tabindex", "-1");
|
|
192
|
+
expect(el).not.toHaveAttribute("href");
|
|
193
|
+
});
|
|
194
|
+
|
|
107
195
|
it("prevents click when disabled", () => {
|
|
108
196
|
const onClick = jest.fn();
|
|
109
197
|
render(
|
|
@@ -173,6 +261,17 @@ describe("Link", () => {
|
|
|
173
261
|
);
|
|
174
262
|
expect(ref.current).toBeInstanceOf(HTMLAnchorElement);
|
|
175
263
|
});
|
|
264
|
+
|
|
265
|
+
it("forwards ref to the anchor element for internal (next/link) links", () => {
|
|
266
|
+
const ref = React.createRef<HTMLAnchorElement>();
|
|
267
|
+
render(
|
|
268
|
+
<Link ref={ref} href="/internal">
|
|
269
|
+
Internal Ref
|
|
270
|
+
</Link>
|
|
271
|
+
);
|
|
272
|
+
expect(ref.current).toBeInstanceOf(HTMLAnchorElement);
|
|
273
|
+
expect(ref.current).toHaveAttribute("data-mock", "next-link");
|
|
274
|
+
});
|
|
176
275
|
});
|
|
177
276
|
|
|
178
277
|
describe("Custom className and style", () => {
|
|
@@ -23,6 +23,7 @@ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
|
|
|
23
23
|
style,
|
|
24
24
|
external = false,
|
|
25
25
|
disabled = false,
|
|
26
|
+
preserveTrailingSlash = false,
|
|
26
27
|
...props
|
|
27
28
|
},
|
|
28
29
|
ref
|
|
@@ -95,13 +96,20 @@ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
|
|
|
95
96
|
tabIndex: -1,
|
|
96
97
|
}),
|
|
97
98
|
};
|
|
98
|
-
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
disabled ||
|
|
102
|
+
external ||
|
|
103
|
+
(typeof href === "string" && href.startsWith("http"))
|
|
104
|
+
) {
|
|
99
105
|
return <a {...linkProps}>{children}</a>;
|
|
100
106
|
}
|
|
101
107
|
return (
|
|
102
108
|
<NextLink
|
|
103
109
|
href={href || "#"}
|
|
110
|
+
ref={ref}
|
|
104
111
|
className={combinedClassName}
|
|
112
|
+
style={style}
|
|
105
113
|
onClick={handleClick}
|
|
106
114
|
{...props}
|
|
107
115
|
>
|
|
@@ -22,4 +22,16 @@ export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
|
22
22
|
external?: boolean;
|
|
23
23
|
/** Disable the link */
|
|
24
24
|
disabled?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Preserve an intentional trailing slash on internal links.
|
|
27
|
+
* next/link normalizes the trailing slash based on the app's `trailingSlash`
|
|
28
|
+
* config (stripping it when `false`). When this is `true` and the href ends
|
|
29
|
+
* with a slash, the link renders as a plain anchor so the slash is kept.
|
|
30
|
+
*
|
|
31
|
+
* Tradeoff: because it renders a plain `<a>` instead of `next/link`, this
|
|
32
|
+
* link bypasses Next.js client-side navigation and prefetch — clicking it
|
|
33
|
+
* triggers a full page load. Only opt in when preserving the trailing slash
|
|
34
|
+
* matters more than client-side routing.
|
|
35
|
+
*/
|
|
36
|
+
preserveTrailingSlash?: boolean;
|
|
25
37
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { BreadcrumbNavigationProps } from "./types";
|
|
3
3
|
|
|
4
|
+
import { Link } from "@shared/components/link";
|
|
4
5
|
import { MaterialIcon } from "@shared/components/material-icon";
|
|
5
6
|
import { Text } from "@shared/components/text";
|
|
6
7
|
|
|
@@ -61,12 +62,12 @@ export const BreadcrumbNavigation: React.FC<
|
|
|
61
62
|
className="mr-2 h-10 w-10"
|
|
62
63
|
/>
|
|
63
64
|
)}
|
|
64
|
-
<
|
|
65
|
+
<Link
|
|
65
66
|
href={linkProps.href}
|
|
66
67
|
className={`label3 mr-2 whitespace-nowrap ${color} hover:underline`}
|
|
67
68
|
>
|
|
68
69
|
{linkProps.buttonLabel}
|
|
69
|
-
</
|
|
70
|
+
</Link>
|
|
70
71
|
|
|
71
72
|
<MaterialIcon name="chevron_right" className={`${color} `} />
|
|
72
73
|
</li>
|
|
@@ -202,6 +202,41 @@ describe("FindKinetic", () => {
|
|
|
202
202
|
});
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
+
it("does not append a trailing slash by default", () => {
|
|
206
|
+
render(<FindKinetic list={[{ name: "Alabama", code: "AL" }]} />);
|
|
207
|
+
const links = screen.getAllByText("Alabama");
|
|
208
|
+
links.forEach(link => {
|
|
209
|
+
expect(link.closest("a")).toHaveAttribute("href", "/local/al");
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("appends a trailing slash when addTrailingSlash is true", () => {
|
|
214
|
+
render(
|
|
215
|
+
<FindKinetic
|
|
216
|
+
list={[{ name: "Alabama", code: "AL" }]}
|
|
217
|
+
addTrailingSlash
|
|
218
|
+
/>
|
|
219
|
+
);
|
|
220
|
+
const links = screen.getAllByText("Alabama");
|
|
221
|
+
links.forEach(link => {
|
|
222
|
+
expect(link.closest("a")).toHaveAttribute("href", "/local/al/");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("does not produce a protocol-relative href when localPathPrefix is '/'", () => {
|
|
227
|
+
render(
|
|
228
|
+
<FindKinetic
|
|
229
|
+
list={[{ name: "Alabama", code: "AL" }]}
|
|
230
|
+
localPathPrefix="/"
|
|
231
|
+
/>
|
|
232
|
+
);
|
|
233
|
+
const links = screen.getAllByText("Alabama");
|
|
234
|
+
links.forEach(link => {
|
|
235
|
+
expect(link.closest("a")).toHaveAttribute("href", "/al");
|
|
236
|
+
expect(link.closest("a")?.getAttribute("href")).not.toMatch(/^\/\//);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
205
240
|
it("sorts list items alphabetically by name", () => {
|
|
206
241
|
const unsortedList = [
|
|
207
242
|
{ name: "Texas", code: "TX" },
|
|
@@ -16,6 +16,7 @@ export const FindKinetic: React.FC<FindKineticProps> = ({
|
|
|
16
16
|
title,
|
|
17
17
|
color = "dark",
|
|
18
18
|
maxWidth = true,
|
|
19
|
+
addTrailingSlash = false,
|
|
19
20
|
}) => {
|
|
20
21
|
const bgColorClasses: Record<ThemeKey, string> = {
|
|
21
22
|
blue: "bg-[#07B2E2]",
|
|
@@ -25,12 +26,22 @@ export const FindKinetic: React.FC<FindKineticProps> = ({
|
|
|
25
26
|
white: "bg-white",
|
|
26
27
|
navy: "bg-[#00002D]",
|
|
27
28
|
};
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
// Trim leading/trailing slashes without a regex to avoid ReDoS on long
|
|
30
|
+
// runs of "/" (CodeQL: polynomial regular expression on uncontrolled data).
|
|
31
|
+
const trimSlashes = (value: string) => {
|
|
32
|
+
let start = 0;
|
|
33
|
+
let end = value.length;
|
|
34
|
+
while (start < end && value.charCodeAt(start) === 47 /* "/" */) start += 1;
|
|
35
|
+
while (end > start && value.charCodeAt(end - 1) === 47 /* "/" */) end -= 1;
|
|
36
|
+
return value.slice(start, end);
|
|
37
|
+
};
|
|
38
|
+
const cleanedLocalPathPrefix = trimSlashes(localPathPrefix ?? "");
|
|
39
|
+
const normalizedLocalPathPrefix = cleanedLocalPathPrefix
|
|
40
|
+
? `/${cleanedLocalPathPrefix}`
|
|
30
41
|
: "";
|
|
31
42
|
|
|
32
43
|
const getLocationHref = (code: string) =>
|
|
33
|
-
`${normalizedLocalPathPrefix}/${code.toLowerCase()}`;
|
|
44
|
+
`${normalizedLocalPathPrefix}/${code.toLowerCase()}${addTrailingSlash ? "/" : ""}`;
|
|
34
45
|
|
|
35
46
|
return (
|
|
36
47
|
<div
|
|
@@ -120,16 +131,21 @@ export const FindKinetic: React.FC<FindKineticProps> = ({
|
|
|
120
131
|
key={`item-list-3-${index}`}
|
|
121
132
|
className={`${image ? "" : "w-[172px]"}`}
|
|
122
133
|
>
|
|
123
|
-
<Link
|
|
134
|
+
<Link
|
|
135
|
+
href={getLocationHref(item.code)}
|
|
136
|
+
className="label1"
|
|
137
|
+
>
|
|
124
138
|
{item.name}
|
|
125
139
|
</Link>
|
|
126
140
|
</li>
|
|
127
|
-
) :
|
|
141
|
+
) : (
|
|
142
|
+
<li key={`empty-3-${index}`} aria-hidden="true" />
|
|
143
|
+
)
|
|
128
144
|
);
|
|
129
145
|
})()}
|
|
130
146
|
</ul>
|
|
131
147
|
{!image && (
|
|
132
|
-
<ul className="hidden xl:grid xl:grid-cols-4
|
|
148
|
+
<ul className="hidden gap-x-20 gap-y-5 xl:grid xl:grid-cols-4">
|
|
133
149
|
{(() => {
|
|
134
150
|
const sortedList = [...list].sort((a, b) =>
|
|
135
151
|
a.name.localeCompare(b.name)
|
|
@@ -145,12 +161,20 @@ export const FindKinetic: React.FC<FindKineticProps> = ({
|
|
|
145
161
|
}
|
|
146
162
|
return rearranged.map((item, index: number) =>
|
|
147
163
|
item ? (
|
|
148
|
-
<li
|
|
149
|
-
|
|
164
|
+
<li
|
|
165
|
+
key={`item-list-4-${index}`}
|
|
166
|
+
className="w-[172px]"
|
|
167
|
+
>
|
|
168
|
+
<Link
|
|
169
|
+
href={getLocationHref(item.code)}
|
|
170
|
+
className="label1"
|
|
171
|
+
>
|
|
150
172
|
{item.name}
|
|
151
173
|
</Link>
|
|
152
174
|
</li>
|
|
153
|
-
) :
|
|
175
|
+
) : (
|
|
176
|
+
<li key={`empty-4-${index}`} aria-hidden="true" />
|
|
177
|
+
)
|
|
154
178
|
);
|
|
155
179
|
})()}
|
|
156
180
|
</ul>
|