@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windstream/react-shared-components",
3
- "version": "0.2.23",
3
+ "version": "0.2.24",
4
4
  "type": "module",
5
5
  "description": "Shared React components for Kinetic applications",
6
6
  "main": "dist/index.js",
@@ -7,10 +7,23 @@ import "@testing-library/jest-dom";
7
7
 
8
8
  // Mock next/link
9
9
  jest.mock("next/link", () => {
10
- const MockLink = ({ children, href, className, onClick, ...rest }: any) => (
11
- <a href={href} className={className} onClick={onClick} {...rest}>
12
- {children}
13
- </a>
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
- if (external || (typeof href === "string" && href.startsWith("http"))) {
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
- <a
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
- </a>
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
- const normalizedLocalPathPrefix = localPathPrefix
29
- ? `/${localPathPrefix.replace(/^\/+|\/+$/g, "")}`
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 href={getLocationHref(item.code)} className="label1">
134
+ <Link
135
+ href={getLocationHref(item.code)}
136
+ className="label1"
137
+ >
124
138
  {item.name}
125
139
  </Link>
126
140
  </li>
127
- ) : <li key={`empty-3-${index}`} aria-hidden="true" />
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 gap-x-20 gap-y-5">
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 key={`item-list-4-${index}`} className="w-[172px]">
149
- <Link href={getLocationHref(item.code)} className="label1">
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
- ) : <li key={`empty-4-${index}`} aria-hidden="true" />
175
+ ) : (
176
+ <li key={`empty-4-${index}`} aria-hidden="true" />
177
+ )
154
178
  );
155
179
  })()}
156
180
  </ul>
@@ -10,6 +10,7 @@ export type FindKineticProps = {
10
10
  maxWidth?: boolean;
11
11
  color?: "dark" | "light";
12
12
  columns?: 2 | 3 | 4;
13
+ addTrailingSlash?: boolean;
13
14
  };
14
15
 
15
16
  export type ThemeKey =