@windstream/react-shared-components 0.1.93 → 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 (298) hide show
  1. package/README.md +635 -635
  2. package/dist/contentful/index.esm.js +3 -3
  3. package/dist/contentful/index.esm.js.map +1 -1
  4. package/dist/contentful/index.js +3 -3
  5. package/dist/contentful/index.js.map +1 -1
  6. package/dist/core.d.ts +6 -6
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.esm.js +13 -5
  9. package/dist/index.esm.js.map +1 -1
  10. package/dist/index.js +13 -5
  11. package/dist/index.js.map +1 -1
  12. package/dist/next/index.esm.js +2 -2
  13. package/dist/next/index.esm.js.map +1 -1
  14. package/dist/next/index.js +2 -2
  15. package/dist/next/index.js.map +1 -1
  16. package/dist/styles.css +1 -1
  17. package/dist/utils/index.esm.js +1 -1
  18. package/dist/utils/index.esm.js.map +1 -1
  19. package/dist/utils/index.js +1 -1
  20. package/dist/utils/index.js.map +1 -1
  21. package/package.json +191 -185
  22. package/src/components/accordion/Accordion.stories.tsx +230 -230
  23. package/src/components/accordion/index.test.tsx +270 -0
  24. package/src/components/accordion/index.tsx +70 -70
  25. package/src/components/accordion/types.ts +12 -12
  26. package/src/components/alert-card/AlertCard.stories.tsx +171 -171
  27. package/src/components/alert-card/index.test.tsx +152 -0
  28. package/src/components/alert-card/index.tsx +41 -41
  29. package/src/components/alert-card/types.ts +13 -13
  30. package/src/components/animation-wrapper/index.test.tsx +424 -0
  31. package/src/components/animation-wrapper/index.tsx +129 -129
  32. package/src/components/animation-wrapper/types.ts +11 -11
  33. package/src/components/brand-button/BrandButton.stories.tsx +223 -223
  34. package/src/components/brand-button/helpers.ts +35 -35
  35. package/src/components/brand-button/index.test.tsx +292 -0
  36. package/src/components/brand-button/index.tsx +120 -120
  37. package/src/components/brand-button/types.ts +38 -38
  38. package/src/components/button/Button.stories.tsx +108 -108
  39. package/src/components/button/index.test.tsx +91 -0
  40. package/src/components/button/index.tsx +27 -27
  41. package/src/components/button/types.ts +14 -14
  42. package/src/components/call-button/CallButton.stories.tsx +324 -324
  43. package/src/components/call-button/index.test.tsx +260 -0
  44. package/src/components/call-button/index.tsx +106 -106
  45. package/src/components/call-button/types.ts +16 -16
  46. package/src/components/checkbox/Checkbox.stories.tsx +247 -247
  47. package/src/components/checkbox/index.test.tsx +252 -0
  48. package/src/components/checkbox/index.tsx +197 -197
  49. package/src/components/checkbox/types.ts +27 -27
  50. package/src/components/checklist/Checklist.stories.tsx +150 -150
  51. package/src/components/checklist/index.test.tsx +231 -0
  52. package/src/components/checklist/index.tsx +96 -61
  53. package/src/components/checklist/types.ts +23 -17
  54. package/src/components/collapse/Collapse.stories.tsx +255 -255
  55. package/src/components/collapse/index.test.tsx +277 -0
  56. package/src/components/collapse/index.tsx +47 -46
  57. package/src/components/collapse/types.ts +6 -6
  58. package/src/components/divider/Divider.stories.tsx +205 -205
  59. package/src/components/divider/index.test.tsx +53 -0
  60. package/src/components/divider/index.tsx +22 -22
  61. package/src/components/divider/type.ts +3 -3
  62. package/src/components/image/Image.stories.tsx +113 -113
  63. package/src/components/image/index.test.tsx +174 -0
  64. package/src/components/image/index.tsx +25 -25
  65. package/src/components/image/types.ts +40 -40
  66. package/src/components/input/Input.stories.tsx +325 -325
  67. package/src/components/input/index.test.tsx +348 -0
  68. package/src/components/input/index.tsx +177 -177
  69. package/src/components/input/types.ts +37 -37
  70. package/src/components/link/Link.stories.tsx +163 -163
  71. package/src/components/link/index.test.tsx +199 -0
  72. package/src/components/link/index.tsx +116 -116
  73. package/src/components/link/types.ts +25 -25
  74. package/src/components/list/List.stories.tsx +272 -272
  75. package/src/components/list/index.test.tsx +166 -0
  76. package/src/components/list/index.tsx +88 -88
  77. package/src/components/list/list-item/index.tsx +38 -38
  78. package/src/components/list/list-item/types.ts +13 -13
  79. package/src/components/list/types.ts +29 -29
  80. package/src/components/material-icon/MaterialIcon.stories.tsx +322 -322
  81. package/src/components/material-icon/constants.ts +99 -99
  82. package/src/components/material-icon/index.test.tsx +130 -0
  83. package/src/components/material-icon/index.tsx +47 -47
  84. package/src/components/material-icon/types.ts +31 -31
  85. package/src/components/modal/Modal.stories.tsx +171 -171
  86. package/src/components/modal/index.test.tsx +310 -0
  87. package/src/components/modal/index.tsx +164 -164
  88. package/src/components/modal/types.ts +24 -24
  89. package/src/components/next-image/index.test.tsx +406 -0
  90. package/src/components/next-image/index.tsx +74 -74
  91. package/src/components/next-image/types.ts +1 -1
  92. package/src/components/pagination/index.test.tsx +521 -0
  93. package/src/components/pagination/index.tsx +91 -91
  94. package/src/components/pagination/types.ts +6 -6
  95. package/src/components/radio-button/RadioButton.stories.tsx +307 -307
  96. package/src/components/radio-button/index.test.tsx +151 -0
  97. package/src/components/radio-button/index.tsx +75 -75
  98. package/src/components/radio-button/types.ts +21 -21
  99. package/src/components/see-more/SeeMore.stories.tsx +181 -181
  100. package/src/components/see-more/index.test.tsx +96 -0
  101. package/src/components/see-more/index.tsx +44 -44
  102. package/src/components/see-more/types.ts +4 -4
  103. package/src/components/select/Select.stories.tsx +411 -411
  104. package/src/components/select/index.test.tsx +256 -0
  105. package/src/components/select/index.tsx +155 -155
  106. package/src/components/select/types.ts +36 -36
  107. package/src/components/select-plan-button/SelectPlanButton.stories.tsx +184 -184
  108. package/src/components/select-plan-button/index.test.tsx +173 -0
  109. package/src/components/select-plan-button/index.tsx +63 -63
  110. package/src/components/select-plan-button/types.ts +17 -17
  111. package/src/components/skeleton/Skeleton.stories.tsx +179 -179
  112. package/src/components/skeleton/index.test.tsx +74 -0
  113. package/src/components/skeleton/index.tsx +61 -61
  114. package/src/components/skeleton/types.ts +4 -4
  115. package/src/components/spinner/Spinner.stories.tsx +335 -335
  116. package/src/components/spinner/index.test.tsx +76 -0
  117. package/src/components/spinner/index.tsx +44 -44
  118. package/src/components/spinner/types.ts +5 -5
  119. package/src/components/text/Text.stories.tsx +321 -321
  120. package/src/components/text/index.test.tsx +65 -0
  121. package/src/components/text/index.tsx +25 -25
  122. package/src/components/text/types.ts +45 -45
  123. package/src/components/tooltip/Tooltip.stories.tsx +219 -219
  124. package/src/components/tooltip/index.test.tsx +50 -0
  125. package/src/components/tooltip/index.tsx +74 -74
  126. package/src/components/tooltip/types.ts +7 -7
  127. package/src/components/view-cart-button/ViewCartButton.stories.tsx +252 -252
  128. package/src/components/view-cart-button/index.test.tsx +57 -0
  129. package/src/components/view-cart-button/index.tsx +42 -42
  130. package/src/components/view-cart-button/types.ts +5 -5
  131. package/src/contentful/blocks/accordion/Accordion.stories.mocks.tsx +128 -128
  132. package/src/contentful/blocks/accordion/Accordion.stories.tsx +98 -98
  133. package/src/contentful/blocks/accordion/index.test.tsx +218 -0
  134. package/src/contentful/blocks/accordion/index.tsx +114 -112
  135. package/src/contentful/blocks/accordion/types.ts +34 -34
  136. package/src/contentful/blocks/address-input-banner/index.test.tsx +132 -0
  137. package/src/contentful/blocks/address-input-banner/index.tsx +52 -52
  138. package/src/contentful/blocks/address-input-banner/types.ts +14 -14
  139. package/src/contentful/blocks/anchored-bottom-banner/index.test.tsx +287 -0
  140. package/src/contentful/blocks/anchored-bottom-banner/index.tsx +181 -181
  141. package/src/contentful/blocks/anchored-bottom-banner/types.ts +13 -13
  142. package/src/contentful/blocks/blogs-grid/BlogGrid.stories.mocks.tsx +144 -144
  143. package/src/contentful/blocks/blogs-grid/BlogGrid.stories.tsx +157 -156
  144. package/src/contentful/blocks/blogs-grid/index.test.tsx +355 -0
  145. package/src/contentful/blocks/blogs-grid/index.tsx +134 -134
  146. package/src/contentful/blocks/blogs-grid/types.ts +26 -26
  147. package/src/contentful/blocks/blogs-grid-base/index.test.tsx +274 -0
  148. package/src/contentful/blocks/blogs-grid-base/index.tsx +119 -119
  149. package/src/contentful/blocks/blogs-grid-base/types.ts +36 -36
  150. package/src/contentful/blocks/breadcrumbs/BreadcrumbNavigation.stories.tsx +147 -147
  151. package/src/contentful/blocks/breadcrumbs/index.test.tsx +281 -0
  152. package/src/contentful/blocks/breadcrumbs/index.tsx +95 -95
  153. package/src/contentful/blocks/breadcrumbs/types.ts +8 -8
  154. package/src/contentful/blocks/button/Button.stories.tsx +40 -40
  155. package/src/contentful/blocks/button/index.test.tsx +339 -0
  156. package/src/contentful/blocks/button/index.tsx +131 -131
  157. package/src/contentful/blocks/button/types.ts +39 -39
  158. package/src/contentful/blocks/callout/Callout.stories.tsx +23 -23
  159. package/src/contentful/blocks/callout/index.test.tsx +539 -0
  160. package/src/contentful/blocks/callout/index.tsx +277 -277
  161. package/src/contentful/blocks/callout/types.ts +78 -78
  162. package/src/contentful/blocks/cards/Cards.stories.tsx +23 -23
  163. package/src/contentful/blocks/cards/blog-card/index.test.tsx +218 -0
  164. package/src/contentful/blocks/cards/blog-card/index.tsx +129 -129
  165. package/src/contentful/blocks/cards/blog-card/types.ts +34 -34
  166. package/src/contentful/blocks/cards/floating-image-card/index.test.tsx +201 -0
  167. package/src/contentful/blocks/cards/floating-image-card/index.tsx +119 -119
  168. package/src/contentful/blocks/cards/floating-image-card/types.ts +30 -30
  169. package/src/contentful/blocks/cards/full-image-card/index.test.tsx +216 -0
  170. package/src/contentful/blocks/cards/full-image-card/index.tsx +130 -130
  171. package/src/contentful/blocks/cards/full-image-card/types.ts +29 -29
  172. package/src/contentful/blocks/cards/index.test.tsx +39 -0
  173. package/src/contentful/blocks/cards/index.tsx +13 -13
  174. package/src/contentful/blocks/cards/product-card/index.test.tsx +263 -0
  175. package/src/contentful/blocks/cards/product-card/index.tsx +251 -251
  176. package/src/contentful/blocks/cards/product-card/types.ts +28 -28
  177. package/src/contentful/blocks/cards/simple-card/index.test.tsx +364 -0
  178. package/src/contentful/blocks/cards/simple-card/index.tsx +325 -325
  179. package/src/contentful/blocks/cards/simple-card/types.ts +71 -71
  180. package/src/contentful/blocks/cards/testimonial-card/index.test.tsx +180 -0
  181. package/src/contentful/blocks/cards/testimonial-card/index.tsx +90 -90
  182. package/src/contentful/blocks/cards/testimonial-card/types.tsx +12 -12
  183. package/src/contentful/blocks/cards/types.ts +1 -1
  184. package/src/contentful/blocks/carousel/Carousel.stories.tsx +23 -23
  185. package/src/contentful/blocks/carousel/helper.test.tsx +539 -0
  186. package/src/contentful/blocks/carousel/helper.tsx +494 -494
  187. package/src/contentful/blocks/carousel/index.test.tsx +308 -0
  188. package/src/contentful/blocks/carousel/index.tsx +87 -87
  189. package/src/contentful/blocks/carousel/types.test.ts +16 -0
  190. package/src/contentful/blocks/carousel/types.ts +145 -145
  191. package/src/contentful/blocks/cart-retention-banner/index.test.tsx +409 -0
  192. package/src/contentful/blocks/cart-retention-banner/index.tsx +109 -109
  193. package/src/contentful/blocks/cart-retention-banner/types.ts +11 -11
  194. package/src/contentful/blocks/comparison-table/index.test.tsx +114 -0
  195. package/src/contentful/blocks/comparison-table/index.tsx +29 -29
  196. package/src/contentful/blocks/comparison-table/types.ts +6 -6
  197. package/src/contentful/blocks/cookiebanner/index.test.tsx +277 -0
  198. package/src/contentful/blocks/cookiebanner/index.tsx +146 -146
  199. package/src/contentful/blocks/cookiebanner/type.ts +7 -7
  200. package/src/contentful/blocks/cta-callout/CtaCallout.stories.tsx +46 -46
  201. package/src/contentful/blocks/cta-callout/index.test.tsx +244 -0
  202. package/src/contentful/blocks/cta-callout/index.tsx +73 -73
  203. package/src/contentful/blocks/cta-callout/types.ts +26 -26
  204. package/src/contentful/blocks/dynamic-tabs/index.test.tsx +240 -0
  205. package/src/contentful/blocks/dynamic-tabs/index.tsx +204 -204
  206. package/src/contentful/blocks/dynamic-tabs/types.ts +21 -21
  207. package/src/contentful/blocks/email-input-block/index.test.tsx +213 -0
  208. package/src/contentful/blocks/email-input-block/index.tsx +121 -116
  209. package/src/contentful/blocks/email-input-block/types.ts +16 -16
  210. package/src/contentful/blocks/find-kinetic/FindKinetic.stories.tsx +23 -23
  211. package/src/contentful/blocks/find-kinetic/index.test.tsx +269 -0
  212. package/src/contentful/blocks/find-kinetic/index.tsx +138 -138
  213. package/src/contentful/blocks/find-kinetic/types.ts +20 -20
  214. package/src/contentful/blocks/floating-banner/FloatingBanner.stories.tsx +34 -34
  215. package/src/contentful/blocks/floating-banner/index.test.tsx +246 -0
  216. package/src/contentful/blocks/floating-banner/index.tsx +97 -97
  217. package/src/contentful/blocks/floating-banner/types.ts +22 -22
  218. package/src/contentful/blocks/footer/Footer.stories.tsx +317 -317
  219. package/src/contentful/blocks/footer/index.test.tsx +302 -0
  220. package/src/contentful/blocks/footer/index.tsx +91 -91
  221. package/src/contentful/blocks/footer/types.ts +13 -13
  222. package/src/contentful/blocks/image-promo-bar/ImagePromoBar.stories.tsx +23 -23
  223. package/src/contentful/blocks/image-promo-bar/helper.test.tsx +61 -0
  224. package/src/contentful/blocks/image-promo-bar/helper.tsx +28 -28
  225. package/src/contentful/blocks/image-promo-bar/index.test.tsx +467 -0
  226. package/src/contentful/blocks/image-promo-bar/index.tsx +246 -246
  227. package/src/contentful/blocks/image-promo-bar/types.ts +44 -44
  228. package/src/contentful/blocks/image-promo-bar/vimeo-embed.test.tsx +142 -0
  229. package/src/contentful/blocks/image-promo-bar/vimeo-embed.tsx +93 -93
  230. package/src/contentful/blocks/image-promo-bar/youtube-embed.test.tsx +104 -0
  231. package/src/contentful/blocks/image-promo-bar/youtube-embed.tsx +46 -46
  232. package/src/contentful/blocks/modal/constants.ts +53 -53
  233. package/src/contentful/blocks/modal/index.test.tsx +209 -0
  234. package/src/contentful/blocks/modal/index.tsx +108 -108
  235. package/src/contentful/blocks/modal/types.ts +12 -12
  236. package/src/contentful/blocks/navigation/Navigation.stories.mocks.tsx +78 -78
  237. package/src/contentful/blocks/navigation/Navigation.stories.tsx +138 -138
  238. package/src/contentful/blocks/navigation/desktop-link-groups.tsx/index.test.tsx +208 -0
  239. package/src/contentful/blocks/navigation/desktop-link-groups.tsx/index.tsx +141 -141
  240. package/src/contentful/blocks/navigation/index.test.tsx +924 -0
  241. package/src/contentful/blocks/navigation/index.tsx +569 -569
  242. package/src/contentful/blocks/navigation/mobile-link-groups.tsx/index.test.tsx +131 -0
  243. package/src/contentful/blocks/navigation/mobile-link-groups.tsx/index.tsx +82 -82
  244. package/src/contentful/blocks/navigation/types.ts +71 -71
  245. package/src/contentful/blocks/primary-hero/PrimaryHero.stories.tsx +23 -23
  246. package/src/contentful/blocks/primary-hero/index.test.tsx +286 -0
  247. package/src/contentful/blocks/primary-hero/index.tsx +239 -236
  248. package/src/contentful/blocks/primary-hero/types.ts +37 -37
  249. package/src/contentful/blocks/search-block/index.test.tsx +268 -0
  250. package/src/contentful/blocks/search-block/index.tsx +90 -90
  251. package/src/contentful/blocks/search-block/types.ts +15 -15
  252. package/src/contentful/blocks/shape-background-wrapper/ShapeBackgroundWrapper.stories.tsx +26 -26
  253. package/src/contentful/blocks/shape-background-wrapper/index.test.tsx +284 -0
  254. package/src/contentful/blocks/shape-background-wrapper/index.tsx +124 -124
  255. package/src/contentful/blocks/shape-background-wrapper/types.ts +36 -36
  256. package/src/contentful/blocks/text/Text.stories.tsx +23 -23
  257. package/src/contentful/blocks/text/index.test.tsx +36 -0
  258. package/src/contentful/blocks/text/index.tsx +12 -12
  259. package/src/contentful/blocks/text/types.ts +1 -1
  260. package/src/contentful/index.test.ts +45 -0
  261. package/src/contentful/index.ts +105 -105
  262. package/src/global-mocks/contentful/to-document.ts +25 -0
  263. package/src/global-mocks/cookie.ts +48 -0
  264. package/src/global-mocks/cx.ts +37 -0
  265. package/src/global-mocks/index.ts +89 -0
  266. package/src/global-mocks/speed-card-bg.ts +27 -0
  267. package/src/global-mocks/utm.ts +49 -0
  268. package/src/hooks/contentful/use-contentful-rich-text.test.tsx +1758 -0
  269. package/src/hooks/contentful/use-contentful-rich-text.tsx +309 -309
  270. package/src/hooks/contentful/use-processed-check-list.test.tsx +277 -0
  271. package/src/hooks/contentful/use-processed-check-list.ts +63 -63
  272. package/src/hooks/use-body-scroll-lock.test.ts +134 -0
  273. package/src/hooks/use-body-scroll-lock.ts +34 -34
  274. package/src/hooks/use-carousel-swipe.test.ts +393 -0
  275. package/src/hooks/use-carousel-swipe.ts +264 -264
  276. package/src/hooks/use-outside-click.test.ts +142 -0
  277. package/src/hooks/use-outside-click.ts +17 -17
  278. package/src/index.ts +107 -107
  279. package/src/next/index.test.ts +7 -0
  280. package/src/next/index.ts +5 -5
  281. package/src/setupTests.ts +52 -46
  282. package/src/stories/DocsTemplate.tsx +24 -24
  283. package/src/styles/globals.css +343 -343
  284. package/src/types/global.d.ts +9 -9
  285. package/src/types/micro-components.ts +99 -99
  286. package/src/types/utm.ts +49 -49
  287. package/src/utils/contentful/to-document.test.ts +85 -0
  288. package/src/utils/contentful/to-document.ts +24 -24
  289. package/src/utils/cookie.test.ts +180 -0
  290. package/src/utils/cookie.ts +84 -84
  291. package/src/utils/cx.test.ts +90 -0
  292. package/src/utils/cx.ts +49 -49
  293. package/src/utils/index.test.ts +115 -0
  294. package/src/utils/index.ts +41 -41
  295. package/src/utils/speed-card-bg.test.ts +46 -0
  296. package/src/utils/speed-card-bg.ts +24 -24
  297. package/src/utils/utm.test.ts +359 -0
  298. 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";