@fpkit/acss 0.5.13 → 0.6.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.
Files changed (280) hide show
  1. package/libs/{chunk-PQ2K3BM6.cjs → chunk-2NRIP6RB.cjs} +3 -3
  2. package/libs/chunk-33PNJ4LO.cjs +15 -0
  3. package/libs/chunk-33PNJ4LO.cjs.map +1 -0
  4. package/libs/chunk-4BZKFPEC.cjs +17 -0
  5. package/libs/chunk-4BZKFPEC.cjs.map +1 -0
  6. package/libs/{chunk-772NRB75.js → chunk-5QD3DWFI.js} +2 -2
  7. package/libs/chunk-6SAHIYCZ.js +7 -0
  8. package/libs/chunk-6SAHIYCZ.js.map +1 -0
  9. package/libs/{chunk-3MKLDCKQ.cjs → chunk-6WTC4JXH.cjs} +3 -3
  10. package/libs/chunk-75QHTLFO.js +7 -0
  11. package/libs/chunk-75QHTLFO.js.map +1 -0
  12. package/libs/{chunk-ZANSFMTD.js → chunk-7XPFW7CB.js} +3 -3
  13. package/libs/chunk-BFK62VX5.js +5 -0
  14. package/libs/chunk-BFK62VX5.js.map +1 -0
  15. package/libs/{chunk-ROZI23GS.cjs → chunk-DKTHCQ5P.cjs} +4 -4
  16. package/libs/chunk-E2AJURUW.cjs +13 -0
  17. package/libs/chunk-E2AJURUW.cjs.map +1 -0
  18. package/libs/{chunk-L75OQKEI.cjs → chunk-ENTCUJ3A.cjs} +3 -3
  19. package/libs/chunk-ENTCUJ3A.cjs.map +1 -0
  20. package/libs/chunk-F5EYMVQM.js +10 -0
  21. package/libs/chunk-F5EYMVQM.js.map +1 -0
  22. package/libs/chunk-FVROL3V5.js +9 -0
  23. package/libs/chunk-FVROL3V5.js.map +1 -0
  24. package/libs/chunk-GT77BX4L.cjs +17 -0
  25. package/libs/chunk-GT77BX4L.cjs.map +1 -0
  26. package/libs/chunk-GUJSMQ3V.cjs +16 -0
  27. package/libs/chunk-GUJSMQ3V.cjs.map +1 -0
  28. package/libs/chunk-HHLNOC5T.js +7 -0
  29. package/libs/chunk-HHLNOC5T.js.map +1 -0
  30. package/libs/chunk-HRRHPLER.js +8 -0
  31. package/libs/chunk-HRRHPLER.js.map +1 -0
  32. package/libs/chunk-IEB64SWY.js +8 -0
  33. package/libs/chunk-IEB64SWY.js.map +1 -0
  34. package/libs/{chunk-NGTJDDFO.js → chunk-IQ76HGVP.js} +2 -2
  35. package/libs/chunk-IRLFZ3OL.js +9 -0
  36. package/libs/chunk-IRLFZ3OL.js.map +1 -0
  37. package/libs/{chunk-JJ43O4Y5.js → chunk-KK47SYZI.js} +2 -2
  38. package/libs/chunk-O3JIHC5M.cjs +15 -0
  39. package/libs/chunk-O3JIHC5M.cjs.map +1 -0
  40. package/libs/chunk-O5XAJ7BY.cjs +18 -0
  41. package/libs/chunk-O5XAJ7BY.cjs.map +1 -0
  42. package/libs/chunk-OVWLQYMK.js +10 -0
  43. package/libs/chunk-OVWLQYMK.js.map +1 -0
  44. package/libs/chunk-PNWIRCG3.cjs +7 -0
  45. package/libs/chunk-PNWIRCG3.cjs.map +1 -0
  46. package/libs/{chunk-D4YLRWAO.cjs → chunk-QVW6W76L.cjs} +6 -6
  47. package/libs/chunk-T4T6GWYQ.cjs +17 -0
  48. package/libs/chunk-T4T6GWYQ.cjs.map +1 -0
  49. package/libs/chunk-TON2YGMD.cjs +9 -0
  50. package/libs/chunk-TON2YGMD.cjs.map +1 -0
  51. package/libs/chunk-UEPAWMDF.js +8 -0
  52. package/libs/chunk-UEPAWMDF.js.map +1 -0
  53. package/libs/{chunk-LT5KZ2QW.cjs → chunk-US2I5GI7.cjs} +3 -3
  54. package/libs/{chunk-B7F5FS6D.cjs → chunk-W2UIN7EV.cjs} +3 -3
  55. package/libs/{chunk-P2DC76ZZ.cjs → chunk-W5TKWBFC.cjs} +3 -3
  56. package/libs/chunk-WXBFBWYF.cjs +16 -0
  57. package/libs/chunk-WXBFBWYF.cjs.map +1 -0
  58. package/libs/{chunk-VUH3FXGJ.js → chunk-X3JCTEPD.js} +5 -5
  59. package/libs/chunk-X5LGFCWG.js +9 -0
  60. package/libs/chunk-X5LGFCWG.js.map +1 -0
  61. package/libs/{chunk-5M57K4SW.js → chunk-Y2PFDELK.js} +2 -2
  62. package/libs/{chunk-ETFLFC2S.js → chunk-ZFJ4U45S.js} +2 -2
  63. package/libs/{component-props-a8a2f97e.d.ts → component-props-67d978a2.d.ts} +4 -4
  64. package/libs/components/alert/alert.css +1 -1
  65. package/libs/components/alert/alert.css.map +1 -1
  66. package/libs/components/alert/alert.min.css +2 -2
  67. package/libs/components/breadcrumbs/breadcrumb.cjs +6 -6
  68. package/libs/components/breadcrumbs/breadcrumb.d.cts +11 -11
  69. package/libs/components/breadcrumbs/breadcrumb.d.ts +11 -11
  70. package/libs/components/breadcrumbs/breadcrumb.js +3 -3
  71. package/libs/components/button.cjs +6 -4
  72. package/libs/components/button.d.cts +97 -4
  73. package/libs/components/button.d.ts +97 -4
  74. package/libs/components/button.js +4 -2
  75. package/libs/components/card.cjs +7 -7
  76. package/libs/components/card.d.cts +14 -14
  77. package/libs/components/card.d.ts +14 -14
  78. package/libs/components/card.js +2 -2
  79. package/libs/components/dialog/dialog.cjs +9 -7
  80. package/libs/components/dialog/dialog.d.cts +3 -3
  81. package/libs/components/dialog/dialog.d.ts +3 -3
  82. package/libs/components/dialog/dialog.js +7 -5
  83. package/libs/components/form/fields.cjs +4 -4
  84. package/libs/components/form/fields.d.cts +16 -7
  85. package/libs/components/form/fields.d.ts +16 -7
  86. package/libs/components/form/fields.js +2 -2
  87. package/libs/components/form/inputs.cjs +6 -4
  88. package/libs/components/form/inputs.d.cts +50 -2
  89. package/libs/components/form/inputs.d.ts +50 -2
  90. package/libs/components/form/inputs.js +4 -2
  91. package/libs/components/form/textarea.cjs +5 -4
  92. package/libs/components/form/textarea.d.cts +32 -23
  93. package/libs/components/form/textarea.d.ts +32 -23
  94. package/libs/components/form/textarea.js +3 -2
  95. package/libs/components/heading/heading.cjs +3 -3
  96. package/libs/components/heading/heading.d.cts +2 -2
  97. package/libs/components/heading/heading.d.ts +2 -2
  98. package/libs/components/heading/heading.js +2 -2
  99. package/libs/components/icons/icon.cjs +4 -4
  100. package/libs/components/icons/icon.d.cts +38 -38
  101. package/libs/components/icons/icon.d.ts +38 -38
  102. package/libs/components/icons/icon.js +2 -2
  103. package/libs/components/link/link.cjs +4 -4
  104. package/libs/components/link/link.css +1 -1
  105. package/libs/components/link/link.css.map +1 -1
  106. package/libs/components/link/link.d.cts +3 -19
  107. package/libs/components/link/link.d.ts +3 -19
  108. package/libs/components/link/link.js +2 -2
  109. package/libs/components/link/link.min.css +2 -2
  110. package/libs/components/list/list.cjs +5 -5
  111. package/libs/components/list/list.css +1 -0
  112. package/libs/components/list/list.css.map +1 -0
  113. package/libs/components/list/list.d.cts +120 -33
  114. package/libs/components/list/list.d.ts +120 -33
  115. package/libs/components/list/list.js +2 -2
  116. package/libs/components/list/list.min.css +3 -0
  117. package/libs/components/modal.cjs +6 -4
  118. package/libs/components/modal.d.cts +8 -8
  119. package/libs/components/modal.d.ts +8 -8
  120. package/libs/components/modal.js +5 -3
  121. package/libs/components/nav/nav.cjs +7 -7
  122. package/libs/components/nav/nav.css +1 -1
  123. package/libs/components/nav/nav.css.map +1 -1
  124. package/libs/components/nav/nav.d.cts +550 -34
  125. package/libs/components/nav/nav.d.ts +550 -34
  126. package/libs/components/nav/nav.js +3 -3
  127. package/libs/components/nav/nav.min.css +2 -2
  128. package/libs/components/popover/popover.d.cts +5 -5
  129. package/libs/components/popover/popover.d.ts +5 -5
  130. package/libs/components/tables/table.cjs +5 -5
  131. package/libs/components/tables/table.d.cts +8 -8
  132. package/libs/components/tables/table.d.ts +8 -8
  133. package/libs/components/tables/table.js +2 -2
  134. package/libs/components/tag/tag.css +1 -1
  135. package/libs/components/tag/tag.css.map +1 -1
  136. package/libs/components/tag/tag.min.css +2 -2
  137. package/libs/components/text/text.cjs +5 -5
  138. package/libs/components/text/text.d.cts +5 -5
  139. package/libs/components/text/text.d.ts +5 -5
  140. package/libs/components/text/text.js +2 -2
  141. package/libs/form.types-d25ebfac.d.ts +233 -0
  142. package/libs/{heading-3648c538.d.ts → heading-7446cb46.d.ts} +8 -8
  143. package/libs/hooks.cjs +9 -4
  144. package/libs/hooks.d.cts +137 -3
  145. package/libs/hooks.d.ts +137 -3
  146. package/libs/hooks.js +4 -3
  147. package/libs/icons.cjs +3 -3
  148. package/libs/icons.d.cts +2 -2
  149. package/libs/icons.d.ts +2 -2
  150. package/libs/icons.js +2 -2
  151. package/libs/index.cjs +53 -51
  152. package/libs/index.cjs.map +1 -1
  153. package/libs/index.css +1 -1
  154. package/libs/index.css.map +1 -1
  155. package/libs/index.d.cts +338 -49
  156. package/libs/index.d.ts +338 -49
  157. package/libs/index.js +24 -22
  158. package/libs/index.js.map +1 -1
  159. package/libs/link-5192f411.d.ts +323 -0
  160. package/libs/list.types-d26de310.d.ts +245 -0
  161. package/libs/{ui-645f95b5.d.ts → ui-d01b50d4.d.ts} +16 -12
  162. package/package.json +4 -6
  163. package/src/components/alert/alert.scss +1 -4
  164. package/src/components/breadcrumbs/breadcrumb.tsx +4 -1
  165. package/src/components/buttons/README.mdx +102 -1
  166. package/src/components/buttons/button.stories.tsx +106 -0
  167. package/src/components/buttons/button.tsx +82 -52
  168. package/src/components/dialog/dialog-a11y-review.md +653 -0
  169. package/src/components/form/README.mdx +725 -43
  170. package/src/components/form/WCAG-REVIEW.md +654 -0
  171. package/src/components/form/fields.tsx +10 -1
  172. package/src/components/form/form.stories.tsx +604 -23
  173. package/src/components/form/form.tsx +204 -63
  174. package/src/components/form/form.types.ts +378 -0
  175. package/src/components/form/input.stories.tsx +71 -3
  176. package/src/components/form/inputs.tsx +159 -67
  177. package/src/components/form/select.tsx +122 -66
  178. package/src/components/form/textarea.tsx +120 -73
  179. package/src/components/fp.tsx +86 -11
  180. package/src/components/link/README.mdx +923 -0
  181. package/src/components/link/link.scss +79 -26
  182. package/src/components/link/link.stories.tsx +383 -30
  183. package/src/components/link/link.test.tsx +677 -0
  184. package/src/components/link/link.tsx +163 -57
  185. package/src/components/link/link.types.ts +261 -0
  186. package/src/components/list/README.mdx +764 -0
  187. package/src/components/list/list.scss +285 -0
  188. package/src/components/list/list.stories.tsx +514 -27
  189. package/src/components/list/list.test.tsx +554 -0
  190. package/src/components/list/list.tsx +153 -51
  191. package/src/components/list/list.types.ts +255 -0
  192. package/src/components/nav/ACCESSIBILITY.md +649 -0
  193. package/src/components/nav/README.mdx +782 -0
  194. package/src/components/nav/nav.scss +32 -1
  195. package/src/components/nav/nav.stories.tsx +44 -6
  196. package/src/components/nav/nav.tsx +302 -51
  197. package/src/components/nav/nav.types.ts +308 -0
  198. package/src/components/tag/README.mdx +426 -0
  199. package/src/components/tag/tag.scss +101 -27
  200. package/src/components/tag/tag.stories.tsx +384 -10
  201. package/src/components/tag/tag.test.tsx +210 -0
  202. package/src/components/tag/tag.tsx +106 -9
  203. package/src/components/tag/tag.types.ts +107 -0
  204. package/src/components/ui.tsx +8 -3
  205. package/src/hooks/use-disabled-state.test.tsx +536 -0
  206. package/src/hooks/use-disabled-state.ts +246 -0
  207. package/src/hooks/useDisabledState.md +393 -0
  208. package/src/hooks.ts +6 -0
  209. package/src/index.scss +2 -0
  210. package/src/index.ts +2 -1
  211. package/src/sass/_globals.scss +2 -7
  212. package/src/styles/alert/alert.css +1 -3
  213. package/src/styles/alert/alert.css.map +1 -1
  214. package/src/styles/index.css +450 -76
  215. package/src/styles/index.css.map +1 -1
  216. package/src/styles/link/link.css +45 -28
  217. package/src/styles/link/link.css.map +1 -1
  218. package/src/styles/list/list.css +214 -0
  219. package/src/styles/list/list.css.map +1 -0
  220. package/src/styles/nav/nav.css +21 -1
  221. package/src/styles/nav/nav.css.map +1 -1
  222. package/src/styles/tag/tag.css +113 -35
  223. package/src/styles/tag/tag.css.map +1 -1
  224. package/src/styles/utilities/_disabled.scss +58 -0
  225. package/src/types/shared.ts +43 -6
  226. package/src/utils/accessibility.ts +109 -0
  227. package/libs/chunk-2LTJ7HHX.cjs +0 -18
  228. package/libs/chunk-2LTJ7HHX.cjs.map +0 -1
  229. package/libs/chunk-2Y7W75TT.js +0 -9
  230. package/libs/chunk-2Y7W75TT.js.map +0 -1
  231. package/libs/chunk-5S4ORA4C.cjs +0 -15
  232. package/libs/chunk-5S4ORA4C.cjs.map +0 -1
  233. package/libs/chunk-AHDJGCG5.cjs +0 -15
  234. package/libs/chunk-AHDJGCG5.cjs.map +0 -1
  235. package/libs/chunk-BHRQBJRY.js +0 -8
  236. package/libs/chunk-BHRQBJRY.js.map +0 -1
  237. package/libs/chunk-GZ4QFPRY.js +0 -9
  238. package/libs/chunk-GZ4QFPRY.js.map +0 -1
  239. package/libs/chunk-IYUN2EW3.cjs +0 -15
  240. package/libs/chunk-IYUN2EW3.cjs.map +0 -1
  241. package/libs/chunk-J32EZPYD.cjs +0 -15
  242. package/libs/chunk-J32EZPYD.cjs.map +0 -1
  243. package/libs/chunk-KUKIVRC2.js +0 -7
  244. package/libs/chunk-KUKIVRC2.js.map +0 -1
  245. package/libs/chunk-L75OQKEI.cjs.map +0 -1
  246. package/libs/chunk-M5RRNTVX.cjs +0 -15
  247. package/libs/chunk-M5RRNTVX.cjs.map +0 -1
  248. package/libs/chunk-OK5QEIMD.cjs +0 -17
  249. package/libs/chunk-OK5QEIMD.cjs.map +0 -1
  250. package/libs/chunk-P7TTEYCD.js +0 -7
  251. package/libs/chunk-P7TTEYCD.js.map +0 -1
  252. package/libs/chunk-QLZWHAMK.js +0 -8
  253. package/libs/chunk-QLZWHAMK.js.map +0 -1
  254. package/libs/chunk-RIVUMPOG.js +0 -8
  255. package/libs/chunk-RIVUMPOG.js.map +0 -1
  256. package/libs/chunk-S7BABR7Z.cjs +0 -13
  257. package/libs/chunk-S7BABR7Z.cjs.map +0 -1
  258. package/libs/chunk-SMYRLO3E.js +0 -8
  259. package/libs/chunk-SMYRLO3E.js.map +0 -1
  260. package/libs/chunk-TYRCEX2L.js +0 -8
  261. package/libs/chunk-TYRCEX2L.js.map +0 -1
  262. package/libs/chunk-XBA562WW.js +0 -8
  263. package/libs/chunk-XBA562WW.js.map +0 -1
  264. package/libs/chunk-XTQKWY7W.cjs +0 -32
  265. package/libs/chunk-XTQKWY7W.cjs.map +0 -1
  266. package/libs/inputs-f3a216db.d.ts +0 -45
  267. /package/libs/{chunk-PQ2K3BM6.cjs.map → chunk-2NRIP6RB.cjs.map} +0 -0
  268. /package/libs/{chunk-772NRB75.js.map → chunk-5QD3DWFI.js.map} +0 -0
  269. /package/libs/{chunk-3MKLDCKQ.cjs.map → chunk-6WTC4JXH.cjs.map} +0 -0
  270. /package/libs/{chunk-ZANSFMTD.js.map → chunk-7XPFW7CB.js.map} +0 -0
  271. /package/libs/{chunk-ROZI23GS.cjs.map → chunk-DKTHCQ5P.cjs.map} +0 -0
  272. /package/libs/{chunk-NGTJDDFO.js.map → chunk-IQ76HGVP.js.map} +0 -0
  273. /package/libs/{chunk-JJ43O4Y5.js.map → chunk-KK47SYZI.js.map} +0 -0
  274. /package/libs/{chunk-D4YLRWAO.cjs.map → chunk-QVW6W76L.cjs.map} +0 -0
  275. /package/libs/{chunk-LT5KZ2QW.cjs.map → chunk-US2I5GI7.cjs.map} +0 -0
  276. /package/libs/{chunk-B7F5FS6D.cjs.map → chunk-W2UIN7EV.cjs.map} +0 -0
  277. /package/libs/{chunk-P2DC76ZZ.cjs.map → chunk-W5TKWBFC.cjs.map} +0 -0
  278. /package/libs/{chunk-VUH3FXGJ.js.map → chunk-X3JCTEPD.js.map} +0 -0
  279. /package/libs/{chunk-5M57K4SW.js.map → chunk-Y2PFDELK.js.map} +0 -0
  280. /package/libs/{chunk-ETFLFC2S.js.map → chunk-ZFJ4U45S.js.map} +0 -0
@@ -0,0 +1,677 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import React from "react";
5
+ import Link from "./link";
6
+
7
+ describe("Link Component", () => {
8
+ describe("Rendering", () => {
9
+ it("should render with href and children", () => {
10
+ render(<Link href="/about">About Us</Link>);
11
+
12
+ const link = screen.getByRole("link");
13
+ expect(link).toBeInTheDocument();
14
+ expect(link).toHaveTextContent("About Us");
15
+ expect(link).toHaveAttribute("href", "/about");
16
+ });
17
+
18
+ it("should render as an anchor element", () => {
19
+ render(<Link href="/test">Link</Link>);
20
+
21
+ const link = screen.getByRole("link");
22
+ expect(link.tagName).toBe("A");
23
+ });
24
+
25
+ it("should render with custom classes via UI component", () => {
26
+ render(
27
+ <Link href="/" classes="custom-link-class">
28
+ Test
29
+ </Link>
30
+ );
31
+
32
+ const link = screen.getByRole("link");
33
+ expect(link).toHaveClass("custom-link-class");
34
+ });
35
+
36
+ it("should apply custom styles via styles prop", () => {
37
+ render(
38
+ <Link href="/" styles={{ color: "red" }}>
39
+ Styled Link
40
+ </Link>
41
+ );
42
+
43
+ const link = screen.getByRole("link");
44
+ const styleAttr = link.getAttribute("style") || "";
45
+ expect(styleAttr).toContain("color");
46
+ });
47
+
48
+ it("should render children correctly", () => {
49
+ render(
50
+ <Link href="/">
51
+ <span data-testid="child-element">Child Content</span>
52
+ </Link>
53
+ );
54
+
55
+ expect(screen.getByTestId("child-element")).toBeInTheDocument();
56
+ expect(screen.getByText("Child Content")).toBeInTheDocument();
57
+ });
58
+ });
59
+
60
+ describe("Target Attribute", () => {
61
+ it("should render with target attribute", () => {
62
+ render(
63
+ <Link href="https://example.com" target="_blank">
64
+ External
65
+ </Link>
66
+ );
67
+
68
+ const link = screen.getByRole("link");
69
+ expect(link).toHaveAttribute("target", "_blank");
70
+ });
71
+
72
+ it("should work with different target values", () => {
73
+ const { rerender } = render(
74
+ <Link href="/" target="_self">
75
+ Self
76
+ </Link>
77
+ );
78
+
79
+ let link = screen.getByRole("link");
80
+ expect(link).toHaveAttribute("target", "_self");
81
+
82
+ rerender(
83
+ <Link href="/" target="_parent">
84
+ Parent
85
+ </Link>
86
+ );
87
+ link = screen.getByRole("link");
88
+ expect(link).toHaveAttribute("target", "_parent");
89
+ });
90
+ });
91
+
92
+ describe("Security (rel attribute)", () => {
93
+ it("should automatically add security attributes for target=_blank", () => {
94
+ render(
95
+ <Link href="https://example.com" target="_blank">
96
+ External Link
97
+ </Link>
98
+ );
99
+
100
+ const link = screen.getByRole("link");
101
+ const rel = link.getAttribute("rel");
102
+
103
+ expect(rel).toContain("noopener");
104
+ expect(rel).toContain("noreferrer");
105
+ });
106
+
107
+ it("should merge custom rel with security defaults for target=_blank", () => {
108
+ render(
109
+ <Link href="https://example.com" target="_blank" rel="nofollow author">
110
+ External Link
111
+ </Link>
112
+ );
113
+
114
+ const link = screen.getByRole("link");
115
+ const rel = link.getAttribute("rel");
116
+
117
+ // Should have security defaults
118
+ expect(rel).toContain("noopener");
119
+ expect(rel).toContain("noreferrer");
120
+
121
+ // Should also have custom values
122
+ expect(rel).toContain("nofollow");
123
+ expect(rel).toContain("author");
124
+ });
125
+
126
+ it("should use provided rel as-is when target is not _blank", () => {
127
+ render(
128
+ <Link href="/internal" rel="author">
129
+ Internal Link
130
+ </Link>
131
+ );
132
+
133
+ const link = screen.getByRole("link");
134
+ expect(link).toHaveAttribute("rel", "author");
135
+ });
136
+
137
+ it("should not add security attributes for internal links", () => {
138
+ render(<Link href="/about">About</Link>);
139
+
140
+ const link = screen.getByRole("link");
141
+ expect(link).not.toHaveAttribute("rel");
142
+ });
143
+
144
+ it("should handle empty rel attribute gracefully", () => {
145
+ render(
146
+ <Link href="https://example.com" target="_blank" rel="">
147
+ External
148
+ </Link>
149
+ );
150
+
151
+ const link = screen.getByRole("link");
152
+ const rel = link.getAttribute("rel");
153
+
154
+ expect(rel).toContain("noopener");
155
+ expect(rel).toContain("noreferrer");
156
+ });
157
+
158
+ it("should not duplicate rel tokens when merging", () => {
159
+ render(
160
+ <Link
161
+ href="https://example.com"
162
+ target="_blank"
163
+ rel="noopener nofollow"
164
+ >
165
+ External
166
+ </Link>
167
+ );
168
+
169
+ const link = screen.getByRole("link");
170
+ const rel = link.getAttribute("rel") || "";
171
+ const tokens = rel.split(/\s+/);
172
+
173
+ // Check for duplicates
174
+ const uniqueTokens = new Set(tokens);
175
+ expect(tokens.length).toBe(uniqueTokens.size);
176
+
177
+ // Should have all three: noopener, noreferrer, nofollow
178
+ expect(uniqueTokens.has("noopener")).toBe(true);
179
+ expect(uniqueTokens.has("noreferrer")).toBe(true);
180
+ expect(uniqueTokens.has("nofollow")).toBe(true);
181
+ });
182
+ });
183
+
184
+ describe("Prefetch", () => {
185
+ it("should add prefetch to rel when prefetch=true and target=_blank", () => {
186
+ render(
187
+ <Link href="https://example.com" target="_blank" prefetch>
188
+ Prefetch Link
189
+ </Link>
190
+ );
191
+
192
+ const link = screen.getByRole("link");
193
+ const rel = link.getAttribute("rel");
194
+
195
+ expect(rel).toContain("noopener");
196
+ expect(rel).toContain("noreferrer");
197
+ expect(rel).toContain("prefetch");
198
+ });
199
+
200
+ it("should not add prefetch when prefetch=false", () => {
201
+ render(
202
+ <Link href="https://example.com" target="_blank" prefetch={false}>
203
+ No Prefetch
204
+ </Link>
205
+ );
206
+
207
+ const link = screen.getByRole("link");
208
+ const rel = link.getAttribute("rel");
209
+
210
+ expect(rel).not.toContain("prefetch");
211
+ });
212
+
213
+ it("should not add prefetch for internal links", () => {
214
+ render(
215
+ <Link href="/internal" prefetch>
216
+ Internal
217
+ </Link>
218
+ );
219
+
220
+ const link = screen.getByRole("link");
221
+ const rel = link.getAttribute("rel");
222
+
223
+ // Internal links don't get prefetch (only external with target="_blank")
224
+ expect(rel).toBeNull();
225
+ });
226
+ });
227
+
228
+ describe("Button Styling", () => {
229
+ it("should apply data-btn attribute when btnStyle is provided", () => {
230
+ render(
231
+ <Link href="/" btnStyle="primary">
232
+ Button Link
233
+ </Link>
234
+ );
235
+
236
+ const link = screen.getByRole("link");
237
+ expect(link).toHaveAttribute("data-btn", "primary");
238
+ });
239
+
240
+ it("should render button-styled link with <b> wrapper", () => {
241
+ render(
242
+ <Link href="/">
243
+ <b>Button Text</b>
244
+ </Link>
245
+ );
246
+
247
+ const link = screen.getByRole("link");
248
+ const bold = link.querySelector("b");
249
+
250
+ expect(bold).toBeInTheDocument();
251
+ expect(bold).toHaveTextContent("Button Text");
252
+ });
253
+
254
+ it("should render pill-styled link with <i> wrapper", () => {
255
+ render(
256
+ <Link href="/">
257
+ <i>Pill Text</i>
258
+ </Link>
259
+ );
260
+
261
+ const link = screen.getByRole("link");
262
+ const italic = link.querySelector("i");
263
+
264
+ expect(italic).toBeInTheDocument();
265
+ expect(italic).toHaveTextContent("Pill Text");
266
+ });
267
+ });
268
+
269
+ describe("Event Handlers", () => {
270
+ it("should call onClick when link is clicked", async () => {
271
+ const user = userEvent.setup();
272
+ const handleClick = vi.fn();
273
+
274
+ render(
275
+ <Link href="/test" onClick={handleClick}>
276
+ Click Me
277
+ </Link>
278
+ );
279
+
280
+ const link = screen.getByRole("link");
281
+ await user.click(link);
282
+
283
+ expect(handleClick).toHaveBeenCalledTimes(1);
284
+ expect(handleClick).toHaveBeenCalledWith(
285
+ expect.objectContaining({
286
+ type: "click",
287
+ })
288
+ );
289
+ });
290
+
291
+ it("should call onClick when activated with keyboard (Enter)", async () => {
292
+ const user = userEvent.setup();
293
+ const handleClick = vi.fn();
294
+
295
+ render(
296
+ <Link href="/test" onClick={handleClick}>
297
+ Click Me
298
+ </Link>
299
+ );
300
+
301
+ const link = screen.getByRole("link");
302
+
303
+ // Focus the link
304
+ link.focus();
305
+ expect(link).toHaveFocus();
306
+
307
+ // Press Enter to activate
308
+ await user.keyboard("{Enter}");
309
+
310
+ // onClick should be called for keyboard activation
311
+ expect(handleClick).toHaveBeenCalled();
312
+ });
313
+
314
+ it("should call onPointerDown when link is clicked", async () => {
315
+ const user = userEvent.setup();
316
+ const handlePointerDown = vi.fn();
317
+
318
+ render(
319
+ <Link href="/test" onPointerDown={handlePointerDown}>
320
+ Click Me
321
+ </Link>
322
+ );
323
+
324
+ const link = screen.getByRole("link");
325
+ await user.click(link);
326
+
327
+ expect(handlePointerDown).toHaveBeenCalledTimes(1);
328
+ expect(handlePointerDown).toHaveBeenCalledWith(
329
+ expect.objectContaining({
330
+ type: "pointerdown",
331
+ })
332
+ );
333
+ });
334
+
335
+ it("should call both onClick and onPointerDown when provided", async () => {
336
+ const user = userEvent.setup();
337
+ const handleClick = vi.fn();
338
+ const handlePointerDown = vi.fn();
339
+
340
+ render(
341
+ <Link href="/test" onClick={handleClick} onPointerDown={handlePointerDown}>
342
+ Click Me
343
+ </Link>
344
+ );
345
+
346
+ const link = screen.getByRole("link");
347
+ await user.click(link);
348
+
349
+ // Both handlers should be called
350
+ expect(handleClick).toHaveBeenCalled();
351
+ expect(handlePointerDown).toHaveBeenCalled();
352
+ });
353
+
354
+ it("should NOT call onPointerDown on keyboard activation (Enter)", async () => {
355
+ const user = userEvent.setup();
356
+ const handlePointerDown = vi.fn();
357
+
358
+ render(
359
+ <Link href="/test" onPointerDown={handlePointerDown}>
360
+ Click Me
361
+ </Link>
362
+ );
363
+
364
+ const link = screen.getByRole("link");
365
+ link.focus();
366
+
367
+ // Press Enter
368
+ await user.keyboard("{Enter}");
369
+
370
+ // onPointerDown should NOT be called (only pointer events trigger it)
371
+ expect(handlePointerDown).not.toHaveBeenCalled();
372
+ });
373
+
374
+ it("should not throw error when onClick is not provided", async () => {
375
+ const user = userEvent.setup();
376
+
377
+ render(<Link href="/test">Click Me</Link>);
378
+
379
+ const link = screen.getByRole("link");
380
+ await expect(user.click(link)).resolves.not.toThrow();
381
+ });
382
+
383
+ it("should not throw error when onPointerDown is not provided", async () => {
384
+ const user = userEvent.setup();
385
+
386
+ render(<Link href="/test">Click Me</Link>);
387
+
388
+ const link = screen.getByRole("link");
389
+ await expect(user.click(link)).resolves.not.toThrow();
390
+ });
391
+ });
392
+
393
+ describe("Ref Forwarding", () => {
394
+ it("should forward ref to the anchor element", () => {
395
+ const ref = React.createRef<HTMLAnchorElement>();
396
+
397
+ render(
398
+ <Link ref={ref} href="/test">
399
+ Test Link
400
+ </Link>
401
+ );
402
+
403
+ expect(ref.current).toBeInstanceOf(HTMLAnchorElement);
404
+ expect(ref.current?.tagName).toBe("A");
405
+ expect(ref.current?.href).toContain("/test");
406
+ });
407
+
408
+ it("should allow programmatic focus via ref", () => {
409
+ const ref = React.createRef<HTMLAnchorElement>();
410
+
411
+ render(
412
+ <Link ref={ref} href="/test">
413
+ Focusable Link
414
+ </Link>
415
+ );
416
+
417
+ ref.current?.focus();
418
+ expect(ref.current).toHaveFocus();
419
+ });
420
+ });
421
+
422
+ describe("Accessibility", () => {
423
+ it("should render with proper role", () => {
424
+ render(<Link href="/test">Accessible Link</Link>);
425
+
426
+ const link = screen.getByRole("link");
427
+ expect(link).toBeInTheDocument();
428
+ expect(link).toBeVisible();
429
+ });
430
+
431
+ it("should render external links accessibly", () => {
432
+ render(
433
+ <Link href="https://example.com" target="_blank">
434
+ External Link
435
+ </Link>
436
+ );
437
+
438
+ const link = screen.getByRole("link");
439
+ expect(link).toBeInTheDocument();
440
+ expect(link).toHaveAttribute("target", "_blank");
441
+
442
+ // Security attributes present
443
+ const rel = link.getAttribute("rel");
444
+ expect(rel).toContain("noopener");
445
+ expect(rel).toContain("noreferrer");
446
+ });
447
+
448
+ it("should render button-styled links with semantic anchor", () => {
449
+ render(
450
+ <Link href="/action">
451
+ <b>Call to Action</b>
452
+ </Link>
453
+ );
454
+
455
+ const link = screen.getByRole("link");
456
+ expect(link.tagName).toBe("A");
457
+ expect(link).toHaveAttribute("href", "/action");
458
+ });
459
+
460
+ it("should support aria-label for icon-only links", () => {
461
+ render(
462
+ <Link href="/settings" aria-label="Open settings">
463
+ <svg aria-hidden="true">
464
+ <path d="M0 0h24v24H0z" />
465
+ </svg>
466
+ </Link>
467
+ );
468
+
469
+ const link = screen.getByRole("link");
470
+ expect(link).toHaveAccessibleName("Open settings");
471
+
472
+ // Verify SVG is hidden from screen readers
473
+ const svg = link.querySelector("svg");
474
+ expect(svg).toHaveAttribute("aria-hidden", "true");
475
+ });
476
+
477
+ it("should be keyboard accessible", async () => {
478
+ const user = userEvent.setup();
479
+
480
+ render(<Link href="/test">Keyboard Link</Link>);
481
+
482
+ const link = screen.getByRole("link");
483
+
484
+ // Tab to link
485
+ await user.tab();
486
+ expect(link).toHaveFocus();
487
+
488
+ // Press Enter should work (default browser behavior)
489
+ // We just verify focus worked
490
+ expect(link).toHaveFocus();
491
+ });
492
+
493
+ it("should have accessible name from text content", () => {
494
+ render(<Link href="/test">Read installation guide</Link>);
495
+
496
+ const link = screen.getByRole("link");
497
+ expect(link).toHaveAccessibleName("Read installation guide");
498
+ });
499
+
500
+ it("should support aria-describedby for additional context", () => {
501
+ render(
502
+ <>
503
+ <span id="link-description">Opens in a new window</span>
504
+ <Link href="https://example.com" aria-describedby="link-description">
505
+ External Resource
506
+ </Link>
507
+ </>
508
+ );
509
+
510
+ const link = screen.getByRole("link");
511
+ expect(link).toHaveAttribute("aria-describedby", "link-description");
512
+ });
513
+ });
514
+
515
+ describe("URL Schemes", () => {
516
+ it("should support mailto: links", () => {
517
+ render(<Link href="mailto:test@example.com">Email Us</Link>);
518
+
519
+ const link = screen.getByRole("link");
520
+ expect(link).toHaveAttribute("href", "mailto:test@example.com");
521
+ });
522
+
523
+ it("should support tel: links", () => {
524
+ render(<Link href="tel:+1234567890">Call Us</Link>);
525
+
526
+ const link = screen.getByRole("link");
527
+ expect(link).toHaveAttribute("href", "tel:+1234567890");
528
+ });
529
+
530
+ it("should support hash/anchor links", () => {
531
+ render(<Link href="#section-1">Jump to Section</Link>);
532
+
533
+ const link = screen.getByRole("link");
534
+ expect(link).toHaveAttribute("href", "#section-1");
535
+ });
536
+
537
+ it("should support relative paths", () => {
538
+ render(<Link href="../parent">Go to Parent</Link>);
539
+
540
+ const link = screen.getByRole("link");
541
+ expect(link).toHaveAttribute("href", "../parent");
542
+ });
543
+
544
+ it("should support absolute paths", () => {
545
+ render(<Link href="/absolute/path">Absolute Path</Link>);
546
+
547
+ const link = screen.getByRole("link");
548
+ expect(link).toHaveAttribute("href", "/absolute/path");
549
+ });
550
+ });
551
+
552
+ describe("Display Name", () => {
553
+ it("should have correct displayName for debugging", () => {
554
+ expect(Link.displayName).toBe("Link");
555
+ });
556
+ });
557
+
558
+ describe("Props Spreading", () => {
559
+ it("should spread additional HTML attributes", () => {
560
+ render(
561
+ <Link href="/" data-testid="custom-link" id="link-123">
562
+ Test
563
+ </Link>
564
+ );
565
+
566
+ const link = screen.getByTestId("custom-link");
567
+ expect(link).toHaveAttribute("id", "link-123");
568
+ });
569
+
570
+ it("should support title attribute", () => {
571
+ render(
572
+ <Link href="/" title="Additional information">
573
+ Hover Me
574
+ </Link>
575
+ );
576
+
577
+ const link = screen.getByRole("link");
578
+ expect(link).toHaveAttribute("title", "Additional information");
579
+ });
580
+ });
581
+
582
+ describe("Edge Cases", () => {
583
+ it("should handle missing href gracefully", () => {
584
+ // href is optional in the type, testing edge case behavior
585
+ render(<Link>No href</Link>);
586
+
587
+ // Should still render, though not a valid link
588
+ const element = screen.getByText("No href");
589
+ expect(element).toBeInTheDocument();
590
+ });
591
+
592
+ it("should handle whitespace-only rel values", () => {
593
+ render(
594
+ <Link href="https://example.com" target="_blank" rel=" ">
595
+ External
596
+ </Link>
597
+ );
598
+
599
+ const link = screen.getByRole("link");
600
+ const rel = link.getAttribute("rel");
601
+
602
+ // Should still include security tokens
603
+ expect(rel).toContain("noopener");
604
+ expect(rel).toContain("noreferrer");
605
+ });
606
+
607
+ it("should handle multiple whitespace between rel tokens", () => {
608
+ render(
609
+ <Link
610
+ href="https://example.com"
611
+ target="_blank"
612
+ rel="nofollow author"
613
+ >
614
+ External
615
+ </Link>
616
+ );
617
+
618
+ const link = screen.getByRole("link");
619
+ const rel = link.getAttribute("rel") || "";
620
+
621
+ // Split should handle multiple spaces
622
+ expect(rel).toContain("noopener");
623
+ expect(rel).toContain("noreferrer");
624
+ expect(rel).toContain("nofollow");
625
+ expect(rel).toContain("author");
626
+ });
627
+ });
628
+
629
+ describe("Performance", () => {
630
+ it("should memoize rel computation to avoid unnecessary recalculations", () => {
631
+ const { rerender } = render(
632
+ <Link href="https://example.com" target="_blank">
633
+ Link
634
+ </Link>
635
+ );
636
+
637
+ const link1 = screen.getByRole("link");
638
+ const rel1 = link1.getAttribute("rel");
639
+
640
+ // Rerender with same props
641
+ rerender(
642
+ <Link href="https://example.com" target="_blank">
643
+ Link
644
+ </Link>
645
+ );
646
+
647
+ const link2 = screen.getByRole("link");
648
+ const rel2 = link2.getAttribute("rel");
649
+
650
+ // Should produce same result
651
+ expect(rel1).toBe(rel2);
652
+ });
653
+
654
+ it("should update rel when dependencies change", () => {
655
+ const { rerender } = render(
656
+ <Link href="https://example.com" target="_blank" prefetch={false}>
657
+ Link
658
+ </Link>
659
+ );
660
+
661
+ const link1 = screen.getByRole("link");
662
+ const rel1 = link1.getAttribute("rel");
663
+ expect(rel1).not.toContain("prefetch");
664
+
665
+ // Rerender with prefetch=true
666
+ rerender(
667
+ <Link href="https://example.com" target="_blank" prefetch={true}>
668
+ Link
669
+ </Link>
670
+ );
671
+
672
+ const link2 = screen.getByRole("link");
673
+ const rel2 = link2.getAttribute("rel");
674
+ expect(rel2).toContain("prefetch");
675
+ });
676
+ });
677
+ });