@windstream/react-shared-components 0.1.92 → 0.1.94

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 (293) hide show
  1. package/README.md +635 -635
  2. package/dist/contentful/index.d.ts +1 -0
  3. package/dist/contentful/index.esm.js +2 -2
  4. package/dist/contentful/index.esm.js.map +1 -1
  5. package/dist/contentful/index.js +3 -3
  6. package/dist/contentful/index.js.map +1 -1
  7. package/dist/core.d.ts +5 -5
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.esm.js +1 -1
  10. package/dist/index.esm.js.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles.css +1 -1
  14. package/dist/utils/index.esm.js +1 -1
  15. package/dist/utils/index.js +1 -1
  16. package/package.json +191 -185
  17. package/src/components/accordion/Accordion.stories.tsx +230 -230
  18. package/src/components/accordion/index.test.tsx +270 -0
  19. package/src/components/accordion/index.tsx +70 -70
  20. package/src/components/accordion/types.ts +12 -12
  21. package/src/components/alert-card/AlertCard.stories.tsx +171 -171
  22. package/src/components/alert-card/index.test.tsx +152 -0
  23. package/src/components/alert-card/index.tsx +41 -41
  24. package/src/components/alert-card/types.ts +13 -13
  25. package/src/components/animation-wrapper/index.test.tsx +424 -0
  26. package/src/components/animation-wrapper/index.tsx +129 -129
  27. package/src/components/animation-wrapper/types.ts +11 -11
  28. package/src/components/brand-button/BrandButton.stories.tsx +223 -223
  29. package/src/components/brand-button/helpers.ts +35 -35
  30. package/src/components/brand-button/index.test.tsx +292 -0
  31. package/src/components/brand-button/index.tsx +120 -120
  32. package/src/components/brand-button/types.ts +38 -38
  33. package/src/components/button/Button.stories.tsx +108 -108
  34. package/src/components/button/index.test.tsx +91 -0
  35. package/src/components/button/index.tsx +27 -27
  36. package/src/components/button/types.ts +14 -14
  37. package/src/components/call-button/CallButton.stories.tsx +324 -324
  38. package/src/components/call-button/index.test.tsx +260 -0
  39. package/src/components/call-button/index.tsx +106 -106
  40. package/src/components/call-button/types.ts +16 -16
  41. package/src/components/checkbox/Checkbox.stories.tsx +247 -247
  42. package/src/components/checkbox/index.test.tsx +252 -0
  43. package/src/components/checkbox/index.tsx +197 -197
  44. package/src/components/checkbox/types.ts +27 -27
  45. package/src/components/checklist/Checklist.stories.tsx +150 -150
  46. package/src/components/checklist/index.test.tsx +231 -0
  47. package/src/components/checklist/index.tsx +96 -61
  48. package/src/components/checklist/types.ts +23 -17
  49. package/src/components/collapse/Collapse.stories.tsx +255 -255
  50. package/src/components/collapse/index.test.tsx +277 -0
  51. package/src/components/collapse/index.tsx +47 -46
  52. package/src/components/collapse/types.ts +6 -6
  53. package/src/components/divider/Divider.stories.tsx +205 -205
  54. package/src/components/divider/index.test.tsx +53 -0
  55. package/src/components/divider/index.tsx +22 -22
  56. package/src/components/divider/type.ts +3 -3
  57. package/src/components/image/Image.stories.tsx +113 -113
  58. package/src/components/image/index.test.tsx +174 -0
  59. package/src/components/image/index.tsx +25 -25
  60. package/src/components/image/types.ts +40 -40
  61. package/src/components/input/Input.stories.tsx +325 -325
  62. package/src/components/input/index.test.tsx +348 -0
  63. package/src/components/input/index.tsx +177 -177
  64. package/src/components/input/types.ts +37 -37
  65. package/src/components/link/Link.stories.tsx +163 -163
  66. package/src/components/link/index.test.tsx +199 -0
  67. package/src/components/link/index.tsx +116 -116
  68. package/src/components/link/types.ts +25 -25
  69. package/src/components/list/List.stories.tsx +272 -272
  70. package/src/components/list/index.test.tsx +166 -0
  71. package/src/components/list/index.tsx +88 -88
  72. package/src/components/list/list-item/index.tsx +38 -38
  73. package/src/components/list/list-item/types.ts +13 -13
  74. package/src/components/list/types.ts +29 -29
  75. package/src/components/material-icon/MaterialIcon.stories.tsx +322 -322
  76. package/src/components/material-icon/constants.ts +99 -99
  77. package/src/components/material-icon/index.test.tsx +130 -0
  78. package/src/components/material-icon/index.tsx +47 -47
  79. package/src/components/material-icon/types.ts +31 -31
  80. package/src/components/modal/Modal.stories.tsx +171 -171
  81. package/src/components/modal/index.test.tsx +310 -0
  82. package/src/components/modal/index.tsx +164 -164
  83. package/src/components/modal/types.ts +24 -24
  84. package/src/components/next-image/index.test.tsx +406 -0
  85. package/src/components/next-image/index.tsx +74 -74
  86. package/src/components/next-image/types.ts +1 -1
  87. package/src/components/pagination/index.test.tsx +521 -0
  88. package/src/components/pagination/index.tsx +91 -91
  89. package/src/components/pagination/types.ts +6 -6
  90. package/src/components/radio-button/RadioButton.stories.tsx +307 -307
  91. package/src/components/radio-button/index.test.tsx +151 -0
  92. package/src/components/radio-button/index.tsx +75 -75
  93. package/src/components/radio-button/types.ts +21 -21
  94. package/src/components/see-more/SeeMore.stories.tsx +181 -181
  95. package/src/components/see-more/index.test.tsx +96 -0
  96. package/src/components/see-more/index.tsx +44 -44
  97. package/src/components/see-more/types.ts +4 -4
  98. package/src/components/select/Select.stories.tsx +411 -411
  99. package/src/components/select/index.test.tsx +256 -0
  100. package/src/components/select/index.tsx +155 -155
  101. package/src/components/select/types.ts +36 -36
  102. package/src/components/select-plan-button/SelectPlanButton.stories.tsx +184 -184
  103. package/src/components/select-plan-button/index.test.tsx +173 -0
  104. package/src/components/select-plan-button/index.tsx +63 -63
  105. package/src/components/select-plan-button/types.ts +17 -17
  106. package/src/components/skeleton/Skeleton.stories.tsx +179 -179
  107. package/src/components/skeleton/index.test.tsx +74 -0
  108. package/src/components/skeleton/index.tsx +61 -61
  109. package/src/components/skeleton/types.ts +4 -4
  110. package/src/components/spinner/Spinner.stories.tsx +335 -335
  111. package/src/components/spinner/index.test.tsx +76 -0
  112. package/src/components/spinner/index.tsx +44 -44
  113. package/src/components/spinner/types.ts +5 -5
  114. package/src/components/text/Text.stories.tsx +321 -321
  115. package/src/components/text/index.test.tsx +65 -0
  116. package/src/components/text/index.tsx +25 -25
  117. package/src/components/text/types.ts +45 -45
  118. package/src/components/tooltip/Tooltip.stories.tsx +219 -219
  119. package/src/components/tooltip/index.test.tsx +50 -0
  120. package/src/components/tooltip/index.tsx +74 -74
  121. package/src/components/tooltip/types.ts +7 -7
  122. package/src/components/view-cart-button/ViewCartButton.stories.tsx +252 -252
  123. package/src/components/view-cart-button/index.test.tsx +57 -0
  124. package/src/components/view-cart-button/index.tsx +42 -42
  125. package/src/components/view-cart-button/types.ts +5 -5
  126. package/src/contentful/blocks/accordion/Accordion.stories.mocks.tsx +128 -128
  127. package/src/contentful/blocks/accordion/Accordion.stories.tsx +98 -98
  128. package/src/contentful/blocks/accordion/index.test.tsx +218 -0
  129. package/src/contentful/blocks/accordion/index.tsx +114 -112
  130. package/src/contentful/blocks/accordion/types.ts +34 -34
  131. package/src/contentful/blocks/address-input-banner/index.test.tsx +132 -0
  132. package/src/contentful/blocks/address-input-banner/index.tsx +52 -52
  133. package/src/contentful/blocks/address-input-banner/types.ts +14 -14
  134. package/src/contentful/blocks/anchored-bottom-banner/index.test.tsx +287 -0
  135. package/src/contentful/blocks/anchored-bottom-banner/index.tsx +181 -181
  136. package/src/contentful/blocks/anchored-bottom-banner/types.ts +13 -13
  137. package/src/contentful/blocks/blogs-grid/BlogGrid.stories.mocks.tsx +144 -144
  138. package/src/contentful/blocks/blogs-grid/BlogGrid.stories.tsx +157 -156
  139. package/src/contentful/blocks/blogs-grid/index.test.tsx +355 -0
  140. package/src/contentful/blocks/blogs-grid/index.tsx +134 -134
  141. package/src/contentful/blocks/blogs-grid/types.ts +26 -26
  142. package/src/contentful/blocks/blogs-grid-base/index.test.tsx +274 -0
  143. package/src/contentful/blocks/blogs-grid-base/index.tsx +119 -119
  144. package/src/contentful/blocks/blogs-grid-base/types.ts +36 -36
  145. package/src/contentful/blocks/breadcrumbs/BreadcrumbNavigation.stories.tsx +147 -147
  146. package/src/contentful/blocks/breadcrumbs/index.test.tsx +281 -0
  147. package/src/contentful/blocks/breadcrumbs/index.tsx +95 -95
  148. package/src/contentful/blocks/breadcrumbs/types.ts +8 -8
  149. package/src/contentful/blocks/button/Button.stories.tsx +40 -40
  150. package/src/contentful/blocks/button/index.test.tsx +339 -0
  151. package/src/contentful/blocks/button/index.tsx +131 -131
  152. package/src/contentful/blocks/button/types.ts +39 -39
  153. package/src/contentful/blocks/callout/Callout.stories.tsx +23 -23
  154. package/src/contentful/blocks/callout/index.test.tsx +539 -0
  155. package/src/contentful/blocks/callout/index.tsx +277 -277
  156. package/src/contentful/blocks/callout/types.ts +78 -78
  157. package/src/contentful/blocks/cards/Cards.stories.tsx +23 -23
  158. package/src/contentful/blocks/cards/blog-card/index.test.tsx +218 -0
  159. package/src/contentful/blocks/cards/blog-card/index.tsx +129 -129
  160. package/src/contentful/blocks/cards/blog-card/types.ts +34 -34
  161. package/src/contentful/blocks/cards/floating-image-card/index.test.tsx +201 -0
  162. package/src/contentful/blocks/cards/floating-image-card/index.tsx +119 -119
  163. package/src/contentful/blocks/cards/floating-image-card/types.ts +30 -30
  164. package/src/contentful/blocks/cards/full-image-card/index.test.tsx +216 -0
  165. package/src/contentful/blocks/cards/full-image-card/index.tsx +130 -130
  166. package/src/contentful/blocks/cards/full-image-card/types.ts +29 -29
  167. package/src/contentful/blocks/cards/index.test.tsx +39 -0
  168. package/src/contentful/blocks/cards/index.tsx +13 -13
  169. package/src/contentful/blocks/cards/product-card/index.test.tsx +263 -0
  170. package/src/contentful/blocks/cards/product-card/index.tsx +251 -251
  171. package/src/contentful/blocks/cards/product-card/types.ts +28 -28
  172. package/src/contentful/blocks/cards/simple-card/index.test.tsx +364 -0
  173. package/src/contentful/blocks/cards/simple-card/index.tsx +325 -325
  174. package/src/contentful/blocks/cards/simple-card/types.ts +71 -71
  175. package/src/contentful/blocks/cards/testimonial-card/index.test.tsx +180 -0
  176. package/src/contentful/blocks/cards/testimonial-card/index.tsx +90 -90
  177. package/src/contentful/blocks/cards/testimonial-card/types.tsx +12 -12
  178. package/src/contentful/blocks/cards/types.ts +1 -1
  179. package/src/contentful/blocks/carousel/Carousel.stories.tsx +23 -23
  180. package/src/contentful/blocks/carousel/helper.test.tsx +539 -0
  181. package/src/contentful/blocks/carousel/helper.tsx +494 -494
  182. package/src/contentful/blocks/carousel/index.test.tsx +308 -0
  183. package/src/contentful/blocks/carousel/index.tsx +87 -87
  184. package/src/contentful/blocks/carousel/types.test.ts +16 -0
  185. package/src/contentful/blocks/carousel/types.ts +145 -145
  186. package/src/contentful/blocks/cart-retention-banner/index.test.tsx +409 -0
  187. package/src/contentful/blocks/cart-retention-banner/index.tsx +109 -109
  188. package/src/contentful/blocks/cart-retention-banner/types.ts +11 -11
  189. package/src/contentful/blocks/comparison-table/index.test.tsx +114 -0
  190. package/src/contentful/blocks/comparison-table/index.tsx +29 -29
  191. package/src/contentful/blocks/comparison-table/types.ts +6 -6
  192. package/src/contentful/blocks/cookiebanner/index.test.tsx +277 -0
  193. package/src/contentful/blocks/cookiebanner/index.tsx +146 -146
  194. package/src/contentful/blocks/cookiebanner/type.ts +7 -7
  195. package/src/contentful/blocks/cta-callout/CtaCallout.stories.tsx +46 -46
  196. package/src/contentful/blocks/cta-callout/index.test.tsx +244 -0
  197. package/src/contentful/blocks/cta-callout/index.tsx +73 -73
  198. package/src/contentful/blocks/cta-callout/types.ts +26 -26
  199. package/src/contentful/blocks/dynamic-tabs/index.test.tsx +240 -0
  200. package/src/contentful/blocks/dynamic-tabs/index.tsx +204 -204
  201. package/src/contentful/blocks/dynamic-tabs/types.ts +21 -21
  202. package/src/contentful/blocks/email-input-block/index.test.tsx +213 -0
  203. package/src/contentful/blocks/email-input-block/index.tsx +121 -116
  204. package/src/contentful/blocks/email-input-block/types.ts +16 -16
  205. package/src/contentful/blocks/find-kinetic/FindKinetic.stories.tsx +23 -23
  206. package/src/contentful/blocks/find-kinetic/index.test.tsx +269 -0
  207. package/src/contentful/blocks/find-kinetic/index.tsx +138 -130
  208. package/src/contentful/blocks/find-kinetic/types.ts +20 -19
  209. package/src/contentful/blocks/floating-banner/FloatingBanner.stories.tsx +34 -34
  210. package/src/contentful/blocks/floating-banner/index.test.tsx +246 -0
  211. package/src/contentful/blocks/floating-banner/index.tsx +97 -97
  212. package/src/contentful/blocks/floating-banner/types.ts +22 -22
  213. package/src/contentful/blocks/footer/Footer.stories.tsx +317 -317
  214. package/src/contentful/blocks/footer/index.test.tsx +302 -0
  215. package/src/contentful/blocks/footer/index.tsx +91 -91
  216. package/src/contentful/blocks/footer/types.ts +13 -13
  217. package/src/contentful/blocks/image-promo-bar/ImagePromoBar.stories.tsx +23 -23
  218. package/src/contentful/blocks/image-promo-bar/helper.test.tsx +61 -0
  219. package/src/contentful/blocks/image-promo-bar/helper.tsx +28 -28
  220. package/src/contentful/blocks/image-promo-bar/index.test.tsx +467 -0
  221. package/src/contentful/blocks/image-promo-bar/index.tsx +246 -246
  222. package/src/contentful/blocks/image-promo-bar/types.ts +44 -44
  223. package/src/contentful/blocks/image-promo-bar/vimeo-embed.test.tsx +142 -0
  224. package/src/contentful/blocks/image-promo-bar/vimeo-embed.tsx +93 -93
  225. package/src/contentful/blocks/image-promo-bar/youtube-embed.test.tsx +104 -0
  226. package/src/contentful/blocks/image-promo-bar/youtube-embed.tsx +46 -46
  227. package/src/contentful/blocks/modal/constants.ts +53 -53
  228. package/src/contentful/blocks/modal/index.test.tsx +209 -0
  229. package/src/contentful/blocks/modal/index.tsx +108 -108
  230. package/src/contentful/blocks/modal/types.ts +12 -12
  231. package/src/contentful/blocks/navigation/Navigation.stories.mocks.tsx +78 -0
  232. package/src/contentful/blocks/navigation/Navigation.stories.tsx +138 -0
  233. package/src/contentful/blocks/navigation/desktop-link-groups.tsx/index.test.tsx +208 -0
  234. package/src/contentful/blocks/navigation/desktop-link-groups.tsx/index.tsx +141 -139
  235. package/src/contentful/blocks/navigation/index.test.tsx +924 -0
  236. package/src/contentful/blocks/navigation/index.tsx +569 -568
  237. package/src/contentful/blocks/navigation/mobile-link-groups.tsx/index.test.tsx +131 -0
  238. package/src/contentful/blocks/navigation/mobile-link-groups.tsx/index.tsx +82 -82
  239. package/src/contentful/blocks/navigation/types.ts +71 -71
  240. package/src/contentful/blocks/primary-hero/PrimaryHero.stories.tsx +23 -23
  241. package/src/contentful/blocks/primary-hero/index.test.tsx +286 -0
  242. package/src/contentful/blocks/primary-hero/index.tsx +239 -236
  243. package/src/contentful/blocks/primary-hero/types.ts +37 -37
  244. package/src/contentful/blocks/search-block/index.test.tsx +268 -0
  245. package/src/contentful/blocks/search-block/index.tsx +90 -90
  246. package/src/contentful/blocks/search-block/types.ts +15 -15
  247. package/src/contentful/blocks/shape-background-wrapper/ShapeBackgroundWrapper.stories.tsx +26 -26
  248. package/src/contentful/blocks/shape-background-wrapper/index.test.tsx +284 -0
  249. package/src/contentful/blocks/shape-background-wrapper/index.tsx +124 -124
  250. package/src/contentful/blocks/shape-background-wrapper/types.ts +36 -36
  251. package/src/contentful/blocks/text/Text.stories.tsx +23 -23
  252. package/src/contentful/blocks/text/index.test.tsx +36 -0
  253. package/src/contentful/blocks/text/index.tsx +12 -12
  254. package/src/contentful/blocks/text/types.ts +1 -1
  255. package/src/contentful/index.test.ts +45 -0
  256. package/src/contentful/index.ts +105 -105
  257. package/src/global-mocks/contentful/to-document.ts +25 -0
  258. package/src/global-mocks/cookie.ts +48 -0
  259. package/src/global-mocks/cx.ts +37 -0
  260. package/src/global-mocks/index.ts +89 -0
  261. package/src/global-mocks/speed-card-bg.ts +27 -0
  262. package/src/global-mocks/utm.ts +49 -0
  263. package/src/hooks/contentful/use-contentful-rich-text.test.tsx +1758 -0
  264. package/src/hooks/contentful/use-contentful-rich-text.tsx +309 -309
  265. package/src/hooks/contentful/use-processed-check-list.test.tsx +277 -0
  266. package/src/hooks/contentful/use-processed-check-list.ts +63 -63
  267. package/src/hooks/use-body-scroll-lock.test.ts +134 -0
  268. package/src/hooks/use-body-scroll-lock.ts +34 -34
  269. package/src/hooks/use-carousel-swipe.test.ts +393 -0
  270. package/src/hooks/use-carousel-swipe.ts +264 -264
  271. package/src/hooks/use-outside-click.test.ts +142 -0
  272. package/src/hooks/use-outside-click.ts +17 -17
  273. package/src/index.ts +107 -107
  274. package/src/next/index.test.ts +7 -0
  275. package/src/next/index.ts +5 -5
  276. package/src/setupTests.ts +52 -46
  277. package/src/stories/DocsTemplate.tsx +24 -24
  278. package/src/styles/globals.css +343 -343
  279. package/src/types/global.d.ts +9 -9
  280. package/src/types/micro-components.ts +99 -99
  281. package/src/types/utm.ts +49 -49
  282. package/src/utils/contentful/to-document.test.ts +85 -0
  283. package/src/utils/contentful/to-document.ts +24 -24
  284. package/src/utils/cookie.test.ts +180 -0
  285. package/src/utils/cookie.ts +84 -84
  286. package/src/utils/cx.test.ts +90 -0
  287. package/src/utils/cx.ts +49 -49
  288. package/src/utils/index.test.ts +115 -0
  289. package/src/utils/index.ts +41 -41
  290. package/src/utils/speed-card-bg.test.ts +46 -0
  291. package/src/utils/speed-card-bg.ts +24 -24
  292. package/src/utils/utm.test.ts +359 -0
  293. package/src/utils/utm.ts +221 -221
@@ -0,0 +1,406 @@
1
+ import "@testing-library/jest-dom";
2
+
3
+ import React, { createRef, forwardRef } from "react";
4
+ import { NextImage } from "./index";
5
+
6
+ import { render, screen } from "@testing-library/react";
7
+
8
+ // Mock next/image - must use require-style reference due to hoisting
9
+ const mockNextImage = jest.fn();
10
+
11
+ jest.mock("next/image", () => {
12
+ const MockNextImage = forwardRef(function MockNextImage(
13
+ props: any,
14
+ ref: any
15
+ ) {
16
+ mockNextImage(props);
17
+ const rest = { ...props };
18
+ const loader = rest.loader;
19
+ delete rest.loader;
20
+ delete rest.unoptimized;
21
+ delete rest.priority;
22
+ delete rest.fill;
23
+ delete rest.sizes;
24
+ return (
25
+ <img
26
+ ref={ref}
27
+ data-testid="next-image"
28
+ data-unoptimized={props.unoptimized?.toString() || "false"}
29
+ data-has-loader={loader ? "true" : "false"}
30
+ {...rest}
31
+ />
32
+ );
33
+ });
34
+ return { __esModule: true, default: MockNextImage };
35
+ });
36
+
37
+ jest.mock("@shared/utils", () => ({
38
+ cx: (...args: any[]) => args.filter(Boolean).join(" "),
39
+ }));
40
+
41
+ describe("NextImage", () => {
42
+ beforeEach(() => {
43
+ mockNextImage.mockClear();
44
+ });
45
+
46
+ it("has displayName set to NextImage", () => {
47
+ expect(NextImage.displayName).toBe("NextImage");
48
+ });
49
+
50
+ describe("basic rendering", () => {
51
+ it("renders an image element", () => {
52
+ render(<NextImage src="/test.png" alt="Test" width={100} height={100} />);
53
+ expect(screen.getByTestId("next-image")).toBeInTheDocument();
54
+ });
55
+
56
+ it("passes src, alt, width, height props", () => {
57
+ render(
58
+ <NextImage src="/photo.jpg" alt="Photo" width={200} height={150} />
59
+ );
60
+ const img = screen.getByTestId("next-image");
61
+ expect(img).toHaveAttribute("src", "/photo.jpg");
62
+ expect(img).toHaveAttribute("alt", "Photo");
63
+ expect(img).toHaveAttribute("width", "200");
64
+ expect(img).toHaveAttribute("height", "150");
65
+ });
66
+
67
+ it("passes className to image", () => {
68
+ render(
69
+ <NextImage
70
+ src="/img.png"
71
+ alt="Img"
72
+ width={50}
73
+ height={50}
74
+ className="rounded-lg"
75
+ />
76
+ );
77
+ const img = screen.getByTestId("next-image");
78
+ expect(img).toHaveClass("rounded-lg");
79
+ });
80
+
81
+ it("passes additional props through", () => {
82
+ render(
83
+ <NextImage
84
+ src="/img.png"
85
+ alt="Img"
86
+ width={50}
87
+ height={50}
88
+ priority={true}
89
+ />
90
+ );
91
+ expect(mockNextImage).toHaveBeenCalledWith(
92
+ expect.objectContaining({ priority: true })
93
+ );
94
+ });
95
+ });
96
+
97
+ describe("ref forwarding", () => {
98
+ it("forwards ref to the underlying image", () => {
99
+ const ref = createRef<HTMLImageElement>();
100
+ render(
101
+ <NextImage ref={ref} src="/img.png" alt="Ref" width={50} height={50} />
102
+ );
103
+ expect(ref.current).toBeInstanceOf(HTMLImageElement);
104
+ });
105
+ });
106
+
107
+ describe("Contentful image detection", () => {
108
+ it("detects Contentful images by domain", () => {
109
+ render(
110
+ <NextImage
111
+ src="https://images.ctfassets.net/abc/image.png"
112
+ alt="Contentful"
113
+ width={100}
114
+ height={100}
115
+ />
116
+ );
117
+ const img = screen.getByTestId("next-image");
118
+ expect(img).toHaveAttribute("data-has-loader", "true");
119
+ expect(img).toHaveAttribute("data-unoptimized", "false");
120
+ });
121
+
122
+ it("does not use contentful loader for non-Contentful images", () => {
123
+ render(
124
+ <NextImage
125
+ src="https://example.com/photo.jpg"
126
+ alt="External"
127
+ width={100}
128
+ height={100}
129
+ />
130
+ );
131
+ const img = screen.getByTestId("next-image");
132
+ expect(img).toHaveAttribute("data-has-loader", "false");
133
+ });
134
+
135
+ it("does not use contentful loader for relative paths", () => {
136
+ render(
137
+ <NextImage
138
+ src="/local/image.png"
139
+ alt="Local"
140
+ width={100}
141
+ height={100}
142
+ />
143
+ );
144
+ const img = screen.getByTestId("next-image");
145
+ expect(img).toHaveAttribute("data-has-loader", "false");
146
+ });
147
+
148
+ it("handles non-string src gracefully", () => {
149
+ const staticImport = { src: "/static.png", height: 100, width: 100 };
150
+ render(
151
+ <NextImage
152
+ src={staticImport as any}
153
+ alt="Static"
154
+ width={100}
155
+ height={100}
156
+ />
157
+ );
158
+ const img = screen.getByTestId("next-image");
159
+ expect(img).toHaveAttribute("data-has-loader", "false");
160
+ });
161
+ });
162
+
163
+ describe("custom loader behavior", () => {
164
+ it("passes a loader function for Contentful images", () => {
165
+ render(
166
+ <NextImage
167
+ src="https://images.ctfassets.net/space/image.jpg"
168
+ alt="CF"
169
+ width={100}
170
+ height={100}
171
+ />
172
+ );
173
+ expect(mockNextImage).toHaveBeenCalledWith(
174
+ expect.objectContaining({
175
+ loader: expect.any(Function),
176
+ unoptimized: false,
177
+ })
178
+ );
179
+ });
180
+
181
+ it("loader appends width, quality, and format params", () => {
182
+ render(
183
+ <NextImage
184
+ src="https://images.ctfassets.net/space/photo.jpg"
185
+ alt="CF"
186
+ width={100}
187
+ height={100}
188
+ />
189
+ );
190
+ const callProps = mockNextImage.mock.calls[0][0];
191
+ const loader = callProps.loader;
192
+ const result = loader({
193
+ src: "https://images.ctfassets.net/space/photo.jpg",
194
+ width: 800,
195
+ quality: 75,
196
+ });
197
+ expect(result).toContain("w=800");
198
+ expect(result).toContain("q=75");
199
+ expect(result).toContain("fm=webp");
200
+ });
201
+
202
+ it("loader uses default quality of 90 when not specified", () => {
203
+ render(
204
+ <NextImage
205
+ src="https://images.ctfassets.net/space/photo.jpg"
206
+ alt="CF"
207
+ width={100}
208
+ height={100}
209
+ />
210
+ );
211
+ const callProps = mockNextImage.mock.calls[0][0];
212
+ const loader = callProps.loader;
213
+ const result = loader({
214
+ src: "https://images.ctfassets.net/space/photo.jpg",
215
+ width: 400,
216
+ quality: undefined,
217
+ });
218
+ expect(result).toContain("q=90");
219
+ });
220
+
221
+ it("loader preserves existing URL params", () => {
222
+ render(
223
+ <NextImage
224
+ src="https://images.ctfassets.net/space/photo.jpg?fit=fill"
225
+ alt="CF"
226
+ width={100}
227
+ height={100}
228
+ />
229
+ );
230
+ const callProps = mockNextImage.mock.calls[0][0];
231
+ const loader = callProps.loader;
232
+ const result = loader({
233
+ src: "https://images.ctfassets.net/space/photo.jpg?fit=fill",
234
+ width: 600,
235
+ quality: 80,
236
+ });
237
+ expect(result).toContain("fit=fill");
238
+ expect(result).toContain("w=600");
239
+ expect(result).toContain("q=80");
240
+ expect(result).toContain("fm=webp");
241
+ });
242
+ });
243
+
244
+ describe("SVG optimization handling", () => {
245
+ it("marks SVG from Contentful as unoptimized", () => {
246
+ render(
247
+ <NextImage
248
+ src="https://images.ctfassets.net/space/logo.svg"
249
+ alt="SVG"
250
+ width={100}
251
+ height={100}
252
+ />
253
+ );
254
+ const img = screen.getByTestId("next-image");
255
+ expect(img).toHaveAttribute("data-unoptimized", "true");
256
+ expect(img).toHaveAttribute("data-has-loader", "false");
257
+ });
258
+
259
+ it("does not use contentful loader for SVG images", () => {
260
+ render(
261
+ <NextImage
262
+ src="https://images.ctfassets.net/space/icon.svg"
263
+ alt="SVG Icon"
264
+ width={24}
265
+ height={24}
266
+ />
267
+ );
268
+ expect(mockNextImage).toHaveBeenCalledWith(
269
+ expect.objectContaining({
270
+ unoptimized: true,
271
+ })
272
+ );
273
+ expect(mockNextImage).not.toHaveBeenCalledWith(
274
+ expect.objectContaining({
275
+ loader: expect.any(Function),
276
+ })
277
+ );
278
+ });
279
+
280
+ it("handles SVG with query params from Contentful", () => {
281
+ render(
282
+ <NextImage
283
+ src="https://images.ctfassets.net/space/icon.svg?v=1"
284
+ alt="SVG"
285
+ width={50}
286
+ height={50}
287
+ />
288
+ );
289
+ const img = screen.getByTestId("next-image");
290
+ expect(img).toHaveAttribute("data-unoptimized", "true");
291
+ expect(img).toHaveAttribute("data-has-loader", "false");
292
+ });
293
+
294
+ it("handles SVG with uppercase extension from Contentful", () => {
295
+ render(
296
+ <NextImage
297
+ src="https://images.ctfassets.net/space/Logo.SVG"
298
+ alt="SVG"
299
+ width={100}
300
+ height={100}
301
+ />
302
+ );
303
+ const img = screen.getByTestId("next-image");
304
+ expect(img).toHaveAttribute("data-unoptimized", "true");
305
+ });
306
+
307
+ it("does not treat non-Contentful SVG specially", () => {
308
+ render(
309
+ <NextImage
310
+ src="https://example.com/icon.svg"
311
+ alt="Ext SVG"
312
+ width={24}
313
+ height={24}
314
+ />
315
+ );
316
+ const img = screen.getByTestId("next-image");
317
+ // Non-contentful SVG: no loader, unoptimized is false (falsy)
318
+ expect(img).toHaveAttribute("data-has-loader", "false");
319
+ expect(img).toHaveAttribute("data-unoptimized", "false");
320
+ });
321
+ });
322
+
323
+ describe("non-Contentful images", () => {
324
+ it("does not set unoptimized for regular external images", () => {
325
+ render(
326
+ <NextImage
327
+ src="https://cdn.example.com/photo.webp"
328
+ alt="WebP"
329
+ width={400}
330
+ height={300}
331
+ />
332
+ );
333
+ const img = screen.getByTestId("next-image");
334
+ expect(img).toHaveAttribute("data-has-loader", "false");
335
+ });
336
+
337
+ it("does not set unoptimized for local images", () => {
338
+ render(
339
+ <NextImage
340
+ src="/images/hero.png"
341
+ alt="Hero"
342
+ width={1200}
343
+ height={600}
344
+ />
345
+ );
346
+ const img = screen.getByTestId("next-image");
347
+ expect(img).toHaveAttribute("data-unoptimized");
348
+ });
349
+ });
350
+ });
351
+
352
+ describe("resolveDefaultExport (CJS/ESM interop)", () => {
353
+ afterEach(() => {
354
+ jest.resetModules();
355
+ });
356
+
357
+ it("unwraps doubly-wrapped default export", async () => {
358
+ jest.doMock("next/image", () => {
359
+ function MockImage(props: any) {
360
+ return <img {...props} />;
361
+ }
362
+ return { __esModule: true, default: { default: MockImage } };
363
+ });
364
+ jest.doMock("@shared/utils", () => ({
365
+ cx: (...args: any[]) => args.filter(Boolean).join(" "),
366
+ }));
367
+ const { NextImage } = await import("./index");
368
+ expect(NextImage).toBeDefined();
369
+ });
370
+
371
+ it("resolves when default export is already a function", async () => {
372
+ jest.doMock("next/image", () => {
373
+ function MockImage(props: any) {
374
+ return <img {...props} />;
375
+ }
376
+ return { __esModule: true, default: MockImage };
377
+ });
378
+ jest.doMock("@shared/utils", () => ({
379
+ cx: (...args: any[]) => args.filter(Boolean).join(" "),
380
+ }));
381
+ const { NextImage } = await import("./index");
382
+ expect(NextImage).toBeDefined();
383
+ });
384
+
385
+ it("returns current when loop exhausts iterations", async () => {
386
+ jest.doMock("next/image", () => {
387
+ const nested = {
388
+ default: {
389
+ default: {
390
+ default: {
391
+ default: {
392
+ default: { default: "not-a-function" },
393
+ },
394
+ },
395
+ },
396
+ },
397
+ };
398
+ return { __esModule: true, default: nested };
399
+ });
400
+ jest.doMock("@shared/utils", () => ({
401
+ cx: (...args: any[]) => args.filter(Boolean).join(" "),
402
+ }));
403
+ const { NextImage } = await import("./index");
404
+ expect(NextImage).toBeDefined();
405
+ });
406
+ });
@@ -1,74 +1,74 @@
1
- "use client";
2
-
3
- import { forwardRef } from "react";
4
- import NextJsImageImport, {
5
- type ImageLoaderProps,
6
- type ImageProps as NextImageProps,
7
- } from "next/image";
8
-
9
- import { cx } from "@shared/utils";
10
-
11
- // Handle CJS/ESM interop: when bundled as ESM and consumed by Webpack,
12
- // the default import may resolve to { default: Component } (or be doubly
13
- // wrapped) instead of the function. Unwrap until we land on a callable.
14
- const resolveDefaultExport = (mod: unknown): unknown => {
15
- let current: any = mod;
16
- // Limit iterations to avoid pathological cycles.
17
- for (let i = 0; i < 5; i++) {
18
- if (typeof current === "function") return current;
19
- if (current && typeof current === "object" && "default" in current) {
20
- current = current.default;
21
- continue;
22
- }
23
- return current;
24
- }
25
- return current;
26
- };
27
- const NextJsImage = resolveDefaultExport(
28
- NextJsImageImport
29
- ) as typeof NextJsImageImport;
30
-
31
- export interface NextImageComponentProps extends NextImageProps {
32
- className?: string;
33
- }
34
-
35
- /**
36
- * Image loader that uses Contentful's Image API to serve optimized WebP images
37
- * at the requested width and quality, avoiding an extra round-trip through
38
- * the Next.js image optimization server.
39
- */
40
- const contentfulImageLoader = ({ src, width, quality }: ImageLoaderProps) => {
41
- const url = new URL(src);
42
- url.searchParams.set("w", String(width));
43
- url.searchParams.set("q", String(quality || 90));
44
- url.searchParams.set("fm", "webp");
45
- return url.toString();
46
- };
47
-
48
- export const NextImage = forwardRef<HTMLImageElement, NextImageComponentProps>(
49
- ({ className, ...props }, ref) => {
50
- const srcString = typeof props.src === "string" ? props.src : "";
51
- const urlWithoutParams = srcString.toLowerCase().split("?")[0] || "";
52
- const isContentfulImage = srcString.includes("images.ctfassets.net");
53
- const isSvgFromContentful =
54
- isContentfulImage && urlWithoutParams.endsWith(".svg");
55
-
56
- // Use Contentful's Image API for non-SVG Contentful images;
57
- // skip optimization entirely for SVGs.
58
- const loaderProps =
59
- isContentfulImage && !isSvgFromContentful
60
- ? { loader: contentfulImageLoader, unoptimized: false }
61
- : { unoptimized: isSvgFromContentful };
62
-
63
- return (
64
- <NextJsImage
65
- ref={ref}
66
- className={cx(className)}
67
- {...props}
68
- {...loaderProps}
69
- />
70
- );
71
- }
72
- );
73
-
74
- NextImage.displayName = "NextImage";
1
+ "use client";
2
+
3
+ import { forwardRef } from "react";
4
+ import NextJsImageImport, {
5
+ type ImageLoaderProps,
6
+ type ImageProps as NextImageProps,
7
+ } from "next/image";
8
+
9
+ import { cx } from "@shared/utils";
10
+
11
+ // Handle CJS/ESM interop: when bundled as ESM and consumed by Webpack,
12
+ // the default import may resolve to { default: Component } (or be doubly
13
+ // wrapped) instead of the function. Unwrap until we land on a callable.
14
+ const resolveDefaultExport = (mod: unknown): unknown => {
15
+ let current: any = mod;
16
+ // Limit iterations to avoid pathological cycles.
17
+ for (let i = 0; i < 5; i++) {
18
+ if (typeof current === "function") return current;
19
+ if (current && typeof current === "object" && "default" in current) {
20
+ current = current.default;
21
+ continue;
22
+ }
23
+ return current;
24
+ }
25
+ return current;
26
+ };
27
+ const NextJsImage = resolveDefaultExport(
28
+ NextJsImageImport
29
+ ) as typeof NextJsImageImport;
30
+
31
+ export interface NextImageComponentProps extends NextImageProps {
32
+ className?: string;
33
+ }
34
+
35
+ /**
36
+ * Image loader that uses Contentful's Image API to serve optimized WebP images
37
+ * at the requested width and quality, avoiding an extra round-trip through
38
+ * the Next.js image optimization server.
39
+ */
40
+ const contentfulImageLoader = ({ src, width, quality }: ImageLoaderProps) => {
41
+ const url = new URL(src);
42
+ url.searchParams.set("w", String(width));
43
+ url.searchParams.set("q", String(quality || 90));
44
+ url.searchParams.set("fm", "webp");
45
+ return url.toString();
46
+ };
47
+
48
+ export const NextImage = forwardRef<HTMLImageElement, NextImageComponentProps>(
49
+ ({ className, ...props }, ref) => {
50
+ const srcString = typeof props.src === "string" ? props.src : "";
51
+ const urlWithoutParams = srcString.toLowerCase().split("?")[0] || "";
52
+ const isContentfulImage = srcString.includes("images.ctfassets.net");
53
+ const isSvgFromContentful =
54
+ isContentfulImage && urlWithoutParams.endsWith(".svg");
55
+
56
+ // Use Contentful's Image API for non-SVG Contentful images;
57
+ // skip optimization entirely for SVGs.
58
+ const loaderProps =
59
+ isContentfulImage && !isSvgFromContentful
60
+ ? { loader: contentfulImageLoader, unoptimized: false }
61
+ : { unoptimized: isSvgFromContentful };
62
+
63
+ return (
64
+ <NextJsImage
65
+ ref={ref}
66
+ className={cx(className)}
67
+ {...props}
68
+ {...loaderProps}
69
+ />
70
+ );
71
+ }
72
+ );
73
+
74
+ NextImage.displayName = "NextImage";
@@ -1 +1 @@
1
- export type { NextImageComponentProps } from "./index";
1
+ export type { NextImageComponentProps } from "./index";