@fpkit/acss 0.5.11 → 0.5.13

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 (312) hide show
  1. package/README.md +514 -18
  2. package/libs/chunk-23ANBDCR.js +8 -0
  3. package/libs/chunk-23ANBDCR.js.map +1 -0
  4. package/libs/chunk-2LTJ7HHX.cjs +18 -0
  5. package/libs/chunk-2LTJ7HHX.cjs.map +1 -0
  6. package/libs/chunk-2Y7W75TT.js +9 -0
  7. package/libs/chunk-2Y7W75TT.js.map +1 -0
  8. package/libs/chunk-3MKLDCKQ.cjs +31 -0
  9. package/libs/chunk-3MKLDCKQ.cjs.map +1 -0
  10. package/libs/chunk-5M57K4SW.js +8 -0
  11. package/libs/chunk-5M57K4SW.js.map +1 -0
  12. package/libs/chunk-5S4ORA4C.cjs +15 -0
  13. package/libs/chunk-5S4ORA4C.cjs.map +1 -0
  14. package/libs/chunk-772NRB75.js +9 -0
  15. package/libs/chunk-772NRB75.js.map +1 -0
  16. package/libs/chunk-AHDJGCG5.cjs +15 -0
  17. package/libs/chunk-AHDJGCG5.cjs.map +1 -0
  18. package/libs/chunk-B7F5FS6D.cjs +16 -0
  19. package/libs/chunk-B7F5FS6D.cjs.map +1 -0
  20. package/libs/chunk-BHRQBJRY.js +8 -0
  21. package/libs/chunk-BHRQBJRY.js.map +1 -0
  22. package/libs/chunk-D4YLRWAO.cjs +18 -0
  23. package/libs/chunk-D4YLRWAO.cjs.map +1 -0
  24. package/libs/chunk-ETFLFC2S.js +10 -0
  25. package/libs/chunk-ETFLFC2S.js.map +1 -0
  26. package/libs/chunk-G55UJ53G.cjs +16 -0
  27. package/libs/chunk-G55UJ53G.cjs.map +1 -0
  28. package/libs/chunk-GZ4QFPRY.js +9 -0
  29. package/libs/chunk-GZ4QFPRY.js.map +1 -0
  30. package/libs/chunk-IYUN2EW3.cjs +15 -0
  31. package/libs/chunk-IYUN2EW3.cjs.map +1 -0
  32. package/libs/chunk-J32EZPYD.cjs +15 -0
  33. package/libs/chunk-J32EZPYD.cjs.map +1 -0
  34. package/libs/chunk-JJ43O4Y5.js +8 -0
  35. package/libs/chunk-JJ43O4Y5.js.map +1 -0
  36. package/libs/chunk-KUKIVRC2.js +7 -0
  37. package/libs/chunk-KUKIVRC2.js.map +1 -0
  38. package/libs/chunk-L75OQKEI.cjs +13 -0
  39. package/libs/chunk-L75OQKEI.cjs.map +1 -0
  40. package/libs/chunk-LT5KZ2QW.cjs +22 -0
  41. package/libs/chunk-LT5KZ2QW.cjs.map +1 -0
  42. package/libs/chunk-M5RRNTVX.cjs +15 -0
  43. package/libs/chunk-M5RRNTVX.cjs.map +1 -0
  44. package/libs/chunk-NGTJDDFO.js +8 -0
  45. package/libs/chunk-NGTJDDFO.js.map +1 -0
  46. package/libs/chunk-OK5QEIMD.cjs +17 -0
  47. package/libs/chunk-OK5QEIMD.cjs.map +1 -0
  48. package/libs/chunk-P2DC76ZZ.cjs +18 -0
  49. package/libs/chunk-P2DC76ZZ.cjs.map +1 -0
  50. package/libs/chunk-P7TTEYCD.js +7 -0
  51. package/libs/chunk-P7TTEYCD.js.map +1 -0
  52. package/libs/chunk-PQ2K3BM6.cjs +17 -0
  53. package/libs/chunk-PQ2K3BM6.cjs.map +1 -0
  54. package/libs/chunk-QLZWHAMK.js +8 -0
  55. package/libs/chunk-QLZWHAMK.js.map +1 -0
  56. package/libs/chunk-RIVUMPOG.js +8 -0
  57. package/libs/chunk-RIVUMPOG.js.map +1 -0
  58. package/libs/chunk-ROZI23GS.cjs +15 -0
  59. package/libs/chunk-ROZI23GS.cjs.map +1 -0
  60. package/libs/chunk-S7BABR7Z.cjs +13 -0
  61. package/libs/chunk-S7BABR7Z.cjs.map +1 -0
  62. package/libs/chunk-SMYRLO3E.js +8 -0
  63. package/libs/chunk-SMYRLO3E.js.map +1 -0
  64. package/libs/chunk-TYRCEX2L.js +8 -0
  65. package/libs/chunk-TYRCEX2L.js.map +1 -0
  66. package/libs/chunk-VUH3FXGJ.js +11 -0
  67. package/libs/chunk-VUH3FXGJ.js.map +1 -0
  68. package/libs/chunk-XBA562WW.js +8 -0
  69. package/libs/chunk-XBA562WW.js.map +1 -0
  70. package/libs/chunk-XTQKWY7W.cjs +32 -0
  71. package/libs/chunk-XTQKWY7W.cjs.map +1 -0
  72. package/libs/chunk-ZANSFMTD.js +9 -0
  73. package/libs/chunk-ZANSFMTD.js.map +1 -0
  74. package/libs/component-props-a8a2f97e.d.ts +38 -0
  75. package/libs/components/alert/alert.css +1 -1
  76. package/libs/components/alert/alert.css.map +1 -1
  77. package/libs/components/alert/alert.min.css +2 -2
  78. package/libs/components/badge/badge.css +1 -1
  79. package/libs/components/badge/badge.css.map +1 -1
  80. package/libs/components/badge/badge.min.css +2 -2
  81. package/libs/components/breadcrumbs/breadcrumb.cjs +24 -0
  82. package/libs/components/breadcrumbs/breadcrumb.cjs.map +1 -0
  83. package/libs/components/breadcrumbs/breadcrumb.d.cts +290 -0
  84. package/libs/components/breadcrumbs/breadcrumb.d.ts +290 -0
  85. package/libs/components/breadcrumbs/breadcrumb.js +5 -0
  86. package/libs/components/breadcrumbs/breadcrumb.js.map +1 -0
  87. package/libs/components/button.cjs +19 -0
  88. package/libs/components/button.cjs.map +1 -0
  89. package/libs/components/button.d.cts +16 -0
  90. package/libs/components/button.d.ts +16 -0
  91. package/libs/components/button.js +4 -0
  92. package/libs/components/button.js.map +1 -0
  93. package/libs/components/buttons/button.css +1 -1
  94. package/libs/components/buttons/button.css.map +1 -1
  95. package/libs/components/buttons/button.min.css +2 -2
  96. package/libs/components/card.cjs +31 -0
  97. package/libs/components/card.cjs.map +1 -0
  98. package/libs/components/card.d.cts +302 -0
  99. package/libs/components/card.d.ts +302 -0
  100. package/libs/components/card.js +4 -0
  101. package/libs/components/card.js.map +1 -0
  102. package/libs/components/cards/card.css +1 -1
  103. package/libs/components/cards/card.css.map +1 -1
  104. package/libs/components/cards/card.min.css +2 -2
  105. package/libs/components/details/details.css +1 -1
  106. package/libs/components/details/details.css.map +1 -1
  107. package/libs/components/details/details.min.css +2 -2
  108. package/libs/components/dialog/dialog.cjs +22 -0
  109. package/libs/components/dialog/dialog.cjs.map +1 -0
  110. package/libs/components/dialog/dialog.css +1 -1
  111. package/libs/components/dialog/dialog.css.map +1 -1
  112. package/libs/components/dialog/dialog.d.cts +105 -0
  113. package/libs/components/dialog/dialog.d.ts +105 -0
  114. package/libs/components/dialog/dialog.js +7 -0
  115. package/libs/components/dialog/dialog.js.map +1 -0
  116. package/libs/components/dialog/dialog.min.css +2 -2
  117. package/libs/components/form/fields.cjs +19 -0
  118. package/libs/components/form/fields.cjs.map +1 -0
  119. package/libs/components/form/fields.d.cts +24 -0
  120. package/libs/components/form/fields.d.ts +24 -0
  121. package/libs/components/form/fields.js +4 -0
  122. package/libs/components/form/fields.js.map +1 -0
  123. package/libs/components/form/inputs.cjs +19 -0
  124. package/libs/components/form/inputs.cjs.map +1 -0
  125. package/libs/components/form/inputs.d.cts +2 -0
  126. package/libs/components/form/inputs.d.ts +2 -0
  127. package/libs/components/form/inputs.js +4 -0
  128. package/libs/components/form/inputs.js.map +1 -0
  129. package/libs/components/form/textarea.cjs +19 -0
  130. package/libs/components/form/textarea.cjs.map +1 -0
  131. package/libs/components/form/textarea.d.cts +29 -0
  132. package/libs/components/form/textarea.d.ts +29 -0
  133. package/libs/components/form/textarea.js +4 -0
  134. package/libs/components/form/textarea.js.map +1 -0
  135. package/libs/components/heading/heading.cjs +10 -0
  136. package/libs/components/heading/heading.cjs.map +1 -0
  137. package/libs/components/heading/heading.d.cts +3 -0
  138. package/libs/components/heading/heading.d.ts +3 -0
  139. package/libs/components/heading/heading.js +4 -0
  140. package/libs/components/heading/heading.js.map +1 -0
  141. package/libs/components/icons/icon.cjs +19 -0
  142. package/libs/components/icons/icon.cjs.map +1 -0
  143. package/libs/{icons-31ace3de.d.ts → components/icons/icon.d.cts} +151 -61
  144. package/libs/components/icons/icon.d.ts +445 -0
  145. package/libs/components/icons/icon.js +4 -0
  146. package/libs/components/icons/icon.js.map +1 -0
  147. package/libs/components/images/img.css +1 -1
  148. package/libs/components/images/img.css.map +1 -1
  149. package/libs/components/images/img.min.css +2 -2
  150. package/libs/components/link/link.cjs +19 -0
  151. package/libs/components/link/link.cjs.map +1 -0
  152. package/libs/components/link/link.d.cts +19 -0
  153. package/libs/components/link/link.d.ts +19 -0
  154. package/libs/components/link/link.js +4 -0
  155. package/libs/components/link/link.js.map +1 -0
  156. package/libs/components/list/list.cjs +23 -0
  157. package/libs/components/list/list.cjs.map +1 -0
  158. package/libs/components/list/list.d.cts +39 -0
  159. package/libs/components/list/list.d.ts +39 -0
  160. package/libs/components/list/list.js +4 -0
  161. package/libs/components/list/list.js.map +1 -0
  162. package/libs/components/modal.cjs +14 -0
  163. package/libs/components/modal.cjs.map +1 -0
  164. package/libs/components/modal.d.cts +35 -0
  165. package/libs/components/modal.d.ts +35 -0
  166. package/libs/components/modal.js +5 -0
  167. package/libs/components/modal.js.map +1 -0
  168. package/libs/components/nav/nav.cjs +28 -0
  169. package/libs/components/nav/nav.cjs.map +1 -0
  170. package/libs/components/nav/nav.d.cts +44 -0
  171. package/libs/components/nav/nav.d.ts +44 -0
  172. package/libs/components/nav/nav.js +5 -0
  173. package/libs/components/nav/nav.js.map +1 -0
  174. package/libs/components/popover/popover.cjs +23 -0
  175. package/libs/components/popover/popover.cjs.map +1 -0
  176. package/libs/components/popover/popover.d.cts +40 -0
  177. package/libs/components/popover/popover.d.ts +40 -0
  178. package/libs/components/popover/popover.js +4 -0
  179. package/libs/components/popover/popover.js.map +1 -0
  180. package/libs/components/tables/table.cjs +21 -0
  181. package/libs/components/tables/table.cjs.map +1 -0
  182. package/libs/components/tables/table.d.cts +36 -0
  183. package/libs/components/tables/table.d.ts +36 -0
  184. package/libs/components/tables/table.js +4 -0
  185. package/libs/components/tables/table.js.map +1 -0
  186. package/libs/components/text/text.cjs +23 -0
  187. package/libs/components/text/text.cjs.map +1 -0
  188. package/libs/components/text/text.d.cts +30 -0
  189. package/libs/components/text/text.d.ts +30 -0
  190. package/libs/components/text/text.js +4 -0
  191. package/libs/components/text/text.js.map +1 -0
  192. package/libs/heading-3648c538.d.ts +250 -0
  193. package/libs/hooks.cjs +7 -0
  194. package/libs/hooks.d.cts +5 -0
  195. package/libs/hooks.d.ts +5 -0
  196. package/libs/hooks.js +3 -0
  197. package/libs/icons.cjs +3 -2
  198. package/libs/icons.d.cts +3 -1
  199. package/libs/icons.d.ts +3 -1
  200. package/libs/icons.js +2 -1
  201. package/libs/index.cjs +174 -62
  202. package/libs/index.cjs.map +1 -1
  203. package/libs/index.css +1 -1
  204. package/libs/index.css.map +1 -1
  205. package/libs/index.d.cts +529 -446
  206. package/libs/index.d.ts +529 -446
  207. package/libs/index.js +36 -7
  208. package/libs/index.js.map +1 -1
  209. package/libs/inputs-f3a216db.d.ts +45 -0
  210. package/libs/ui-645f95b5.d.ts +285 -0
  211. package/package.json +2 -2
  212. package/src/components/README-UI.mdx +416 -0
  213. package/src/components/alert/ACCESSIBILITY.md +319 -0
  214. package/src/components/alert/README.mdx +475 -19
  215. package/src/components/alert/alert.scss +113 -6
  216. package/src/components/alert/alert.stories.tsx +372 -0
  217. package/src/components/alert/alert.test.tsx +762 -0
  218. package/src/components/alert/alert.tsx +331 -66
  219. package/src/components/alert/views/alert-actions.tsx +13 -0
  220. package/src/components/alert/views/alert-content.tsx +17 -0
  221. package/src/components/alert/views/alert-icon.tsx +53 -0
  222. package/src/components/alert/views/alert-screen-reader-text.tsx +30 -0
  223. package/src/components/alert/views/alert-title.tsx +23 -0
  224. package/src/components/alert/views/alert-view.tsx +158 -0
  225. package/src/components/alert/views/index.ts +12 -0
  226. package/src/components/badge/badge.mdx +186 -49
  227. package/src/components/badge/badge.scss +20 -2
  228. package/src/components/badge/badge.stories.tsx +160 -14
  229. package/src/components/badge/badge.test.tsx +179 -0
  230. package/src/components/badge/badge.tsx +97 -4
  231. package/src/components/breadcrumbs/README.mdx +364 -45
  232. package/src/components/breadcrumbs/__snapshots__/breadcrumb.test.tsx.snap +152 -0
  233. package/src/components/breadcrumbs/breadcrumb.stories.tsx +7 -3
  234. package/src/components/breadcrumbs/breadcrumb.test.tsx +490 -0
  235. package/src/components/breadcrumbs/breadcrumb.tsx +427 -170
  236. package/src/components/button.ts +2 -0
  237. package/src/components/buttons/button.scss +34 -31
  238. package/src/components/buttons/button.stories.tsx +35 -0
  239. package/src/components/card.ts +2 -0
  240. package/src/components/cards/README.mdx +657 -0
  241. package/src/components/cards/card.scss +22 -0
  242. package/src/components/cards/card.stories.tsx +167 -5
  243. package/src/components/cards/card.test.tsx +360 -20
  244. package/src/components/cards/card.tsx +200 -79
  245. package/src/components/cards/card.types.ts +135 -0
  246. package/src/components/cards/card.utils.ts +79 -0
  247. package/src/components/details/ACCESSIBILITY-REVIEW-LIVE.md +1050 -0
  248. package/src/components/details/ACCESSIBILITY-REVIEW.md +502 -0
  249. package/src/components/details/README.mdx +437 -69
  250. package/src/components/details/details.scss +16 -0
  251. package/src/components/details/details.test.tsx +385 -0
  252. package/src/components/details/details.tsx +101 -69
  253. package/src/components/details/details.types.ts +76 -0
  254. package/src/components/dialog/README.mdx +513 -110
  255. package/src/components/dialog/dialog-modal.tsx +79 -56
  256. package/src/components/dialog/dialog.scss +53 -3
  257. package/src/components/dialog/dialog.stories.tsx +10 -7
  258. package/src/components/dialog/dialog.test.tsx +450 -0
  259. package/src/components/dialog/dialog.tsx +69 -59
  260. package/src/components/dialog/dialog.types.ts +133 -0
  261. package/src/components/dialog/views/dialog-footer.tsx +54 -11
  262. package/src/components/dialog/views/dialog-header.tsx +20 -15
  263. package/src/components/heading/heading.stories.tsx +44 -4
  264. package/src/components/heading/heading.tsx +89 -23
  265. package/src/components/icons/README.mdx +332 -0
  266. package/src/components/icons/icon.stories.tsx +74 -1
  267. package/src/components/icons/icon.tsx +89 -1
  268. package/src/components/icons/types.ts +47 -0
  269. package/src/components/images/README.mdx +340 -24
  270. package/src/components/images/img.scss +19 -3
  271. package/src/components/images/img.stories.tsx +424 -15
  272. package/src/components/images/img.test.tsx +354 -25
  273. package/src/components/images/img.tsx +186 -63
  274. package/src/components/images/img.types.ts +211 -0
  275. package/src/components/modal.ts +1 -0
  276. package/src/components/title/MIGRATION.md +199 -0
  277. package/src/components/title/README.md +326 -0
  278. package/src/components/title/README.mdx +452 -0
  279. package/src/components/title/title.stories.tsx +393 -0
  280. package/src/components/title/title.test.tsx +251 -0
  281. package/src/components/title/title.tsx +219 -0
  282. package/src/components/ui.stories.tsx +894 -0
  283. package/src/components/ui.test.tsx +559 -0
  284. package/src/components/ui.tsx +266 -15
  285. package/src/components/word-count/README.md +240 -0
  286. package/src/hooks.ts +1 -0
  287. package/src/index.ts +51 -19
  288. package/src/sass/_properties.scss +1 -0
  289. package/src/styles/alert/alert.css +94 -4
  290. package/src/styles/alert/alert.css.map +1 -1
  291. package/src/styles/badge/badge.css +20 -2
  292. package/src/styles/badge/badge.css.map +1 -1
  293. package/src/styles/buttons/button.css +31 -31
  294. package/src/styles/buttons/button.css.map +1 -1
  295. package/src/styles/cards/card.css +16 -0
  296. package/src/styles/cards/card.css.map +1 -1
  297. package/src/styles/details/details.css +19 -0
  298. package/src/styles/details/details.css.map +1 -1
  299. package/src/styles/dialog/dialog.css +43 -2
  300. package/src/styles/dialog/dialog.css.map +1 -1
  301. package/src/styles/images/img.css +15 -3
  302. package/src/styles/images/img.css.map +1 -1
  303. package/src/styles/index.css +240 -43
  304. package/src/styles/index.css.map +1 -1
  305. package/src/test/setup.d.ts +9 -0
  306. package/src/test/setup.ts +53 -1
  307. package/libs/chunk-PWVRDQ3R.js +0 -8
  308. package/libs/chunk-PWVRDQ3R.js.map +0 -1
  309. package/libs/chunk-SVS4MX3U.cjs +0 -31
  310. package/libs/chunk-SVS4MX3U.cjs.map +0 -1
  311. package/src/components/cards/README.md +0 -80
  312. package/src/components/dialog/hooks/useClickOutside.ts +0 -33
@@ -1,43 +1,372 @@
1
1
  import { Img } from './img'
2
2
  import React from 'react'
3
3
  import { fireEvent, render, screen } from '@testing-library/react'
4
- import jest from 'jest-mock'
4
+ import { describe, it, expect, vi } from 'vitest'
5
+
5
6
  describe('Img', () => {
6
- it('should render an img element with passed props', () => {
7
- const src = 'test.jpg'
8
- const alt = 'Test image'
9
- const width = 100
7
+ describe('Basic Rendering', () => {
8
+ it('should render an img element with passed props', () => {
9
+ const src = 'test.jpg'
10
+ const alt = 'Test image'
11
+ const width = 100
12
+
13
+ render(<Img src={src} alt={alt} width={width} />)
14
+
15
+ const img = screen.getByRole('img')
16
+ expect(img).toBeInTheDocument()
17
+ expect(img).toHaveAttribute('src', src)
18
+ expect(img).toHaveAttribute('alt', alt)
19
+ expect(img).toHaveAttribute('width', width.toString())
20
+ })
21
+
22
+ it('should render with default props when none provided', () => {
23
+ render(<Img />)
24
+
25
+ const img = screen.getByRole('img')
26
+ expect(img).toBeInTheDocument()
27
+ expect(img).toHaveAttribute('src', '//')
28
+ expect(img).toHaveAttribute('width', '480')
29
+ expect(img).toHaveAttribute('loading', 'lazy')
30
+ })
31
+
32
+ it('should apply custom width and height', () => {
33
+ render(<Img src="test.jpg" alt="Test" width={200} height={150} />)
34
+
35
+ const img = screen.getByRole('img')
36
+ expect(img).toHaveAttribute('width', '200')
37
+ expect(img).toHaveAttribute('height', '150')
38
+ })
39
+ })
40
+
41
+ describe('Accessibility', () => {
42
+ it('should support decorative images with empty alt', () => {
43
+ render(<Img src="decorative.png" alt="" />)
44
+
45
+ const img = screen.getByRole('img', { hidden: true })
46
+ expect(img).toHaveAttribute('alt', '')
47
+ })
48
+
49
+ it('should support semantic images with descriptive alt text', () => {
50
+ const altText = 'Sales chart showing 30% growth in Q4'
51
+ render(<Img src="chart.png" alt={altText} />)
52
+
53
+ const img = screen.getByRole('img')
54
+ expect(img).toHaveAccessibleName(altText)
55
+ })
56
+
57
+ it('should allow missing alt attribute', () => {
58
+ render(<Img src="test.jpg" />)
59
+
60
+ const img = screen.getByRole('img')
61
+ expect(img).toBeInTheDocument()
62
+ })
63
+ })
64
+
65
+ describe('Responsive Images', () => {
66
+ it('should support srcSet attribute', () => {
67
+ const srcSet = 'image-320w.jpg 320w, image-640w.jpg 640w'
68
+ render(<Img src="image.jpg" srcSet={srcSet} alt="Responsive image" />)
69
+
70
+ const img = screen.getByRole('img')
71
+ expect(img).toHaveAttribute('srcset', srcSet)
72
+ })
73
+
74
+ it('should support sizes attribute', () => {
75
+ const sizes = '(max-width: 640px) 100vw, 640px'
76
+ render(
77
+ <Img
78
+ src="image.jpg"
79
+ srcSet="image-320w.jpg 320w, image-640w.jpg 640w"
80
+ sizes={sizes}
81
+ alt="Responsive image"
82
+ />,
83
+ )
84
+
85
+ const img = screen.getByRole('img')
86
+ expect(img).toHaveAttribute('sizes', sizes)
87
+ })
88
+
89
+ it('should work without srcSet and sizes', () => {
90
+ render(<Img src="image.jpg" alt="Regular image" />)
91
+
92
+ const img = screen.getByRole('img')
93
+ expect(img).not.toHaveAttribute('srcset')
94
+ expect(img).not.toHaveAttribute('sizes')
95
+ })
96
+ })
97
+
98
+ describe('Loading and Performance', () => {
99
+ it('should default to lazy loading', () => {
100
+ render(<Img src="test.jpg" alt="Test" />)
101
+
102
+ const img = screen.getByRole('img')
103
+ expect(img).toHaveAttribute('loading', 'lazy')
104
+ })
105
+
106
+ it('should support eager loading', () => {
107
+ render(<Img src="hero.jpg" alt="Hero" loading="eager" />)
108
+
109
+ const img = screen.getByRole('img')
110
+ expect(img).toHaveAttribute('loading', 'eager')
111
+ })
112
+
113
+ it('should support fetchpriority attribute', () => {
114
+ render(
115
+ <Img src="hero.jpg" alt="Hero" fetchpriority="high" loading="eager" />,
116
+ )
117
+
118
+ const img = screen.getByRole('img')
119
+ expect(img).toHaveAttribute('fetchpriority', 'high')
120
+ })
121
+
122
+ it('should support decoding attribute', () => {
123
+ render(<Img src="test.jpg" alt="Test" decoding="async" />)
124
+
125
+ const img = screen.getByRole('img')
126
+ expect(img).toHaveAttribute('decoding', 'async')
127
+ })
128
+ })
129
+
130
+ describe('Error Handling', () => {
131
+ it('should call onError callback and apply fallback placeholder', () => {
132
+ const onError = vi.fn()
133
+ render(<Img src="bad.jpg" alt="Test" onError={onError} />)
134
+
135
+ const img = screen.getByRole('img') as HTMLImageElement
136
+ fireEvent.error(img)
137
+
138
+ // Should call custom handler
139
+ expect(onError).toHaveBeenCalledTimes(1)
140
+ expect(onError).toHaveBeenCalledWith(expect.any(Object))
141
+
142
+ // Should still apply default fallback
143
+ expect(img.src).toContain('data:image/svg+xml')
144
+ })
145
+
146
+ it('should prevent default fallback when preventDefault is called', () => {
147
+ const customSrc = 'https://custom-fallback.jpg'
148
+ const onError = vi.fn((e) => {
149
+ e.preventDefault()
150
+ e.currentTarget.src = customSrc
151
+ })
152
+ render(<Img src="bad.jpg" alt="Test" onError={onError} />)
153
+
154
+ const img = screen.getByRole('img') as HTMLImageElement
155
+ fireEvent.error(img)
156
+
157
+ // Should call custom handler
158
+ expect(onError).toHaveBeenCalledTimes(1)
159
+
160
+ // Should use custom fallback, not default SVG
161
+ expect(img.src).toContain(customSrc)
162
+ expect(img.src).not.toContain('data:image/svg+xml')
163
+ })
164
+
165
+ it('should fallback to placeholder when error occurs and no onError handler', () => {
166
+ render(<Img src="bad.jpg" alt="Test" placeholder="/fallback.png" />)
167
+
168
+ const img = screen.getByRole('img') as HTMLImageElement
169
+ fireEvent.error(img)
10
170
 
11
- const { container } = render(<Img src={src} alt={alt} width={width} />)
171
+ expect(img.src).toContain('fallback.png')
172
+ })
12
173
 
13
- const img = screen.getByRole('img')
14
- expect(img).toBeInTheDocument()
15
- expect(img).toHaveAttribute('src', src)
16
- expect(img).toHaveAttribute('alt', alt)
17
- expect(img).toHaveAttribute('width', width.toString())
174
+ it('should use default SVG placeholder when none provided', () => {
175
+ render(<Img src="bad.jpg" alt="Test" width={300} />)
176
+
177
+ const img = screen.getByRole('img') as HTMLImageElement
178
+ fireEvent.error(img)
179
+
180
+ // Should use SVG data URI placeholder
181
+ expect(img.src).toContain('data:image/svg+xml')
182
+ expect(img.src).toContain('300') // Width in dimension text
183
+ })
184
+
185
+ it('should not enter infinite loop when placeholder also fails', () => {
186
+ render(<Img src="bad.jpg" alt="Test" placeholder="/also-bad.png" />)
187
+
188
+ const img = screen.getByRole('img') as HTMLImageElement
189
+
190
+ // First error: switches to placeholder
191
+ fireEvent.error(img)
192
+ const firstSrc = img.src
193
+
194
+ // Second error: should not change src again
195
+ fireEvent.error(img)
196
+ expect(img.src).toBe(firstSrc)
197
+ })
18
198
  })
19
199
 
20
- it('should apply default styles when renderStyles is true', () => {
21
- const { container } = render(<Img src="" alt="" />)
200
+ describe('Event Handlers', () => {
201
+ it('should call onLoad callback when image loads', () => {
202
+ const onLoad = vi.fn()
203
+ render(<Img src="good.jpg" alt="Test" onLoad={onLoad} />)
204
+
205
+ const img = screen.getByRole('img')
206
+ fireEvent.load(img)
207
+
208
+ expect(onLoad).toHaveBeenCalledTimes(1)
209
+ expect(onLoad).toHaveBeenCalledWith(expect.any(Object))
210
+ })
211
+
212
+ it('should work without onLoad callback', () => {
213
+ render(<Img src="test.jpg" alt="Test" />)
214
+
215
+ const img = screen.getByRole('img')
216
+ expect(() => fireEvent.load(img)).not.toThrow()
217
+ })
218
+
219
+ it('should work without onError callback', () => {
220
+ render(<Img src="test.jpg" alt="Test" />)
221
+
222
+ const img = screen.getByRole('img')
223
+ expect(() => fireEvent.error(img)).not.toThrow()
224
+ })
225
+ })
226
+
227
+ describe('Styling', () => {
228
+ it('should apply inline styles', () => {
229
+ const styles = { border: '1px solid red', borderRadius: '0.5rem' }
230
+ render(<Img src="test.jpg" alt="Test" styles={styles} />)
231
+
232
+ const img = screen.getByRole('img')
233
+ expect(img).toHaveStyle({ border: '1px solid red' })
234
+ expect(img).toHaveStyle({ borderRadius: '0.5rem' })
235
+ })
236
+
237
+ it('should forward additional props', () => {
238
+ render(
239
+ <Img
240
+ src="test.jpg"
241
+ alt="Test"
242
+ data-testid="custom-img"
243
+ className="custom-class"
244
+ />,
245
+ )
246
+
247
+ const img = screen.getByTestId('custom-img')
248
+ expect(img).toHaveClass('custom-class')
249
+ })
22
250
  })
23
251
 
24
- it('should call imgError callback on error', () => {
25
- const onError = jest.fn()
26
- render(<Img src="bad.jpg" alt="" imgError={onError} />)
252
+ describe('Edge Cases', () => {
253
+ it('should handle string width values', () => {
254
+ render(<Img src="test.jpg" alt="Test" width="100%" />)
255
+
256
+ const img = screen.getByRole('img')
257
+ expect(img).toHaveAttribute('width', '100%')
258
+ })
259
+
260
+ it('should default height to auto when not provided', () => {
261
+ render(<Img src="test.jpg" alt="Test" />)
27
262
 
28
- const img = screen.getByRole('img')
29
- fireEvent.error(img)
263
+ const img = screen.getByRole('img')
264
+ expect(img).toHaveAttribute('height', 'auto')
265
+ })
30
266
 
31
- expect(onError).toHaveBeenCalledTimes(1)
267
+ it('should accept custom height', () => {
268
+ render(<Img src="test.jpg" alt="Test" height={200} />)
269
+
270
+ const img = screen.getByRole('img')
271
+ expect(img).toHaveAttribute('height', '200')
272
+ })
32
273
  })
33
274
 
34
- it('should call imgLoaded callback on load', () => {
35
- const onLoad = jest.fn()
36
- render(<Img src="good.jpg" alt="" imgLoaded={onLoad} />)
275
+ describe('SVG Placeholder', () => {
276
+ it('should generate SVG placeholder with correct dimensions', () => {
277
+ render(<Img src="bad.jpg" alt="Test" width={800} height={600} />)
278
+
279
+ const img = screen.getByRole('img') as HTMLImageElement
280
+ fireEvent.error(img)
281
+
282
+ // Should be SVG data URI
283
+ expect(img.src).toContain('data:image/svg+xml')
284
+ // Should contain both dimensions in the text
285
+ expect(img.src).toContain('800')
286
+ expect(img.src).toContain('600')
287
+ })
288
+
289
+ it('should generate SVG with 4:3 aspect ratio when height not provided', () => {
290
+ render(<Img src="bad.jpg" alt="Test" width={400} />)
291
+
292
+ const img = screen.getByRole('img') as HTMLImageElement
293
+ fireEvent.error(img)
294
+
295
+ expect(img.src).toContain('data:image/svg+xml')
296
+ // 400 * 0.75 = 300
297
+ expect(img.src).toContain('400')
298
+ expect(img.src).toContain('300')
299
+ })
300
+
301
+ it('should generate SVG with gradient elements', () => {
302
+ render(<Img src="bad.jpg" alt="Test" width={500} />)
303
+
304
+ const img = screen.getByRole('img') as HTMLImageElement
305
+ fireEvent.error(img)
306
+
307
+ const decodedSvg = decodeURIComponent(img.src.replace('data:image/svg+xml,', ''))
308
+
309
+ // Should contain gradient definition
310
+ expect(decodedSvg).toContain('linearGradient')
311
+ expect(decodedSvg).toContain('#6366f1') // Indigo
312
+ expect(decodedSvg).toContain('#8b5cf6') // Purple
313
+ expect(decodedSvg).toContain('#ec4899') // Pink
314
+
315
+ // Should contain decorative elements
316
+ expect(decodedSvg).toContain('circle') // Sun
317
+ expect(decodedSvg).toContain('path') // Mountain wave
318
+ expect(decodedSvg).toContain('text') // Dimension text
319
+ })
320
+
321
+ it('should generate unique gradient IDs for different dimensions', () => {
322
+ const { rerender } = render(<Img src="bad1.jpg" alt="Test" width={400} height={300} />)
323
+ const img1 = screen.getByRole('img') as HTMLImageElement
324
+ fireEvent.error(img1)
325
+ const svg1 = decodeURIComponent(img1.src.replace('data:image/svg+xml,', ''))
326
+
327
+ rerender(<Img src="bad2.jpg" alt="Test" width={800} height={600} />)
328
+ const img2 = screen.getByRole('img') as HTMLImageElement
329
+ fireEvent.error(img2)
330
+ const svg2 = decodeURIComponent(img2.src.replace('data:image/svg+xml,', ''))
331
+
332
+ // Different gradient IDs to prevent conflicts
333
+ expect(svg1).toContain('grad-400-300')
334
+ expect(svg2).toContain('grad-800-600')
335
+ })
336
+
337
+ it('should handle string width by defaulting to 480', () => {
338
+ render(<Img src="bad.jpg" alt="Test" width="100%" />)
339
+
340
+ const img = screen.getByRole('img') as HTMLImageElement
341
+ fireEvent.error(img)
342
+
343
+ expect(img.src).toContain('data:image/svg+xml')
344
+ // Should default to 480 when width is string
345
+ expect(img.src).toContain('480')
346
+ })
347
+
348
+ it('should be smaller than external placeholder', () => {
349
+ render(<Img src="bad.jpg" alt="Test" width={800} />)
350
+
351
+ const img = screen.getByRole('img') as HTMLImageElement
352
+ fireEvent.error(img)
353
+
354
+ // SVG data URI should be relatively small (< 2KB)
355
+ expect(img.src.length).toBeLessThan(2000)
356
+ // Should start with data URI scheme
357
+ expect(img.src).toMatch(/^data:image\/svg\+xml,/)
358
+ })
359
+
360
+ it('should contain viewBox for responsiveness', () => {
361
+ render(<Img src="bad.jpg" alt="Test" width={600} height={400} />)
362
+
363
+ const img = screen.getByRole('img') as HTMLImageElement
364
+ fireEvent.error(img)
37
365
 
38
- const img = screen.getByRole('img')
39
- fireEvent.load(img)
366
+ const decodedSvg = decodeURIComponent(img.src.replace('data:image/svg+xml,', ''))
40
367
 
41
- expect(onLoad).toHaveBeenCalledTimes(1)
368
+ // Should have viewBox for perfect scaling
369
+ expect(decodedSvg).toContain('viewBox="0 0 600 400"')
370
+ })
42
371
  })
43
372
  })
@@ -1,75 +1,196 @@
1
- import UI from '../ui'
2
- import React from 'react'
3
- /*
4
- * ImageProps interface
5
- *
6
- * Extends ComponentProps and defines additional props for the Img component.
7
- *
8
- * @property {string} [src] - The image source URL
9
- * @property {string} alt - Required alt text for image accessibility
10
- * @property {number} width - Required width of image
11
- * @property {number} [height] - Optional height of image
12
- * @property {"eager" | "lazy"} [loading="lazy"] - Loading behavior
13
- * @property {string} [placeholder] - Fallback placeholder image
14
- * @property {"high" | "low"} [fetchpriority="low"] - Image fetch priority
15
- * @property {"sync" | "async" | "auto"} [decoding="auto"] - Decode setting
16
- * @property {function} [imgError] - Error callback
17
- * @property {function} [imgLoaded] - Loaded callback
18
- */
19
- export type ImageProps = React.ComponentProps<'img'> &
20
- React.ComponentProps<typeof UI>
1
+ import UI from "../ui";
2
+ import React, { useMemo } from "react";
3
+ import type { ImgProps } from "./img.types";
21
4
 
22
- /*
23
- * Img component
24
- *
25
- * Renders an <img> element with custom props.
26
- *
27
- * @param {string} src - The image source URL.
28
- * @param {string} alt - The alt text for the image.
29
- * @param {number} [width=480] - The width of the image.
30
- * @param {number} [height] - The height of the image.
31
- * @param {Object} [styles] - Additional CSS styles to apply.
32
- * @param {boolean} [renderStyles=true] - Whether to render the default styles.
33
- * @param {"eager" | "lazy"} [loading="lazy"] - The loading attribute.
34
- * @param {string} [placeholder] - A placeholder image URL.
35
- * @param {"high" | "low"} [fetchpriority="low"] - The fetchpriority attribute.
36
- * @param {"sync" | "async" | "auto"} [decoding="auto"] - The decoding attribute.
37
- * @param {function} [imgLoaded] - Callback when image loads successfully.
38
- * @param {function} [imgError] - Callback when image errors.
39
- *
40
- * @returns {JSX.Element} The Img component.
5
+ /**
6
+ * Img - A semantic image component with accessibility and performance best practices.
7
+ *
8
+ * This component wraps the native `<img>` element with enhanced features:
9
+ * - **Responsive images** via optional srcset/sizes
10
+ * - **Lazy loading** by default for performance
11
+ * - **Error handling** with configurable fallback placeholders
12
+ * - **Type safety** with full TypeScript support
13
+ *
14
+ * ## Accessibility Patterns (WCAG 2.1 AA)
15
+ *
16
+ * ### Decorative Images
17
+ * Images that are purely visual decoration should use an empty alt attribute.
18
+ * These images are typically borders, patterns, or visual separators.
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * // ✅ GOOD: Decorative border image
23
+ * <Img src="/decorative-border.png" alt="" />
24
+ *
25
+ * // ✅ GOOD: Background pattern
26
+ * <Img src="/pattern.svg" alt="" loading="eager" />
27
+ * ```
28
+ *
29
+ * ### Semantic Images
30
+ * Images that convey information must have descriptive alt text that explains
31
+ * the content and purpose of the image.
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * // ✅ GOOD: Informative image with descriptive alt
36
+ * <Img
37
+ * src="/sales-chart.png"
38
+ * alt="Sales chart showing 30% revenue growth in Q4 2024"
39
+ * />
40
+ *
41
+ * // ✅ GOOD: Product photo with context
42
+ * <Img
43
+ * src="/laptop.jpg"
44
+ * alt="Silver MacBook Pro 14-inch on wooden desk"
45
+ * />
46
+ * ```
47
+ *
48
+ * ## Performance Optimization
49
+ *
50
+ * ### Lazy Loading
51
+ * By default, images use lazy loading to improve page load performance.
52
+ * Only use `loading="eager"` for above-the-fold images.
53
+ *
54
+ * @example
55
+ * ```tsx
56
+ * // ✅ GOOD: Lazy load below-the-fold image
57
+ * <Img src="/photo.jpg" alt="Photo" />
58
+ *
59
+ * // ✅ GOOD: Eager load hero image
60
+ * <Img
61
+ * src="/hero.jpg"
62
+ * alt="Hero banner"
63
+ * loading="eager"
64
+ * fetchpriority="high"
65
+ * />
66
+ * ```
67
+ *
68
+ * ### Responsive Images
69
+ * Use srcset and sizes for responsive images to serve appropriate image sizes
70
+ * based on viewport width, improving performance and bandwidth usage.
71
+ *
72
+ * @example
73
+ * ```tsx
74
+ * // ✅ GOOD: Responsive image with multiple sizes
75
+ * <Img
76
+ * src="/photo.jpg"
77
+ * srcSet="/photo-320w.jpg 320w, /photo-640w.jpg 640w, /photo-1024w.jpg 1024w"
78
+ * sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 800px"
79
+ * alt="Responsive image adapts to viewport"
80
+ * />
81
+ * ```
82
+ *
83
+ * ## Error Handling
84
+ *
85
+ * @example
86
+ * ```tsx
87
+ * // ✅ GOOD: Custom placeholder on error
88
+ * <Img
89
+ * src="/photo.jpg"
90
+ * placeholder="/fallback.png"
91
+ * alt="User profile photo"
92
+ * />
93
+ *
94
+ * // ✅ GOOD: Custom error handler
95
+ * <Img
96
+ * src="/photo.jpg"
97
+ * onError={(e) => {
98
+ * console.error('Image failed to load')
99
+ * logToAnalytics('image_error', { src: e.currentTarget.src })
100
+ * }}
101
+ * alt="Photo"
102
+ * />
103
+ * ```
104
+ *
105
+ * @param {ImgProps} props - Component props extending native img attributes
106
+ * @returns {React.ReactElement} Image element with enhanced functionality
107
+ *
108
+ * @see {@link ImgProps} for complete prop documentation
109
+ * @see https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html
41
110
  */
42
111
  export const Img = ({
43
- src = '//',
112
+ src = "//",
44
113
  alt,
45
114
  width = 480,
46
115
  height,
47
116
  styles,
48
- loading = 'lazy',
49
- placeholder = `https://via.placeholder.com/${width}?text=PLACEHOLDER`,
50
- fetchpriority = 'low',
51
- decoding = 'auto',
52
- imgLoaded,
53
- imgError,
117
+ loading = "lazy",
118
+ placeholder,
119
+ fetchpriority = "low",
120
+ decoding = "auto",
121
+ srcSet,
122
+ sizes,
123
+ onError,
124
+ onLoad,
54
125
  ...props
55
- }: ImageProps) => {
126
+ }: ImgProps) => {
127
+ /**
128
+ * Generates a performant, responsive SVG gradient placeholder.
129
+ * Uses data URI to avoid network requests and memoizes based on dimensions.
130
+ * The SVG uses viewBox for perfect scaling at any size.
131
+ *
132
+ * Features:
133
+ * - Zero network requests (works offline)
134
+ * - ~900 bytes vs. 5-10KB external image
135
+ * - Responsive with viewBox
136
+ * - Attractive gradient (indigo → purple → pink)
137
+ * - Dimension text for debugging
138
+ */
139
+ const defaultPlaceholder = useMemo(() => {
140
+ const w = typeof width === "number" ? width : 480;
141
+ const h = typeof height === "number" ? height : Math.round(w * 0.75);
142
+
143
+ // Responsive SVG with attractive gradient and dimension text
144
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">
145
+ <defs>
146
+ <linearGradient id="grad-${w}-${h}" x1="0%" y1="0%" x2="100%" y2="100%">
147
+ <stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
148
+ <stop offset="50%" style="stop-color:#8b5cf6;stop-opacity:1" />
149
+ <stop offset="100%" style="stop-color:#ec4899;stop-opacity:1" />
150
+ </linearGradient>
151
+ </defs>
152
+ <rect width="${w}" height="${h}" fill="url(#grad-${w}-${h})"/>
153
+ <circle cx="${w * 0.15}" cy="${h * 0.2}" r="${Math.min(w, h) * 0.08}" fill="rgba(255,255,255,0.2)"/>
154
+ <path d="M0,${h * 0.75} Q${w * 0.25},${h * 0.65} ${w * 0.5},${h * 0.75} T${w},${h * 0.75} L${w},${h} L0,${h} Z" fill="rgba(0,0,0,0.15)"/>
155
+ <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-size="${Math.max(16, Math.min(w, h) * 0.05)}" font-weight="500" fill="rgba(255,255,255,0.9)">${w}×${h}</text>
156
+ </svg>`;
157
+
158
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
159
+ }, [width, height]);
160
+
161
+ const fallbackPlaceholder = placeholder ?? defaultPlaceholder;
162
+
163
+ /**
164
+ * Handles image load errors.
165
+ * Calls custom error handler if provided, then applies fallback placeholder.
166
+ * The custom handler can prevent the default fallback by calling e.preventDefault().
167
+ */
56
168
  const handleImgError = (
57
- e: React.SyntheticEvent<HTMLImageElement, Event>,
169
+ e: React.SyntheticEvent<HTMLImageElement, Event>
58
170
  ): void => {
59
- if (imgError) {
60
- imgError?.(e)
61
- return
171
+ // Call custom error handler first (for logging, analytics, etc.)
172
+ if (onError) {
173
+ onError(e);
62
174
  }
63
- if (e.currentTarget.src !== placeholder) {
64
- e.currentTarget.src = placeholder
175
+
176
+ // Apply fallback unless preventDefault() was called
177
+ if (!e.defaultPrevented) {
178
+ // Avoid infinite error loop by checking if already showing placeholder
179
+ if (e.currentTarget.src !== fallbackPlaceholder) {
180
+ e.currentTarget.src = fallbackPlaceholder;
181
+ }
65
182
  }
66
- }
183
+ };
67
184
 
185
+ /**
186
+ * Handles successful image load.
187
+ * Calls custom load handler if provided.
188
+ */
68
189
  const handleImgLoad = (
69
- e: React.SyntheticEvent<HTMLImageElement, Event>,
190
+ e: React.SyntheticEvent<HTMLImageElement, Event>
70
191
  ): void => {
71
- imgLoaded?.(e)
72
- }
192
+ onLoad?.(e);
193
+ };
73
194
 
74
195
  return (
75
196
  <UI
@@ -77,17 +198,19 @@ export const Img = ({
77
198
  src={src}
78
199
  alt={alt}
79
200
  width={width}
80
- height={height || 'auto'}
201
+ height={height || "auto"}
81
202
  loading={loading}
82
203
  style={styles}
204
+ srcSet={srcSet}
205
+ sizes={sizes}
83
206
  onError={handleImgError}
84
207
  onLoad={handleImgLoad}
85
- fetchPriority={fetchpriority}
86
208
  decoding={decoding}
87
209
  {...props}
210
+ {...(fetchpriority && { fetchpriority })}
88
211
  />
89
- )
90
- }
212
+ );
213
+ };
91
214
 
92
- export default Img
93
- Img.displayName = 'Img'
215
+ export default Img;
216
+ Img.displayName = "Img";