@eifi1/ui-kit 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (342) hide show
  1. package/README.md +57 -32
  2. package/dist/chart.d.ts +3 -1
  3. package/dist/chart.js +2 -0
  4. package/dist/chart.js.map +1 -1
  5. package/dist/components/account-settings-labels.d.ts +105 -0
  6. package/dist/components/account-settings-labels.js +81 -0
  7. package/dist/components/account-settings-labels.js.map +1 -0
  8. package/dist/components/account-settings.d.ts +36 -52
  9. package/dist/components/account-settings.js +44 -8
  10. package/dist/components/account-settings.js.map +1 -1
  11. package/dist/components/amount-input.d.ts +24 -4
  12. package/dist/components/amount-input.js +5 -2
  13. package/dist/components/amount-input.js.map +1 -1
  14. package/dist/components/authed-image.d.ts +68 -0
  15. package/dist/components/authed-image.js +117 -0
  16. package/dist/components/authed-image.js.map +1 -0
  17. package/dist/components/breadcrumbs.d.ts +2 -1
  18. package/dist/components/breadcrumbs.js +5 -2
  19. package/dist/components/breadcrumbs.js.map +1 -1
  20. package/dist/components/button-group.d.ts +51 -2
  21. package/dist/components/button-group.js +5 -1
  22. package/dist/components/button-group.js.map +1 -1
  23. package/dist/components/calculator.d.ts +15 -4
  24. package/dist/components/calendar-heatmap.d.ts +41 -3
  25. package/dist/components/calendar-heatmap.js +48 -7
  26. package/dist/components/calendar-heatmap.js.map +1 -1
  27. package/dist/components/chip.d.ts +2 -1
  28. package/dist/components/chip.js +6 -3
  29. package/dist/components/chip.js.map +1 -1
  30. package/dist/components/choice-card.d.ts +35 -3
  31. package/dist/components/choice-card.js +83 -25
  32. package/dist/components/choice-card.js.map +1 -1
  33. package/dist/components/combobox-core.js +2 -0
  34. package/dist/components/combobox-core.js.map +1 -1
  35. package/dist/components/combobox.d.ts +2 -2
  36. package/dist/components/combobox.js +31 -9
  37. package/dist/components/combobox.js.map +1 -1
  38. package/dist/components/copy-button.d.ts +51 -70
  39. package/dist/components/copy-button.js +1 -1
  40. package/dist/components/copy-button.js.map +1 -1
  41. package/dist/components/data-table-cells.d.ts +63 -0
  42. package/dist/components/data-table-cells.js +64 -0
  43. package/dist/components/data-table-cells.js.map +1 -0
  44. package/dist/components/data-table-filter-popover.d.ts +1 -1
  45. package/dist/components/data-table-filters.d.ts +1 -1
  46. package/dist/components/data-table-filters.js +31 -1
  47. package/dist/components/data-table-filters.js.map +1 -1
  48. package/dist/components/data-table-labels.d.ts +13 -0
  49. package/dist/components/data-table-labels.js +8 -1
  50. package/dist/components/data-table-labels.js.map +1 -1
  51. package/dist/components/data-table.d.ts +1 -1
  52. package/dist/components/data-table.js +102 -12
  53. package/dist/components/data-table.js.map +1 -1
  54. package/dist/components/date-picker.d.ts +3 -1
  55. package/dist/components/date-picker.js +15 -2
  56. package/dist/components/date-picker.js.map +1 -1
  57. package/dist/components/description-list.d.ts +23 -3
  58. package/dist/components/description-list.js +23 -3
  59. package/dist/components/description-list.js.map +1 -1
  60. package/dist/components/dialog-frame.d.ts +1 -0
  61. package/dist/components/entity-combobox.d.ts +1 -1
  62. package/dist/components/entity-combobox.js +18 -3
  63. package/dist/components/entity-combobox.js.map +1 -1
  64. package/dist/components/error-boundary.d.ts +92 -0
  65. package/dist/components/error-boundary.js +111 -0
  66. package/dist/components/error-boundary.js.map +1 -0
  67. package/dist/components/field.d.ts +30 -4
  68. package/dist/components/field.js +40 -17
  69. package/dist/components/field.js.map +1 -1
  70. package/dist/components/file-button.d.ts +50 -208
  71. package/dist/components/file-dropzone.d.ts +49 -2
  72. package/dist/components/floating-panel.d.ts +13 -4
  73. package/dist/components/floating-panel.js +7 -3
  74. package/dist/components/floating-panel.js.map +1 -1
  75. package/dist/components/form-actions.d.ts +53 -0
  76. package/dist/components/form-actions.js +98 -0
  77. package/dist/components/form-actions.js.map +1 -0
  78. package/dist/components/image-grid.d.ts +67 -0
  79. package/dist/components/image-grid.js +112 -0
  80. package/dist/components/image-grid.js.map +1 -0
  81. package/dist/components/lightbox.d.ts +104 -0
  82. package/dist/components/lightbox.js +229 -0
  83. package/dist/components/lightbox.js.map +1 -0
  84. package/dist/components/line-items.d.ts +114 -0
  85. package/dist/components/line-items.js +238 -0
  86. package/dist/components/line-items.js.map +1 -0
  87. package/dist/components/list.d.ts +23 -2
  88. package/dist/components/list.js +32 -13
  89. package/dist/components/list.js.map +1 -1
  90. package/dist/components/loading-state.d.ts +24 -0
  91. package/dist/components/loading-state.js +32 -0
  92. package/dist/components/loading-state.js.map +1 -0
  93. package/dist/components/menu-item.d.ts +2 -1
  94. package/dist/components/menu-item.js +5 -1
  95. package/dist/components/menu-item.js.map +1 -1
  96. package/dist/components/mini-calendar.d.ts +13 -1
  97. package/dist/components/mini-calendar.js +46 -2
  98. package/dist/components/mini-calendar.js.map +1 -1
  99. package/dist/components/modal.d.ts +20 -1
  100. package/dist/components/modal.js +26 -2
  101. package/dist/components/modal.js.map +1 -1
  102. package/dist/components/month-picker.d.ts +21 -1
  103. package/dist/components/month-picker.js +139 -26
  104. package/dist/components/month-picker.js.map +1 -1
  105. package/dist/components/multi-entity-combobox.d.ts +1 -1
  106. package/dist/components/multi-entity-combobox.js +19 -3
  107. package/dist/components/multi-entity-combobox.js.map +1 -1
  108. package/dist/components/nav-pills.d.ts +2 -1
  109. package/dist/components/nav-pills.js +5 -1
  110. package/dist/components/nav-pills.js.map +1 -1
  111. package/dist/components/number-field.d.ts +24 -5
  112. package/dist/components/number-field.js +5 -7
  113. package/dist/components/number-field.js.map +1 -1
  114. package/dist/components/number-input.d.ts +25 -5
  115. package/dist/components/number-input.js +7 -1
  116. package/dist/components/number-input.js.map +1 -1
  117. package/dist/components/numpad-sheet.d.ts +15 -4
  118. package/dist/components/passkeys-setting.d.ts +67 -0
  119. package/dist/components/passkeys-setting.js +214 -0
  120. package/dist/components/passkeys-setting.js.map +1 -0
  121. package/dist/components/picker-sheet.js +2 -1
  122. package/dist/components/picker-sheet.js.map +1 -1
  123. package/dist/components/pie-chart-labels.d.ts +20 -0
  124. package/dist/components/pie-chart-labels.js +12 -0
  125. package/dist/components/pie-chart-labels.js.map +1 -0
  126. package/dist/components/pie-chart.d.ts +101 -0
  127. package/dist/components/pie-chart.js +283 -0
  128. package/dist/components/pie-chart.js.map +1 -0
  129. package/dist/components/progress-bar.d.ts +44 -3
  130. package/dist/components/progress-bar.js +58 -22
  131. package/dist/components/progress-bar.js.map +1 -1
  132. package/dist/components/qr-code.d.ts +34 -0
  133. package/dist/components/qr-code.js +57 -0
  134. package/dist/components/qr-code.js.map +1 -0
  135. package/dist/components/search-field.d.ts +15 -3
  136. package/dist/components/search-field.js +5 -1
  137. package/dist/components/search-field.js.map +1 -1
  138. package/dist/components/settings-fields.d.ts +49 -1
  139. package/dist/components/signed-amount.d.ts +121 -0
  140. package/dist/components/signed-amount.js +152 -0
  141. package/dist/components/signed-amount.js.map +1 -0
  142. package/dist/components/skeleton.d.ts +10 -3
  143. package/dist/components/skeleton.js +12 -2
  144. package/dist/components/skeleton.js.map +1 -1
  145. package/dist/components/stat-tile.d.ts +2 -1
  146. package/dist/components/stat-tile.js +5 -2
  147. package/dist/components/stat-tile.js.map +1 -1
  148. package/dist/components/table.d.ts +39 -5
  149. package/dist/components/table.js +45 -11
  150. package/dist/components/table.js.map +1 -1
  151. package/dist/components/text-link.d.ts +176 -0
  152. package/dist/components/text-link.js +109 -0
  153. package/dist/components/text-link.js.map +1 -0
  154. package/dist/components/time-input.d.ts +49 -1
  155. package/dist/components/toggle-group.d.ts +13 -0
  156. package/dist/components/toggle-group.js +35 -6
  157. package/dist/components/toggle-group.js.map +1 -1
  158. package/dist/components/toggle-legend.d.ts +36 -3
  159. package/dist/components/toggle-legend.js +49 -0
  160. package/dist/components/toggle-legend.js.map +1 -1
  161. package/dist/components/ui.d.ts +51 -779
  162. package/dist/components/ui.js +215 -23
  163. package/dist/components/ui.js.map +1 -1
  164. package/dist/components/use-table-state.d.ts +3 -52
  165. package/dist/components/use-table-state.js +128 -30
  166. package/dist/components/use-table-state.js.map +1 -1
  167. package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
  168. package/dist/data-table.d.ts +2 -1
  169. package/dist/data-table.js +5 -1
  170. package/dist/data-table.js.map +1 -1
  171. package/dist/feedback/feedback-dialog.d.ts +26 -3
  172. package/dist/feedback/feedback-dialog.js +16 -2
  173. package/dist/feedback/feedback-dialog.js.map +1 -1
  174. package/dist/feedback/feedback-thread.d.ts +133 -0
  175. package/dist/feedback/feedback-thread.js +237 -0
  176. package/dist/feedback/feedback-thread.js.map +1 -0
  177. package/dist/feedback.d.ts +2 -1
  178. package/dist/feedback.js +1 -0
  179. package/dist/feedback.js.map +1 -1
  180. package/dist/hooks/use-authed-src.d.ts +71 -0
  181. package/dist/hooks/use-authed-src.js +63 -0
  182. package/dist/hooks/use-authed-src.js.map +1 -0
  183. package/dist/hooks/use-file-drop.d.ts +49 -2
  184. package/dist/hooks/use-hotkey.d.ts +59 -0
  185. package/dist/hooks/use-hotkey.js +105 -0
  186. package/dist/hooks/use-hotkey.js.map +1 -0
  187. package/dist/hooks/use-search-param-state.d.ts +83 -0
  188. package/dist/hooks/use-search-param-state.js +94 -0
  189. package/dist/hooks/use-search-param-state.js.map +1 -0
  190. package/dist/i18n/defaults.d.ts +15 -4
  191. package/dist/i18n/defaults.js +31 -1
  192. package/dist/i18n/defaults.js.map +1 -1
  193. package/dist/i18n/kit-labels.d.ts +46 -312
  194. package/dist/i18n/kit-labels.js +20 -5
  195. package/dist/i18n/kit-labels.js.map +1 -1
  196. package/dist/i18n/locales/de-CH-informal.d.ts +15 -4
  197. package/dist/i18n/locales/de-CH.d.ts +15 -4
  198. package/dist/i18n/locales/de-informal.d.ts +15 -4
  199. package/dist/i18n/locales/de.d.ts +15 -4
  200. package/dist/i18n/locales/de.js +156 -3
  201. package/dist/i18n/locales/de.js.map +1 -1
  202. package/dist/i18n/locales/es.d.ts +15 -4
  203. package/dist/i18n/locales/es.js +153 -3
  204. package/dist/i18n/locales/es.js.map +1 -1
  205. package/dist/i18n/locales/fr.d.ts +15 -4
  206. package/dist/i18n/locales/fr.js +153 -3
  207. package/dist/i18n/locales/fr.js.map +1 -1
  208. package/dist/i18n/locales/hu.d.ts +15 -4
  209. package/dist/i18n/locales/hu.js +153 -3
  210. package/dist/i18n/locales/hu.js.map +1 -1
  211. package/dist/i18n/locales/it.d.ts +15 -4
  212. package/dist/i18n/locales/it.js +153 -3
  213. package/dist/i18n/locales/it.js.map +1 -1
  214. package/dist/i18n/locales/zh.d.ts +15 -4
  215. package/dist/i18n/locales/zh.js +153 -3
  216. package/dist/i18n/locales/zh.js.map +1 -1
  217. package/dist/index.d.ts +37 -17
  218. package/dist/index.js +40 -1
  219. package/dist/index.js.map +1 -1
  220. package/dist/kit-labels-v3biUF1L.d.ts +1645 -0
  221. package/dist/lib/format.d.ts +144 -0
  222. package/dist/lib/format.js +149 -0
  223. package/dist/lib/format.js.map +1 -0
  224. package/dist/lib/qr-encode.d.ts +44 -0
  225. package/dist/lib/qr-encode.js +338 -0
  226. package/dist/lib/qr-encode.js.map +1 -0
  227. package/dist/rhf/fields.d.ts +241 -0
  228. package/dist/rhf/fields.js +601 -0
  229. package/dist/rhf/fields.js.map +1 -0
  230. package/dist/rhf/form.d.ts +49 -1
  231. package/dist/rhf/line-items.d.ts +31 -0
  232. package/dist/rhf/line-items.js +59 -0
  233. package/dist/rhf/line-items.js.map +1 -0
  234. package/dist/rhf.d.ts +61 -1
  235. package/dist/rhf.js +2 -0
  236. package/dist/rhf.js.map +1 -1
  237. package/dist/shell/app-shell.d.ts +99 -2
  238. package/dist/shell/app-shell.js +58 -3
  239. package/dist/shell/app-shell.js.map +1 -1
  240. package/dist/shell/auth-layout.d.ts +74 -0
  241. package/dist/shell/auth-layout.js +97 -0
  242. package/dist/shell/auth-layout.js.map +1 -0
  243. package/dist/shell/top-bar-brand.d.ts +81 -0
  244. package/dist/shell/top-bar-brand.js +47 -0
  245. package/dist/shell/top-bar-brand.js.map +1 -0
  246. package/dist/shell/topbar-action-menu.d.ts +12 -1
  247. package/dist/shell/topbar-action-menu.js +16 -1
  248. package/dist/shell/topbar-action-menu.js.map +1 -1
  249. package/dist/shell.d.ts +56 -1
  250. package/dist/shell.js +2 -0
  251. package/dist/shell.js.map +1 -1
  252. package/dist/wizard/stepper-nav.d.ts +48 -1
  253. package/dist/wizard/wizard-context.d.ts +24 -1
  254. package/dist/wizard/wizard-context.js +19 -2
  255. package/dist/wizard/wizard-context.js.map +1 -1
  256. package/dist/wizard/wizard-step.d.ts +20 -5
  257. package/dist/wizard/wizard-step.js +16 -3
  258. package/dist/wizard/wizard-step.js.map +1 -1
  259. package/dist/wizard.d.ts +50 -3
  260. package/package.json +1 -1
  261. package/src/chart.ts +4 -0
  262. package/src/components/account-settings-labels.ts +190 -0
  263. package/src/components/account-settings.tsx +110 -63
  264. package/src/components/amount-input.tsx +13 -1
  265. package/src/components/authed-image.tsx +190 -0
  266. package/src/components/breadcrumbs.tsx +8 -2
  267. package/src/components/button-group.tsx +7 -2
  268. package/src/components/calendar-heatmap.tsx +105 -10
  269. package/src/components/chip.tsx +8 -4
  270. package/src/components/choice-card.tsx +136 -25
  271. package/src/components/combobox-core.tsx +5 -0
  272. package/src/components/combobox.tsx +31 -9
  273. package/src/components/copy-button.tsx +3 -2
  274. package/src/components/data-table-cells.tsx +119 -0
  275. package/src/components/data-table-filters.ts +77 -0
  276. package/src/components/data-table-labels.ts +23 -0
  277. package/src/components/data-table.tsx +224 -13
  278. package/src/components/date-picker.tsx +19 -3
  279. package/src/components/description-list.tsx +52 -3
  280. package/src/components/entity-combobox.tsx +18 -3
  281. package/src/components/error-boundary.tsx +216 -0
  282. package/src/components/field.tsx +56 -5
  283. package/src/components/floating-panel.tsx +20 -7
  284. package/src/components/form-actions.tsx +191 -0
  285. package/src/components/image-grid.tsx +181 -0
  286. package/src/components/lightbox.tsx +374 -0
  287. package/src/components/line-items.tsx +385 -0
  288. package/src/components/list.tsx +65 -16
  289. package/src/components/loading-state.tsx +47 -0
  290. package/src/components/menu-item.tsx +7 -2
  291. package/src/components/mini-calendar.tsx +72 -3
  292. package/src/components/modal.tsx +61 -3
  293. package/src/components/month-picker.tsx +181 -26
  294. package/src/components/multi-entity-combobox.tsx +19 -3
  295. package/src/components/nav-pills.tsx +8 -1
  296. package/src/components/number-field.tsx +15 -10
  297. package/src/components/number-input.tsx +16 -1
  298. package/src/components/passkeys-setting.tsx +331 -0
  299. package/src/components/picker-sheet.tsx +7 -1
  300. package/src/components/pie-chart-labels.ts +32 -0
  301. package/src/components/pie-chart.tsx +479 -0
  302. package/src/components/progress-bar.tsx +129 -19
  303. package/src/components/qr-code.tsx +83 -0
  304. package/src/components/search-field.tsx +20 -4
  305. package/src/components/signed-amount.tsx +286 -0
  306. package/src/components/skeleton.tsx +21 -3
  307. package/src/components/stat-tile.tsx +8 -2
  308. package/src/components/table.tsx +102 -13
  309. package/src/components/text-link.tsx +248 -0
  310. package/src/components/toggle-group.tsx +58 -10
  311. package/src/components/toggle-legend.tsx +87 -2
  312. package/src/components/ui.tsx +426 -36
  313. package/src/components/use-table-state.ts +265 -33
  314. package/src/data-table.ts +10 -0
  315. package/src/feedback/feedback-dialog.tsx +42 -3
  316. package/src/feedback/feedback-thread.tsx +434 -0
  317. package/src/feedback.ts +1 -0
  318. package/src/hooks/use-authed-src.ts +161 -0
  319. package/src/hooks/use-hotkey.ts +172 -0
  320. package/src/hooks/use-search-param-state.ts +187 -0
  321. package/src/i18n/defaults.ts +30 -0
  322. package/src/i18n/kit-labels.tsx +102 -8
  323. package/src/i18n/locales/de.ts +153 -0
  324. package/src/i18n/locales/es.ts +150 -0
  325. package/src/i18n/locales/fr.ts +153 -0
  326. package/src/i18n/locales/hu.ts +151 -0
  327. package/src/i18n/locales/it.ts +151 -0
  328. package/src/i18n/locales/zh.ts +150 -0
  329. package/src/index.ts +59 -0
  330. package/src/lib/format.ts +325 -0
  331. package/src/lib/qr-encode.ts +420 -0
  332. package/src/rhf/fields.tsx +1054 -0
  333. package/src/rhf/line-items.tsx +130 -0
  334. package/src/rhf.ts +6 -0
  335. package/src/shell/app-shell.tsx +147 -2
  336. package/src/shell/auth-layout.tsx +190 -0
  337. package/src/shell/top-bar-brand.tsx +76 -0
  338. package/src/shell/topbar-action-menu.tsx +21 -0
  339. package/src/shell.ts +2 -0
  340. package/src/wizard/wizard-context.tsx +41 -1
  341. package/src/wizard/wizard-step.tsx +43 -6
  342. package/tokens.css +12 -0
@@ -0,0 +1,190 @@
1
+ import { useMemo } from "react";
2
+ import { useKitLabelOverrides } from "../i18n/kit-labels";
3
+
4
+ /**
5
+ * The `accountSettings` namespace of `<UiKitProvider labels>`: one record per section —
6
+ * `profile`, `password`, `twoFactor`, `passkeys` — each merged key by key, so an app
7
+ * can translate `accountSettings.twoFactor.scanHint` alone.
8
+ *
9
+ * Before 0.12.0 every section took a REQUIRED `labels` prop and had no English of its
10
+ * own, so each app spelled out 30-odd keys at the call site and still hard-coded some
11
+ * (kastlan's profile page passed `email: "Email"` in English to a German page). The
12
+ * prop stays, now partial, and wins over the provider as everywhere else in the kit.
13
+ *
14
+ * Its own module, beside the components rather than in them, so `PasskeysSetting`
15
+ * (its own file) and the older three read one tree without importing each other.
16
+ */
17
+
18
+ export interface ProfileSettingLabels {
19
+ title: string;
20
+ email: string;
21
+ role: string;
22
+ memberSince: string;
23
+ displayName: string;
24
+ save: string;
25
+ }
26
+
27
+ export interface PasswordSettingLabels {
28
+ title: string;
29
+ current: string;
30
+ next: string;
31
+ confirm: string;
32
+ submit: string;
33
+ tooShort: string;
34
+ mismatch: string;
35
+ }
36
+
37
+ export interface TwoFactorSettingLabels {
38
+ status: string;
39
+ enabledText: string;
40
+ disabledText: string;
41
+ enable: string;
42
+ scanHint: string;
43
+ codeLabel: string;
44
+ verify: string;
45
+ disableSection: string;
46
+ password: string;
47
+ disable: string;
48
+ /**
49
+ * Alt text for the setup QR code, which shipped as a hardcoded `alt="QR"`.
50
+ *
51
+ * OPTIONAL, like the two keys below, and not because it matters less: this interface
52
+ * is annotated at consumer call sites (`const LABELS: TwoFactorSettingLabels = {…}`),
53
+ * so a new REQUIRED key is a compile error in every app on the next `npm update` —
54
+ * the additive-API rule in the README. Each falls back to the provider's
55
+ * `accountSettings.twoFactor`, then English.
56
+ */
57
+ qrAlt?: string;
58
+ /** The line over the manual-entry key, for a phone that cannot scan its own screen. */
59
+ secretHint?: string;
60
+ /** The copy button beside that key. */
61
+ copySecret?: string;
62
+ }
63
+
64
+ export interface PasskeysSettingLabels {
65
+ title: string;
66
+ /** The line under the title saying what a passkey is for. */
67
+ description: string;
68
+ empty: string;
69
+ loading: string;
70
+ /** Accessible name of the list. */
71
+ list: string;
72
+ nameLabel: string;
73
+ namePlaceholder: string;
74
+ add: string;
75
+ /** The add button while the app's WebAuthn ceremony runs — the OS prompt is open. */
76
+ adding: string;
77
+ rename: string;
78
+ /** The row's rename button, named for its passkey — it starts with `rename`, the
79
+ * visible text, so voice control finds it by what it says. */
80
+ renameItem: (name: string) => string;
81
+ /** Accessible name of the inline rename field. */
82
+ renameField: (name: string) => string;
83
+ save: string;
84
+ cancel: string;
85
+ delete: string;
86
+ /** The row's delete button, named for its passkey (see `renameItem`). */
87
+ deleteItem: (name: string) => string;
88
+ /** The inline question a delete asks first. */
89
+ deleteConfirm: (name: string) => string;
90
+ /** The destructive button that answers it. */
91
+ confirmDelete: string;
92
+ /** `date` arrives formatted in the provider's locale. */
93
+ created: (date: string) => string;
94
+ lastUsed: (date: string) => string;
95
+ neverUsed: string;
96
+ }
97
+
98
+ export interface AccountSettingsLabels {
99
+ profile: ProfileSettingLabels;
100
+ password: PasswordSettingLabels;
101
+ twoFactor: Required<TwoFactorSettingLabels>;
102
+ passkeys: PasskeysSettingLabels;
103
+ }
104
+
105
+ export const DEFAULT_ACCOUNT_SETTINGS_LABELS: AccountSettingsLabels = {
106
+ profile: {
107
+ title: "Profile",
108
+ email: "Email",
109
+ role: "Role",
110
+ memberSince: "Member since",
111
+ displayName: "Display name",
112
+ save: "Save",
113
+ },
114
+ password: {
115
+ title: "Change password",
116
+ current: "Current password",
117
+ next: "New password",
118
+ confirm: "Confirm new password",
119
+ submit: "Change password",
120
+ tooShort: "The new password is too short.",
121
+ mismatch: "The passwords don’t match.",
122
+ },
123
+ twoFactor: {
124
+ status: "Two-factor authentication",
125
+ enabledText: "On",
126
+ disabledText: "Off",
127
+ enable: "Set up two-factor authentication",
128
+ scanHint: "Scan this code with your authenticator app, then enter the code it shows.",
129
+ codeLabel: "Verification code",
130
+ verify: "Verify and turn on",
131
+ disableSection: "Turn off two-factor authentication",
132
+ password: "Current password",
133
+ disable: "Turn off",
134
+ // Unchanged from the fallback it had before the namespace (i18n-labels.test pins it).
135
+ qrAlt: "QR code",
136
+ secretHint: "Can’t scan it? Enter this key in the app instead:",
137
+ copySecret: "Copy key",
138
+ },
139
+ passkeys: {
140
+ title: "Passkeys",
141
+ description: "Sign in with your fingerprint, face or device PIN instead of a password.",
142
+ empty: "No passkeys yet",
143
+ loading: "Loading passkeys…",
144
+ list: "Your passkeys",
145
+ nameLabel: "Name (optional)",
146
+ namePlaceholder: "e.g. Laptop, Phone",
147
+ add: "Add passkey",
148
+ adding: "Waiting for your device…",
149
+ rename: "Rename",
150
+ renameItem: (name) => `Rename ${name}`,
151
+ renameField: (name) => `New name for ${name}`,
152
+ save: "Save",
153
+ cancel: "Cancel",
154
+ delete: "Delete",
155
+ deleteItem: (name) => `Delete ${name}`,
156
+ deleteConfirm: (name) => `Delete “${name}”? It can no longer be used to sign in.`,
157
+ confirmDelete: "Delete passkey",
158
+ created: (date) => `Added ${date}`,
159
+ lastUsed: (date) => `Last used ${date}`,
160
+ neverUsed: "Never used",
161
+ },
162
+ };
163
+
164
+ /**
165
+ * One section of the namespace, resolved: English, then the provider's
166
+ * `accountSettings.<section>`, then the component's own `labels` prop. An `undefined`
167
+ * in either source is skipped, so a prop object built from optional values cannot
168
+ * blank out a default.
169
+ */
170
+ export function useAccountSettingsLabels<K extends keyof AccountSettingsLabels>(
171
+ section: K,
172
+ prop?: Partial<AccountSettingsLabels[K]>,
173
+ ): AccountSettingsLabels[K] {
174
+ const fromProvider = useKitLabelOverrides("accountSettings")?.[section] as
175
+ | Partial<AccountSettingsLabels[K]>
176
+ | undefined;
177
+ return useMemo(
178
+ () => ({
179
+ ...DEFAULT_ACCOUNT_SETTINGS_LABELS[section],
180
+ ...definedOnly(fromProvider),
181
+ ...definedOnly(prop),
182
+ }),
183
+ [section, fromProvider, prop],
184
+ );
185
+ }
186
+
187
+ function definedOnly<T extends object>(value: T | undefined): Partial<T> {
188
+ if (!value) return {};
189
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial<T>;
190
+ }
@@ -2,24 +2,37 @@ import { useId, useState } from "react";
2
2
  import type { KeyboardEvent, ReactNode } from "react";
3
3
  import { Button, Card, FIELD_WRITABLE_LOOK, Input } from "./ui";
4
4
  import { UserAvatar } from "./user-avatar";
5
+ import { CopyButton } from "./copy-button";
6
+ import { QrCode } from "./qr-code";
5
7
  import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
8
+ import { useAccountSettingsLabels } from "./account-settings-labels";
9
+ import type {
10
+ PasswordSettingLabels,
11
+ ProfileSettingLabels,
12
+ TwoFactorSettingLabels,
13
+ } from "./account-settings-labels";
14
+
15
+ // The label types moved beside the namespace's defaults (0.12.0); re-exported here so
16
+ // every existing `import type { ProfileSettingLabels }` keeps resolving.
17
+ export type {
18
+ AccountSettingsLabels,
19
+ PasskeysSettingLabels,
20
+ PasswordSettingLabels,
21
+ ProfileSettingLabels,
22
+ TwoFactorSettingLabels,
23
+ } from "./account-settings-labels";
24
+ export { DEFAULT_ACCOUNT_SETTINGS_LABELS } from "./account-settings-labels";
6
25
 
7
26
  /**
8
27
  * App-agnostic account-settings SECTIONS (feedback #333). The presentation lives
9
28
  * in @hb/ui; each app injects its own auth-API handlers + translated labels and
10
29
  * arranges these next to its own app-specific cards (appearance, budgets, …).
11
30
  * Every section is a self-contained <Card> owning its transient form state.
31
+ *
32
+ * Every `labels` prop is optional since 0.12.0: the strings come from the provider's
33
+ * `accountSettings` namespace, then English (see account-settings-labels.ts).
12
34
  */
13
35
 
14
- export interface ProfileSettingLabels {
15
- title: string;
16
- email: string;
17
- role: string;
18
- memberSince: string;
19
- displayName: string;
20
- save: string;
21
- }
22
-
23
36
  export function ProfileSetting({
24
37
  name,
25
38
  email,
@@ -29,7 +42,7 @@ export function ProfileSetting({
29
42
  onChange,
30
43
  onSave,
31
44
  saving,
32
- labels,
45
+ labels: labelsProp,
33
46
  }: {
34
47
  name?: string | null;
35
48
  email?: string | null;
@@ -39,8 +52,10 @@ export function ProfileSetting({
39
52
  onChange: (value: string) => void;
40
53
  onSave: () => void;
41
54
  saving?: boolean;
42
- labels: ProfileSettingLabels;
55
+ /** Prop > `<UiKitProvider labels={{ accountSettings: { profile } }}>` > English. */
56
+ labels?: Partial<ProfileSettingLabels>;
43
57
  }) {
58
+ const labels = useAccountSettingsLabels("profile", labelsProp);
44
59
  const dirty = value.trim().length > 0 && value.trim() !== (name ?? "");
45
60
  // Generated, not the literal "display-name" it was: two of these on one page (an
46
61
  // admin editing someone else's profile beside their own) shared an id, and the
@@ -52,7 +67,12 @@ export function ProfileSetting({
52
67
  <UserAvatar name={name} email={email} size="lg" />
53
68
  <div className="min-w-0">
54
69
  <div className="text-sm font-medium">{labels.title}</div>
55
- <div className="truncate font-mono text-xs text-[var(--text-muted)]">{email ?? "—"}</div>
70
+ <div className="truncate font-mono text-xs text-[var(--text-muted)]">
71
+ {/* `email` was a required key that nothing rendered; it names the address
72
+ for a screen reader, which otherwise hears a bare string under a title. */}
73
+ <span className="sr-only">{labels.email}: </span>
74
+ {email ?? "—"}
75
+ </div>
56
76
  </div>
57
77
  </div>
58
78
  <div className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-sm">
@@ -75,28 +95,20 @@ export function ProfileSetting({
75
95
  );
76
96
  }
77
97
 
78
- export interface PasswordSettingLabels {
79
- title: string;
80
- current: string;
81
- next: string;
82
- confirm: string;
83
- submit: string;
84
- tooShort: string;
85
- mismatch: string;
86
- }
87
-
88
98
  export function PasswordSetting({
89
99
  onSubmit,
90
100
  pending,
91
101
  minLength = 8,
92
- labels,
102
+ labels: labelsProp,
93
103
  }: {
94
104
  /** Called with the validated (current, new) pair; return a promise to auto-clear on success. */
95
105
  onSubmit: (currentPassword: string, newPassword: string) => void | Promise<unknown>;
96
106
  pending?: boolean;
97
107
  minLength?: number;
98
- labels: PasswordSettingLabels;
108
+ /** Prop > `<UiKitProvider labels={{ accountSettings: { password } }}>` > English. */
109
+ labels?: Partial<PasswordSettingLabels>;
99
110
  }) {
111
+ const labels = useAccountSettingsLabels("password", labelsProp);
100
112
  const [current, setCurrent] = useState("");
101
113
  const [next, setNext] = useState("");
102
114
  const [confirm, setConfirm] = useState("");
@@ -136,34 +148,35 @@ export function PasswordSetting({
136
148
  );
137
149
  }
138
150
 
139
- export interface TwoFactorSettingLabels {
140
- status: string;
141
- enabledText: string;
142
- disabledText: string;
143
- enable: string;
144
- scanHint: string;
145
- codeLabel: string;
146
- verify: string;
147
- disableSection: string;
148
- password: string;
149
- disable: string;
150
- /**
151
- * Alt text for the setup QR image, which shipped as a hardcoded `alt="QR"`.
152
- *
153
- * OPTIONAL, alone among these keys, and not because it matters less: this
154
- * interface is annotated at consumer call sites (`const LABELS:
155
- * TwoFactorSettingLabels = {…}`), so a new REQUIRED key is a compile error in
156
- * every app on the next `npm update` — the additive-API rule in the README. It
157
- * falls back to English here the way `AmountInput`'s label keys do. Make it
158
- * required in the next major, when the three apps can be updated with it.
159
- */
160
- qrAlt?: string;
151
+ /**
152
+ * What the app's setup call returned.
153
+ *
154
+ * `otpauthUri` (0.12.0) is what an authenticator backend actually hands out; the kit
155
+ * draws the QR code itself ({@link QrCode}) and reads the manual-entry key out of the
156
+ * URI's `secret` parameter when `secret` is not given. The `qrSvg` shape — a
157
+ * base64-encoded SVG image — is what the section took before, which made every app
158
+ * render a QR library to a static string and base64 it only for the kit to draw it back.
159
+ */
160
+ export type TwoFactorSetupData =
161
+ | { qrSvg: string; secret: string }
162
+ | { otpauthUri: string; secret?: string };
163
+
164
+ /** The `secret` parameter of an `otpauth://` URI, or null. */
165
+ function otpauthSecret(uri: string): string | null {
166
+ const match = /[?&]secret=([^&#]*)/i.exec(uri);
167
+ if (!match) return null;
168
+ try {
169
+ return decodeURIComponent(match[1]) || null;
170
+ } catch {
171
+ return match[1] || null;
172
+ }
161
173
  }
162
174
 
163
- /** `qrAlt`'s English fallback, kept beside the interface rather than inline so the
164
- * one English word in this section is findable by the same `DEFAULT_*` grep as
165
- * every other one. Module-private: the key goes required in the next major. */
166
- const DEFAULT_QR_ALT = "QR code";
175
+ /** "JBSW Y3DP EHPK 3PXP": the key in groups of four, which is how an authenticator's
176
+ * manual-entry screen shows it and how a person copies it by eye. */
177
+ function groupSecret(secret: string): string {
178
+ return secret.replace(/\s+/g, "").replace(/(.{4})(?=.)/g, "$1 ");
179
+ }
167
180
 
168
181
  export function TwoFactorSetting({
169
182
  enabled,
@@ -172,17 +185,23 @@ export function TwoFactorSetting({
172
185
  onEnable,
173
186
  onDisable,
174
187
  busy,
175
- labels,
188
+ renderQr,
189
+ labels: labelsProp,
176
190
  }: {
177
191
  enabled: boolean;
178
- /** QR + secret returned by the app's setup call; null before setup starts. */
179
- setup: { qrSvg: string; secret: string } | null;
192
+ /** What the app's setup call returned; null before setup starts. */
193
+ setup: TwoFactorSetupData | null;
180
194
  onStartSetup: () => void;
181
195
  onEnable: (code: string) => void;
182
196
  onDisable: (password: string, code: string) => void;
183
197
  busy?: boolean;
184
- labels: TwoFactorSettingLabels;
198
+ /** Draw the code yourself (a branded QR, a library you already ship). Given the
199
+ * `otpauthUri`; the kit's own {@link QrCode} otherwise. Unused with `qrSvg`. */
200
+ renderQr?: (otpauthUri: string) => ReactNode;
201
+ /** Prop > `<UiKitProvider labels={{ accountSettings: { twoFactor } }}>` > English. */
202
+ labels?: Partial<TwoFactorSettingLabels>;
185
203
  }) {
204
+ const labels = useAccountSettingsLabels("twoFactor", labelsProp);
186
205
  const [code, setCode] = useState("");
187
206
  const [password, setPassword] = useState("");
188
207
  // Browsers autofill one-time-code fields with the saved username; keep the
@@ -195,6 +214,9 @@ export function TwoFactorSetting({
195
214
  // Only for the "Status: Enabled" composition — the punctuation between a field's
196
215
  // name and its value is the language's (see `CommonLabels.fieldValue`).
197
216
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
217
+ const secret = setup
218
+ ? (setup.secret ?? ("otpauthUri" in setup ? otpauthSecret(setup.otpauthUri) : null))
219
+ : null;
198
220
 
199
221
  return (
200
222
  <Card className="p-4 space-y-3">
@@ -216,15 +238,40 @@ export function TwoFactorSetting({
216
238
  their quiet zone. On the dark theme a surface-coloured backing makes it
217
239
  slow to acquire, and on a warm light preset it lowers the contrast ratio
218
240
  the spec is written against. This is the one place in the kit where a
219
- literal colour is the correct answer. */}
220
- <div className="inline-block rounded-md bg-white p-2">
221
- <img
222
- alt={labels.qrAlt ?? DEFAULT_QR_ALT}
223
- src={`data:image/svg+xml;base64,${setup.qrSvg}`}
224
- className="h-48 w-48"
225
- />
226
- </div>
227
- <div className="break-all font-mono text-xs">{setup.secret}</div>
241
+ literal colour is the correct answer. `data-private`: the code IS the
242
+ second factor, and demo mode blurs it like any other secret. */}
243
+ {"otpauthUri" in setup ? (
244
+ // No backing of its own: QrCode draws its quiet zone white inside the SVG.
245
+ <div data-private className="inline-block overflow-hidden rounded-md">
246
+ {renderQr ? (
247
+ renderQr(setup.otpauthUri)
248
+ ) : (
249
+ <QrCode value={setup.otpauthUri} label={labels.qrAlt} className="size-48" />
250
+ )}
251
+ </div>
252
+ ) : (
253
+ <div data-private className="inline-block rounded-md bg-white p-2">
254
+ <img
255
+ alt={labels.qrAlt}
256
+ src={`data:image/svg+xml;base64,${setup.qrSvg}`}
257
+ className="h-48 w-48"
258
+ />
259
+ </div>
260
+ )}
261
+ {secret && (
262
+ <div className="space-y-1">
263
+ <div className="text-xs text-[var(--text-secondary)]">{labels.secretHint}</div>
264
+ <div className="flex items-center gap-1">
265
+ {/* `dir="ltr"`: a base32 key is Latin letters and digits in a fixed
266
+ order, and in an RTL page the groups would otherwise be laid out
267
+ right to left. */}
268
+ <code data-private dir="ltr" className="break-all font-mono text-xs select-all">
269
+ {groupSecret(secret)}
270
+ </code>
271
+ <CopyButton text={secret} label={labels.copySecret} size="xs" tone="muted" />
272
+ </div>
273
+ </div>
274
+ )}
228
275
  <Input
229
276
  inputMode="numeric"
230
277
  autoComplete="one-time-code"
@@ -38,6 +38,15 @@ interface AmountInputProps {
38
38
  className?: string;
39
39
  id?: string;
40
40
  ariaLabel?: string;
41
+ /**
42
+ * Set on the `<input>`, with `id` above. Declared so `Field`'s render-prop spread —
43
+ * `{(ids) => <AmountInput {...ids} … />}` — is typed and reaches the input: the
44
+ * hint and error are described, a required amount is announced as required, and
45
+ * `aria-invalid` paints {@link FIELD_INVALID} the way `invalid` does.
46
+ */
47
+ "aria-describedby"?: string;
48
+ "aria-invalid"?: boolean | "true" | "false";
49
+ "aria-required"?: boolean | "true" | "false";
41
50
  /** Every user-facing string this component and the two it composes own, so a
42
51
  * translating host can supply its own. Each key optional, and each a per-field
43
52
  * override of the kit-wide translation in `<UiKitProvider labels>`: `currency`
@@ -183,9 +192,10 @@ function isResultOf(previous: string, text: string): boolean {
183
192
  }
184
193
 
185
194
  export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
186
- ({ value, onChange, currency, onCurrencyChange, placeholder, label, disabled, invalid, className, id, ariaLabel, autoFocus, tone = "neutral", negative = false, onNegativeChange, variant = "field", align = "start", labels, currencyNames }, ref) => {
195
+ ({ value, onChange, currency, onCurrencyChange, placeholder, label, disabled, invalid: invalidProp, className, id, ariaLabel, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, autoFocus, tone = "neutral", negative = false, onNegativeChange, variant = "field", align = "start", labels, currencyNames }, ref) => {
187
196
  const generatedId = useId();
188
197
  const fieldId = id ?? generatedId;
198
+ const invalid = Boolean(invalidProp) || ariaInvalid === true || ariaInvalid === "true";
189
199
  const editable = !!onCurrencyChange;
190
200
  // On phones we suppress the OS keyboard (inputMode="none" below) and show our
191
201
  // own calculator numpad, so the desktop popover trigger is hidden. The
@@ -350,6 +360,8 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
350
360
  invalid && FIELD_INVALID,
351
361
  )}
352
362
  aria-invalid={invalid || undefined}
363
+ aria-describedby={ariaDescribedBy}
364
+ aria-required={ariaRequired}
353
365
  />
354
366
  {label !== undefined && (
355
367
  // The display shape drops the label VISUALLY, not from the accessibility
@@ -0,0 +1,190 @@
1
+ import { useState } from "react";
2
+ import type { ImgHTMLAttributes, ReactNode } from "react";
3
+ import { ImageOff } from "lucide-react";
4
+
5
+ import { cn } from "../lib/cn";
6
+ import { useKitLabels } from "../i18n/kit-labels";
7
+ import { SKELETON_CLASS } from "./skeleton";
8
+ import { useAuthedSrc } from "../hooks/use-authed-src";
9
+ import type { AuthedFetcher, UseAuthedSrcResult } from "../hooks/use-authed-src";
10
+
11
+ /** The two states an image can be in besides "there". */
12
+ export interface AuthedImageLabels {
13
+ /** Read out while the bytes are on their way (the skeleton itself is silent). */
14
+ loading: string;
15
+ /** Shown, small, in place of an image that could not be fetched or decoded. */
16
+ loadError: string;
17
+ /** The failed tile's accessible name, given the image's alt. */
18
+ failedImage: (alt: string) => string;
19
+ }
20
+
21
+ export const DEFAULT_AUTHED_IMAGE_LABELS: AuthedImageLabels = {
22
+ loading: "Loading image…",
23
+ loadError: "Image couldn’t be loaded",
24
+ failedImage: (alt) => `${alt}: image couldn’t be loaded`,
25
+ };
26
+
27
+ export interface AuthedImageProps
28
+ extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt" | "children"> {
29
+ /** The image's URL — an API path when a {@link fetcher} is given. */
30
+ src: string | null | undefined;
31
+ /** Required: an image without a text alternative is a gap a reader falls into.
32
+ * `""` for a purely decorative one. */
33
+ alt: string;
34
+ /** The app's authenticated GET (see {@link useAuthedSrc}). Left out, `src` is loaded
35
+ * by the browser as usual and this is an `<img>` with loading and error states. */
36
+ fetcher?: AuthedFetcher | null;
37
+ /** Classes for the box around the image (the placeholders fill it). */
38
+ wrapperClassName?: string;
39
+ /**
40
+ * Wrap the image in a link to itself, opening in a new tab — kastlan's
41
+ * `AuthedImage` did, because an object URL is the only address of a protected image
42
+ * a browser tab can open. The link's href is the `blob:` URL, never the API path.
43
+ */
44
+ link?: boolean;
45
+ /** Replaces the default skeleton while loading. */
46
+ loadingFallback?: ReactNode;
47
+ /** Replaces the default "couldn't load" tile. */
48
+ errorFallback?: ReactNode;
49
+ labels?: Partial<AuthedImageLabels>;
50
+ }
51
+
52
+ /**
53
+ * An image with a loading state, an error state and — given a `fetcher` — the app's
54
+ * authentication. Replaces kastlan's `shared/components/authed-image.tsx`, which
55
+ * rendered NOTHING until the blob arrived and nothing at all if it never did: a card
56
+ * with a photo slot either had the photo or was silently shorter.
57
+ *
58
+ * Two failures are one state here: the fetch failing (401, 404, offline) and the
59
+ * browser failing to decode what arrived (`onError` on the `<img>`, e.g. a PDF served
60
+ * where an image was expected).
61
+ */
62
+ export function AuthedImage({
63
+ src,
64
+ alt,
65
+ fetcher,
66
+ wrapperClassName,
67
+ className,
68
+ link,
69
+ loadingFallback,
70
+ errorFallback,
71
+ labels: labelsProp,
72
+ onLoad,
73
+ onError,
74
+ ...rest
75
+ }: AuthedImageProps) {
76
+ const labels = useKitLabels("authedImage", DEFAULT_AUTHED_IMAGE_LABELS, labelsProp);
77
+ const fetched = useAuthedSrc(src, { fetcher });
78
+ return (
79
+ <ImageStates
80
+ fetched={fetched}
81
+ alt={alt}
82
+ labels={labels}
83
+ className={className}
84
+ wrapperClassName={wrapperClassName}
85
+ link={link}
86
+ loadingFallback={loadingFallback}
87
+ errorFallback={errorFallback}
88
+ imgProps={rest}
89
+ onLoad={onLoad}
90
+ onError={onError}
91
+ />
92
+ );
93
+ }
94
+
95
+ /**
96
+ * The rendering half of {@link AuthedImage}, for a caller that already holds a
97
+ * {@link useAuthedSrc} result (the lightbox needs the object URL for its download link
98
+ * as well). Internal to the package.
99
+ */
100
+ export function ImageStates({
101
+ fetched,
102
+ alt,
103
+ labels,
104
+ className,
105
+ wrapperClassName,
106
+ link,
107
+ loadingFallback,
108
+ errorFallback,
109
+ imgProps,
110
+ onLoad,
111
+ onError,
112
+ }: {
113
+ fetched: UseAuthedSrcResult;
114
+ alt: string;
115
+ labels: AuthedImageLabels;
116
+ className?: string;
117
+ wrapperClassName?: string;
118
+ link?: boolean;
119
+ loadingFallback?: ReactNode;
120
+ errorFallback?: ReactNode;
121
+ imgProps?: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt" | "children" | "onLoad" | "onError">;
122
+ onLoad?: ImgHTMLAttributes<HTMLImageElement>["onLoad"];
123
+ onError?: ImgHTMLAttributes<HTMLImageElement>["onError"];
124
+ }) {
125
+ // Decode state, per src: keyed so a new picture starts over rather than inheriting
126
+ // the previous one's "loaded" or "broken".
127
+ const [decode, setDecode] = useState<{ src: string | null; state: "loaded" | "error" } | null>(null);
128
+ const decoded = decode && decode.src === fetched.src ? decode.state : "pending";
129
+ const failed = fetched.status === "error" || decoded === "error";
130
+ const waiting = !failed && (fetched.status === "loading" || (fetched.src !== null && decoded === "pending"));
131
+
132
+ const img = fetched.src !== null && !failed && (
133
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- load/error are resource events, not interactions
134
+ <img
135
+ {...imgProps}
136
+ src={fetched.src}
137
+ alt={alt}
138
+ onLoad={(e) => {
139
+ setDecode({ src: fetched.src, state: "loaded" });
140
+ onLoad?.(e);
141
+ }}
142
+ onError={(e) => {
143
+ setDecode({ src: fetched.src, state: "error" });
144
+ onError?.(e);
145
+ }}
146
+ className={cn(
147
+ // Kept in the tree while it decodes (the browser needs it there to load at
148
+ // all), but invisible, so the skeleton is what shows.
149
+ decoded === "pending" && "opacity-0",
150
+ className,
151
+ )}
152
+ />
153
+ );
154
+
155
+ return (
156
+ <span
157
+ data-image-state={failed ? "error" : waiting ? "loading" : fetched.status === "idle" ? "idle" : "ready"}
158
+ className={cn("relative block overflow-hidden", wrapperClassName)}
159
+ aria-busy={waiting || undefined}
160
+ >
161
+ {link && img && fetched.src ? (
162
+ <a href={fetched.src} target="_blank" rel="noreferrer noopener" className="block h-full w-full">
163
+ {img}
164
+ </a>
165
+ ) : (
166
+ img
167
+ )}
168
+ {waiting &&
169
+ (loadingFallback ?? (
170
+ <span className={cn(SKELETON_CLASS, "absolute inset-0 block rounded-none")}>
171
+ <span className="sr-only">{labels.loading}</span>
172
+ </span>
173
+ ))}
174
+ {failed &&
175
+ (errorFallback ?? (
176
+ <span
177
+ // `role="img"` with the alt as its name: the picture that failed still
178
+ // occupies its place in the reading order, and "couldn't be loaded" is
179
+ // the one fact a reader needs about it.
180
+ role="img"
181
+ aria-label={alt ? labels.failedImage(alt) : labels.loadError}
182
+ className="flex h-full min-h-16 w-full flex-col items-center justify-center gap-1 bg-[var(--bg-hover)] p-2 text-center text-xs text-[var(--text-muted)]"
183
+ >
184
+ <ImageOff aria-hidden className="size-5 shrink-0" />
185
+ <span aria-hidden className="line-clamp-2">{labels.loadError}</span>
186
+ </span>
187
+ ))}
188
+ </span>
189
+ );
190
+ }