@zentauri-ui/zentauri-components 2.3.0 → 2.3.2

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 (243) hide show
  1. package/README.md +9 -5
  2. package/cli/props.json +2508 -1458
  3. package/cli/registry.json +20 -0
  4. package/dist/chunk-2RBBXVFA.js +19 -0
  5. package/dist/{chunk-ENKXB2BA.js.map → chunk-2RBBXVFA.js.map} +1 -1
  6. package/dist/{chunk-BFHJF4MV.mjs → chunk-425DAZTS.mjs} +4 -4
  7. package/dist/{chunk-BFHJF4MV.mjs.map → chunk-425DAZTS.mjs.map} +1 -1
  8. package/dist/chunk-4U6PVVST.mjs +15 -0
  9. package/dist/chunk-4U6PVVST.mjs.map +1 -0
  10. package/dist/{chunk-AARJLZXP.js → chunk-6GP74P4F.js} +6 -6
  11. package/dist/{chunk-AARJLZXP.js.map → chunk-6GP74P4F.js.map} +1 -1
  12. package/dist/{chunk-DIAA5VH4.mjs → chunk-CIZQQ32L.mjs} +3 -3
  13. package/dist/chunk-CIZQQ32L.mjs.map +1 -0
  14. package/dist/chunk-DUKHIN2W.js +44 -0
  15. package/dist/chunk-DUKHIN2W.js.map +1 -0
  16. package/dist/{chunk-PQ2XTY3M.js → chunk-G36ZV446.js} +13 -13
  17. package/dist/{chunk-PQ2XTY3M.js.map → chunk-G36ZV446.js.map} +1 -1
  18. package/dist/chunk-HJ7EFBED.js +86 -0
  19. package/dist/chunk-HJ7EFBED.js.map +1 -0
  20. package/dist/{chunk-H3BJOK22.js → chunk-HNAUUCR5.js} +3 -3
  21. package/dist/chunk-HNAUUCR5.js.map +1 -0
  22. package/dist/chunk-K2VCZK4I.mjs +75 -0
  23. package/dist/chunk-K2VCZK4I.mjs.map +1 -0
  24. package/dist/{chunk-DSX6RUYI.js → chunk-KVBFWRPF.js} +12 -12
  25. package/dist/{chunk-DSX6RUYI.js.map → chunk-KVBFWRPF.js.map} +1 -1
  26. package/dist/chunk-N4MLFU2Q.mjs +69 -0
  27. package/dist/chunk-N4MLFU2Q.mjs.map +1 -0
  28. package/dist/{chunk-ATE5SCTR.mjs → chunk-N6Q4ZLQR.mjs} +3 -3
  29. package/dist/{chunk-ATE5SCTR.mjs.map → chunk-N6Q4ZLQR.mjs.map} +1 -1
  30. package/dist/{chunk-YY7G4NV3.js → chunk-NZQA4M35.js} +49 -17
  31. package/dist/chunk-NZQA4M35.js.map +1 -0
  32. package/dist/chunk-OME7XOPN.js +78 -0
  33. package/dist/chunk-OME7XOPN.js.map +1 -0
  34. package/dist/chunk-PVDWJUMF.mjs +34 -0
  35. package/dist/chunk-PVDWJUMF.mjs.map +1 -0
  36. package/dist/chunk-PVVYOIU2.js +38 -0
  37. package/dist/chunk-PVVYOIU2.js.map +1 -0
  38. package/dist/chunk-PWEZB53R.js +90 -0
  39. package/dist/chunk-PWEZB53R.js.map +1 -0
  40. package/dist/chunk-PWYEC3KY.mjs +30 -0
  41. package/dist/chunk-PWYEC3KY.mjs.map +1 -0
  42. package/dist/{chunk-JKKF5DCF.mjs → chunk-R2JXARKB.mjs} +3 -3
  43. package/dist/{chunk-JKKF5DCF.mjs.map → chunk-R2JXARKB.mjs.map} +1 -1
  44. package/dist/chunk-SD7YXMNV.js +40 -0
  45. package/dist/chunk-SD7YXMNV.js.map +1 -0
  46. package/dist/{chunk-ZB6C6CJQ.mjs → chunk-TZTUL6C4.mjs} +40 -8
  47. package/dist/chunk-TZTUL6C4.mjs.map +1 -0
  48. package/dist/chunk-UBQY572I.mjs +81 -0
  49. package/dist/chunk-UBQY572I.mjs.map +1 -0
  50. package/dist/chunk-V5EE5ATH.mjs +36 -0
  51. package/dist/chunk-V5EE5ATH.mjs.map +1 -0
  52. package/dist/chunk-XUK5P37Y.js +19 -0
  53. package/dist/chunk-XUK5P37Y.js.map +1 -0
  54. package/dist/{chunk-WZY32L6K.mjs → chunk-YDD5HQGX.mjs} +3 -3
  55. package/dist/{chunk-WZY32L6K.mjs.map → chunk-YDD5HQGX.mjs.map} +1 -1
  56. package/dist/design-system/facade.js +12 -8
  57. package/dist/design-system/facade.js.map +1 -1
  58. package/dist/design-system/facade.mjs +11 -7
  59. package/dist/design-system/facade.mjs.map +1 -1
  60. package/dist/design-system/index.d.ts +4 -0
  61. package/dist/design-system/index.d.ts.map +1 -1
  62. package/dist/design-system/qr-code.d.ts +4 -0
  63. package/dist/design-system/qr-code.d.ts.map +1 -0
  64. package/dist/design-system/qr-scanner.d.ts +11 -0
  65. package/dist/design-system/qr-scanner.d.ts.map +1 -0
  66. package/dist/design-system/secret-reveal.d.ts +1 -1
  67. package/dist/design-system/secret-reveal.d.ts.map +1 -1
  68. package/dist/design-system/speech-recognition.d.ts +39 -0
  69. package/dist/design-system/speech-recognition.d.ts.map +1 -0
  70. package/dist/design-system/speech-synthesizer.d.ts +41 -0
  71. package/dist/design-system/speech-synthesizer.d.ts.map +1 -0
  72. package/dist/ui/buttons/animated.js +14 -10
  73. package/dist/ui/buttons/animated.js.map +1 -1
  74. package/dist/ui/buttons/animated.mjs +12 -8
  75. package/dist/ui/buttons/animated.mjs.map +1 -1
  76. package/dist/ui/buttons.js +15 -11
  77. package/dist/ui/buttons.mjs +13 -9
  78. package/dist/ui/data-table.js +25 -21
  79. package/dist/ui/data-table.js.map +1 -1
  80. package/dist/ui/data-table.mjs +15 -11
  81. package/dist/ui/data-table.mjs.map +1 -1
  82. package/dist/ui/dynamic-stepper.js +24 -20
  83. package/dist/ui/dynamic-stepper.js.map +1 -1
  84. package/dist/ui/dynamic-stepper.mjs +13 -9
  85. package/dist/ui/dynamic-stepper.mjs.map +1 -1
  86. package/dist/ui/pagination.js +16 -12
  87. package/dist/ui/pagination.mjs +13 -9
  88. package/dist/ui/qr-code/animated/animations.d.ts +8 -0
  89. package/dist/ui/qr-code/animated/animations.d.ts.map +1 -0
  90. package/dist/ui/qr-code/animated/index.d.ts +4 -0
  91. package/dist/ui/qr-code/animated/index.d.ts.map +1 -0
  92. package/dist/ui/qr-code/animated/qr-code-animated.d.ts +6 -0
  93. package/dist/ui/qr-code/animated/qr-code-animated.d.ts.map +1 -0
  94. package/dist/ui/qr-code/animated/types.d.ts +9 -0
  95. package/dist/ui/qr-code/animated/types.d.ts.map +1 -0
  96. package/dist/ui/qr-code/animated.js +156 -0
  97. package/dist/ui/qr-code/animated.js.map +1 -0
  98. package/dist/ui/qr-code/animated.mjs +149 -0
  99. package/dist/ui/qr-code/animated.mjs.map +1 -0
  100. package/dist/ui/qr-code/index.d.ts +5 -0
  101. package/dist/ui/qr-code/index.d.ts.map +1 -0
  102. package/dist/ui/qr-code/qr-code-base.d.ts +47 -0
  103. package/dist/ui/qr-code/qr-code-base.d.ts.map +1 -0
  104. package/dist/ui/qr-code/qr-code.d.ts +2 -0
  105. package/dist/ui/qr-code/qr-code.d.ts.map +1 -0
  106. package/dist/ui/qr-code/types.d.ts +14 -0
  107. package/dist/ui/qr-code/types.d.ts.map +1 -0
  108. package/dist/ui/qr-code/variants.d.ts +4 -0
  109. package/dist/ui/qr-code/variants.d.ts.map +1 -0
  110. package/dist/ui/qr-code.js +35 -0
  111. package/dist/ui/qr-code.js.map +1 -0
  112. package/dist/ui/qr-code.mjs +17 -0
  113. package/dist/ui/qr-code.mjs.map +1 -0
  114. package/dist/ui/qr-scanner/index.d.ts +4 -0
  115. package/dist/ui/qr-scanner/index.d.ts.map +1 -0
  116. package/dist/ui/qr-scanner/qr-scanner-base.d.ts +62 -0
  117. package/dist/ui/qr-scanner/qr-scanner-base.d.ts.map +1 -0
  118. package/dist/ui/qr-scanner/qr-scanner.d.ts +2 -0
  119. package/dist/ui/qr-scanner/qr-scanner.d.ts.map +1 -0
  120. package/dist/ui/qr-scanner/types.d.ts +28 -0
  121. package/dist/ui/qr-scanner/types.d.ts.map +1 -0
  122. package/dist/ui/qr-scanner/variants.d.ts +9 -0
  123. package/dist/ui/qr-scanner/variants.d.ts.map +1 -0
  124. package/dist/ui/qr-scanner.js +316 -0
  125. package/dist/ui/qr-scanner.js.map +1 -0
  126. package/dist/ui/qr-scanner.mjs +308 -0
  127. package/dist/ui/qr-scanner.mjs.map +1 -0
  128. package/dist/ui/secret-reveal/animated/secret-reveal-animated.d.ts.map +1 -1
  129. package/dist/ui/secret-reveal/animated.js +10 -7
  130. package/dist/ui/secret-reveal/animated.js.map +1 -1
  131. package/dist/ui/secret-reveal/animated.mjs +6 -3
  132. package/dist/ui/secret-reveal/animated.mjs.map +1 -1
  133. package/dist/ui/secret-reveal/secret-reveal-base.d.ts.map +1 -1
  134. package/dist/ui/secret-reveal.js +14 -11
  135. package/dist/ui/secret-reveal.js.map +1 -1
  136. package/dist/ui/secret-reveal.mjs +7 -4
  137. package/dist/ui/secret-reveal.mjs.map +1 -1
  138. package/dist/ui/speech-recognition/animated/animations.d.ts +8 -0
  139. package/dist/ui/speech-recognition/animated/animations.d.ts.map +1 -0
  140. package/dist/ui/speech-recognition/animated/index.d.ts +4 -0
  141. package/dist/ui/speech-recognition/animated/index.d.ts.map +1 -0
  142. package/dist/ui/speech-recognition/animated/speech-recognition-animated.d.ts +6 -0
  143. package/dist/ui/speech-recognition/animated/speech-recognition-animated.d.ts.map +1 -0
  144. package/dist/ui/speech-recognition/animated/types.d.ts +9 -0
  145. package/dist/ui/speech-recognition/animated/types.d.ts.map +1 -0
  146. package/dist/ui/speech-recognition/animated.js +288 -0
  147. package/dist/ui/speech-recognition/animated.js.map +1 -0
  148. package/dist/ui/speech-recognition/animated.mjs +285 -0
  149. package/dist/ui/speech-recognition/animated.mjs.map +1 -0
  150. package/dist/ui/speech-recognition/index.d.ts +4 -0
  151. package/dist/ui/speech-recognition/index.d.ts.map +1 -0
  152. package/dist/ui/speech-recognition/speech-recognition-base.d.ts +6 -0
  153. package/dist/ui/speech-recognition/speech-recognition-base.d.ts.map +1 -0
  154. package/dist/ui/speech-recognition/speech-recognition.d.ts +2 -0
  155. package/dist/ui/speech-recognition/speech-recognition.d.ts.map +1 -0
  156. package/dist/ui/speech-recognition/types.d.ts +31 -0
  157. package/dist/ui/speech-recognition/types.d.ts.map +1 -0
  158. package/dist/ui/speech-recognition/variants.d.ts +11 -0
  159. package/dist/ui/speech-recognition/variants.d.ts.map +1 -0
  160. package/dist/ui/speech-recognition.js +242 -0
  161. package/dist/ui/speech-recognition.js.map +1 -0
  162. package/dist/ui/speech-recognition.mjs +233 -0
  163. package/dist/ui/speech-recognition.mjs.map +1 -0
  164. package/dist/ui/speech-synthesizer/animated/animations.d.ts +8 -0
  165. package/dist/ui/speech-synthesizer/animated/animations.d.ts.map +1 -0
  166. package/dist/ui/speech-synthesizer/animated/index.d.ts +4 -0
  167. package/dist/ui/speech-synthesizer/animated/index.d.ts.map +1 -0
  168. package/dist/ui/speech-synthesizer/animated/speech-synthesizer-animated.d.ts +6 -0
  169. package/dist/ui/speech-synthesizer/animated/speech-synthesizer-animated.d.ts.map +1 -0
  170. package/dist/ui/speech-synthesizer/animated/types.d.ts +9 -0
  171. package/dist/ui/speech-synthesizer/animated/types.d.ts.map +1 -0
  172. package/dist/ui/speech-synthesizer/animated.js +269 -0
  173. package/dist/ui/speech-synthesizer/animated.js.map +1 -0
  174. package/dist/ui/speech-synthesizer/animated.mjs +266 -0
  175. package/dist/ui/speech-synthesizer/animated.mjs.map +1 -0
  176. package/dist/ui/speech-synthesizer/index.d.ts +4 -0
  177. package/dist/ui/speech-synthesizer/index.d.ts.map +1 -0
  178. package/dist/ui/speech-synthesizer/speech-synthesizer-base.d.ts +6 -0
  179. package/dist/ui/speech-synthesizer/speech-synthesizer-base.d.ts.map +1 -0
  180. package/dist/ui/speech-synthesizer/speech-synthesizer.d.ts +2 -0
  181. package/dist/ui/speech-synthesizer/speech-synthesizer.d.ts.map +1 -0
  182. package/dist/ui/speech-synthesizer/types.d.ts +43 -0
  183. package/dist/ui/speech-synthesizer/types.d.ts.map +1 -0
  184. package/dist/ui/speech-synthesizer/variants.d.ts +13 -0
  185. package/dist/ui/speech-synthesizer/variants.d.ts.map +1 -0
  186. package/dist/ui/speech-synthesizer.js +220 -0
  187. package/dist/ui/speech-synthesizer.js.map +1 -0
  188. package/dist/ui/speech-synthesizer.mjs +211 -0
  189. package/dist/ui/speech-synthesizer.mjs.map +1 -0
  190. package/dist/ui/split-button.js +26 -22
  191. package/dist/ui/split-button.js.map +1 -1
  192. package/dist/ui/split-button.mjs +13 -9
  193. package/dist/ui/split-button.mjs.map +1 -1
  194. package/package.json +5 -2
  195. package/src/design-system/index.ts +4 -0
  196. package/src/design-system/qr-code.ts +13 -0
  197. package/src/design-system/qr-scanner.ts +32 -0
  198. package/src/design-system/secret-reveal.ts +1 -1
  199. package/src/design-system/speech-recognition.ts +82 -0
  200. package/src/design-system/speech-synthesizer.ts +90 -0
  201. package/src/ui/qr-code/animated/animations.ts +51 -0
  202. package/src/ui/qr-code/animated/index.ts +5 -0
  203. package/src/ui/qr-code/animated/qr-code-animated.tsx +111 -0
  204. package/src/ui/qr-code/animated/types.ts +10 -0
  205. package/src/ui/qr-code/index.ts +10 -0
  206. package/src/ui/qr-code/qr-code-base.tsx +149 -0
  207. package/src/ui/qr-code/qr-code.test.tsx +58 -0
  208. package/src/ui/qr-code/qr-code.tsx +2 -0
  209. package/src/ui/qr-code/types.ts +22 -0
  210. package/src/ui/qr-code/variants.ts +11 -0
  211. package/src/ui/qr-scanner/index.ts +17 -0
  212. package/src/ui/qr-scanner/qr-scanner-base.tsx +568 -0
  213. package/src/ui/qr-scanner/qr-scanner.test.tsx +61 -0
  214. package/src/ui/qr-scanner/qr-scanner.tsx +2 -0
  215. package/src/ui/qr-scanner/types.ts +32 -0
  216. package/src/ui/qr-scanner/variants.ts +26 -0
  217. package/src/ui/secret-reveal/animated/secret-reveal-animated.tsx +4 -1
  218. package/src/ui/secret-reveal/secret-reveal-base.tsx +4 -1
  219. package/src/ui/speech-recognition/animated/animations.ts +62 -0
  220. package/src/ui/speech-recognition/animated/index.ts +8 -0
  221. package/src/ui/speech-recognition/animated/speech-recognition-animated.tsx +276 -0
  222. package/src/ui/speech-recognition/animated/types.ts +11 -0
  223. package/src/ui/speech-recognition/index.ts +15 -0
  224. package/src/ui/speech-recognition/speech-recognition-base.tsx +276 -0
  225. package/src/ui/speech-recognition/speech-recognition.test.tsx +74 -0
  226. package/src/ui/speech-recognition/speech-recognition.tsx +1 -0
  227. package/src/ui/speech-recognition/types.ts +50 -0
  228. package/src/ui/speech-recognition/variants.ts +47 -0
  229. package/src/ui/speech-synthesizer/animated/animations.ts +62 -0
  230. package/src/ui/speech-synthesizer/animated/index.ts +8 -0
  231. package/src/ui/speech-synthesizer/animated/speech-synthesizer-animated.tsx +260 -0
  232. package/src/ui/speech-synthesizer/animated/types.ts +11 -0
  233. package/src/ui/speech-synthesizer/index.ts +14 -0
  234. package/src/ui/speech-synthesizer/speech-synthesizer-base.tsx +255 -0
  235. package/src/ui/speech-synthesizer/speech-synthesizer.test.tsx +87 -0
  236. package/src/ui/speech-synthesizer/speech-synthesizer.tsx +1 -0
  237. package/src/ui/speech-synthesizer/types.ts +57 -0
  238. package/src/ui/speech-synthesizer/variants.ts +55 -0
  239. package/dist/chunk-DIAA5VH4.mjs.map +0 -1
  240. package/dist/chunk-ENKXB2BA.js +0 -19
  241. package/dist/chunk-H3BJOK22.js.map +0 -1
  242. package/dist/chunk-YY7G4NV3.js.map +0 -1
  243. package/dist/chunk-ZB6C6CJQ.mjs.map +0 -1
@@ -0,0 +1,568 @@
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useImperativeHandle,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+
11
+ import { cn } from "../../lib/utils";
12
+
13
+ import type { QrScannerBaseProps } from "./types";
14
+ import {
15
+ qrScannerFallbackVariants,
16
+ qrScannerOverlayVariants,
17
+ qrScannerStatusVariants,
18
+ qrScannerVariants,
19
+ qrScannerVideoVariants,
20
+ qrScannerViewfinderVariants,
21
+ } from "./variants";
22
+
23
+ /**
24
+ * Module-level cache for the `jsQR` decoder library.
25
+ *
26
+ * `jsqr` is dynamically imported on first use (both from `scanFrame` and
27
+ * `scanImage`) and stored here so subsequent frame scans don't re-fetch the
28
+ * module from the network. The import returns an ESM default export, which
29
+ * we extract and cache as a plain function.
30
+ *
31
+ * The `any` type is used because `jsqr`'s published types have a complex
32
+ * overloaded signature (Uint8ClampedArray vs Uint8Array) that doesn't map
33
+ * cleanly to the runtime ImageData we pass. The runtime behaviour is correct
34
+ * regardless.
35
+ */
36
+ let cachedJsQR: any = null;
37
+
38
+ /** Lazy-loader that returns the cached jsQR decoder, importing it once. */
39
+ async function getJsqr() {
40
+ if (!cachedJsQR) {
41
+ const { default: m } = await import("jsqr");
42
+ cachedJsQR = m;
43
+ }
44
+ return cachedJsQR;
45
+ }
46
+
47
+ /**
48
+ * QrScannerBase uses the device camera (via `getUserMedia`) to decode QR
49
+ * codes in real time. It provides an imperative handle (`ref`) so parents
50
+ * can control the camera lifecycle and scan images without camera access.
51
+ *
52
+ * --- Architecture overview ---
53
+ *
54
+ * The component has three main async loops:
55
+ *
56
+ * 1. **Camera startup** (`startCamera`) — calls `getUserMedia`, attaches
57
+ * the stream to a `<video>` element, calls `video.play()`, then
58
+ * kicks off the scan loop via `requestAnimationFrame`.
59
+ *
60
+ * 2. **Scan loop** (`scanFrame`) — scheduled with `requestAnimationFrame`.
61
+ * Each frame draws the current video frame to an off-screen `<canvas>`,
62
+ * calls `jsQR` on the pixel data, and fires `onResult` when a QR code
63
+ * is detected. Respects `scanDelay` to avoid thrashing.
64
+ *
65
+ * 3. **Image scanning** (`scanImage` on the imperative handle) — decodes a
66
+ * QR code from a static `File` (e.g. an uploaded screenshot) without
67
+ * involving the camera at all.
68
+ *
69
+ * --- Race-condition guards ---
70
+ *
71
+ * Camera start/stop is inherently racy because `video.play()` returns a
72
+ * Promise that resolves asynchronously. If `stopCamera()` (or the
73
+ * `useEffect` cleanup) runs while `play()` is still pending, we must not
74
+ * operate on the new stream that may have been set up in the meantime.
75
+ *
76
+ * Both `stopCamera` and the effect cleanup capture `streamRef.current` into
77
+ * a local `streamToStop` variable at the time they are called. The actual
78
+ * stop/cleanup logic (which may run later via `playPromise.then(...)`)
79
+ * checks `video.srcObject === streamToStop` before pausing, ensuring it
80
+ * only affects the stream that was active at the time of the request.
81
+ *
82
+ * --- Callback ref pattern ---
83
+ *
84
+ * `onResult`, `onError`, `onStart`, `onStop` are stored in refs and kept
85
+ * current in the render body. The `useCallback` hooks for `scanFrame`,
86
+ * `startCamera`, and `stopCamera` read from these refs instead of listing
87
+ * the callbacks in their dependency arrays. This prevents the camera from
88
+ * restarting every time the parent passes a new inline function reference.
89
+ *
90
+ * --- Error classification ---
91
+ *
92
+ * `getUserMedia` throws `DOMException` with specific names:
93
+ * - `NotFoundError` → no camera hardware detected
94
+ * - `NotAllowedError` → user denied permission
95
+ * - `NotReadableError` → camera is busy (another app)
96
+ * - `AbortError` → `play()` was interrupted by unmount
97
+ *
98
+ * The first three set `status` to `"no-camera"` and render a fallback UI.
99
+ * `AbortError` is silently swallowed because it's a normal consequence of
100
+ * unmounting during startup. All other errors set `status` to `"error"` and
101
+ * display the error message.
102
+ */
103
+ export function QrScannerBase({
104
+ onResult,
105
+ onError,
106
+ onStart,
107
+ onStop,
108
+ facingMode = "environment",
109
+ constraints,
110
+ scanDelay = 500,
111
+ continuous = false,
112
+ fallbackText = "Camera not available",
113
+ loadingText = "Starting camera...",
114
+ noCameraText = "No camera detected",
115
+ appearance,
116
+ autoStart = true,
117
+ className,
118
+ ref,
119
+ ...rest
120
+ }: QrScannerBaseProps) {
121
+ // ---- Refs ----
122
+
123
+ /** The `<video>` element that plays the camera stream. */
124
+ const videoRef = useRef<HTMLVideoElement>(null);
125
+
126
+ /** The hidden `<canvas>` used to grab frames for jsQR decoding. */
127
+ const canvasRef = useRef<HTMLCanvasElement>(null);
128
+
129
+ /** The active `MediaStream`, or `null` when the camera is stopped. */
130
+ const streamRef = useRef<MediaStream | null>(null);
131
+
132
+ /**
133
+ * Boolean ref that tracks whether the scan loop should keep running.
134
+ * Checked at the top of `scanFrame`. Set to `false` by `stopCamera`.
135
+ */
136
+ const scanningRef = useRef(false);
137
+
138
+ /**
139
+ * Timestamp (ms) of the last attempted frame scan. Used to implement
140
+ * `scanDelay` — frame processing is skipped if frames arrive before the
141
+ * delay elapses, regardless of whether jsQR finds a code.
142
+ */
143
+ const lastScanRef = useRef(0);
144
+
145
+ /** The current `requestAnimationFrame` ID, used for cancellation. */
146
+ const rafRef = useRef(0);
147
+
148
+ /** Set to `true` on mount, `false` on unmount. Guards async callbacks. */
149
+ const mountedRef = useRef(true);
150
+
151
+ /**
152
+ * Stores the pending `play()` promise so that `stopCamera` and the effect
153
+ * cleanup can chain `.then()` on it. Without this, stopping the camera
154
+ * while `play()` is in flight causes the browser warning:
155
+ * "The play() request was interrupted because the media was removed"
156
+ */
157
+ const playPromiseRef = useRef<Promise<void> | null>(null);
158
+
159
+ // ---- Callback refs (keep current without causing re-renders) ----
160
+
161
+ const onResultRef = useRef(onResult);
162
+ const onErrorRef = useRef(onError);
163
+ const onStartRef = useRef(onStart);
164
+ const onStopRef = useRef(onStop);
165
+ onResultRef.current = onResult;
166
+ onErrorRef.current = onError;
167
+ onStartRef.current = onStart;
168
+ onStopRef.current = onStop;
169
+
170
+ // ---- State ----
171
+
172
+ /** Whether the camera is actively scanning (used by `isScanning` on the imperative handle). */
173
+ const [scanning, setScanning] = useState(false);
174
+
175
+ /**
176
+ * High-level lifecycle status, used to decide which UI to render:
177
+ * - `"idle"` — Camera not yet requested.
178
+ * - `"starting"` -- `getUserMedia` or `play()` in progress.
179
+ * - `"scanning"` -- Camera is live and frames are being analysed.
180
+ * - `"error"` -- `getUserMedia` threw a non-camera error.
181
+ * - `"no-camera"` -- Camera not found, permission denied, or busy.
182
+ */
183
+ const [status, setStatus] = useState<
184
+ "idle" | "starting" | "scanning" | "error" | "no-camera"
185
+ >("idle");
186
+
187
+ /** The error message to display when `status === "error"`. */
188
+ const [error, setError] = useState<string | null>(null);
189
+
190
+ /**
191
+ * Tracks the mount lifecycle. The cleanup sets `mountedRef.current = false`,
192
+ * which is checked by `startCamera` after each `await` point so it can bail
193
+ * early if the component unmounted mid-startup.
194
+ */
195
+ useEffect(() => {
196
+ mountedRef.current = true;
197
+ return () => {
198
+ mountedRef.current = false;
199
+ };
200
+ }, []);
201
+
202
+ // ---- Camera control ----
203
+
204
+ /**
205
+ * Stops the camera: cancels the animation frame, pauses the video, stops
206
+ * all tracks on the captured stream, and nulls out refs.
207
+ *
208
+ * **Race-condition handling:**
209
+ * Captures `streamRef.current` into `streamToStop` at call time. If `play()`
210
+ * is pending, the actual stop logic is deferred to a `.then()` callback.
211
+ * That callback checks `video.srcObject === streamToStop` so it only shuts
212
+ * down the stream that was active when `stopCamera` was called, not a
213
+ * newer stream that may have been started in the meantime.
214
+ */
215
+ const stopCamera = useCallback(() => {
216
+ scanningRef.current = false;
217
+ setScanning(false);
218
+ setStatus("idle");
219
+ cancelAnimationFrame(rafRef.current);
220
+
221
+ const streamToStop = streamRef.current;
222
+
223
+ const doStop = () => {
224
+ if (videoRef.current && videoRef.current.srcObject === streamToStop) {
225
+ videoRef.current.pause();
226
+ videoRef.current.srcObject = null;
227
+ }
228
+ if (streamToStop) {
229
+ streamToStop.getTracks().forEach((track) => track.stop());
230
+ }
231
+ if (streamRef.current === streamToStop) {
232
+ streamRef.current = null;
233
+ }
234
+ onStopRef.current?.();
235
+ };
236
+
237
+ if (playPromiseRef.current) {
238
+ playPromiseRef.current.then(doStop, doStop);
239
+ playPromiseRef.current = null;
240
+ } else {
241
+ doStop();
242
+ }
243
+ }, []);
244
+
245
+ /**
246
+ * The per-frame scan logic, scheduled via `requestAnimationFrame`.
247
+ *
248
+ * 1. Guards: exits early if scanning was cancelled, the component unmounted,
249
+ * or the video doesn't have enough data yet.
250
+ * 2. Throttle: skips frames that arrive before `scanDelay` ms have elapsed
251
+ * since the last decode.
252
+ * 3. Captures the current video frame onto the off-screen canvas.
253
+ * 4. Runs jsQR on the canvas pixel data.
254
+ * 5. On a match: fires `onResultRef.current`. If `continuous` is `false`,
255
+ * stops the camera immediately (single-scan mode).
256
+ * 6. Re-schedules itself unless scanning was stopped.
257
+ *
258
+ * Frame processing errors (e.g. a corrupted frame) are silently caught and
259
+ * the loop continues.
260
+ */
261
+ const scanFrame = useCallback(async () => {
262
+ if (!scanningRef.current || !mountedRef.current) return;
263
+ const video = videoRef.current;
264
+ const canvas = canvasRef.current;
265
+ if (!video || !canvas || video.readyState < video.HAVE_ENOUGH_DATA) {
266
+ rafRef.current = requestAnimationFrame(scanFrame);
267
+ return;
268
+ }
269
+
270
+ const now = Date.now();
271
+ if (now - lastScanRef.current < scanDelay) {
272
+ rafRef.current = requestAnimationFrame(scanFrame);
273
+ return;
274
+ }
275
+ lastScanRef.current = now;
276
+
277
+ const ctx = canvas.getContext("2d");
278
+ if (!ctx) {
279
+ rafRef.current = requestAnimationFrame(scanFrame);
280
+ return;
281
+ }
282
+
283
+ canvas.width = video.videoWidth;
284
+ canvas.height = video.videoHeight;
285
+ ctx.drawImage(video, 0, 0);
286
+
287
+ try {
288
+ const jsqr = await getJsqr();
289
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
290
+ const code = jsqr(imageData.data, imageData.width, imageData.height);
291
+
292
+ if (code) {
293
+ onResultRef.current(code.data);
294
+ if (!continuous) {
295
+ scanningRef.current = false;
296
+ setScanning(false);
297
+ stopCamera();
298
+ return;
299
+ }
300
+ }
301
+ } catch {
302
+ // Frame processing error, continue scanning
303
+ }
304
+
305
+ if (mountedRef.current) {
306
+ rafRef.current = requestAnimationFrame(scanFrame);
307
+ }
308
+ }, [scanDelay, continuous, stopCamera]);
309
+
310
+ /**
311
+ * Starts the camera: requests a `MediaStream`, attaches it to the video
312
+ * element, waits for playback to settle, then begins the scan loop.
313
+ *
314
+ * Steps:
315
+ * 1. Guard: skip if unmounted or `getUserMedia` is unavailable.
316
+ * 2. Call `getUserMedia` with the configured `facingMode` and
317
+ * `constraints`.
318
+ * 3. Guard: if unmounted during the await, stop the freshly-acquired
319
+ * stream and bail.
320
+ * 4. Assign the stream to `streamRef.current` and the video element.
321
+ * 5. Save the `play()` promise to `playPromiseRef` so it can be awaited
322
+ * by `stopCamera` / cleanup.
323
+ * 6. Guard: if unmounted after play settles, call `stopCamera()` and
324
+ * bail.
325
+ * 7. Mark scanning as active and schedule the first `scanFrame`.
326
+ *
327
+ * The `onStart` callback (via ref) is fired once scanning begins.
328
+ *
329
+ * `getUserMedia` availability is checked upfront because it only exists
330
+ * in secure contexts (HTTPS or localhost). Without the check the call
331
+ * would throw a confusing `TypeError` instead of a user-friendly message.
332
+ */
333
+ const startCamera = useCallback(async () => {
334
+ if (!mountedRef.current) return;
335
+
336
+ if (!navigator.mediaDevices?.getUserMedia) {
337
+ setStatus("no-camera");
338
+ setError("Camera API not available in this context");
339
+ onErrorRef.current?.(new Error("getUserMedia is not available"));
340
+ return;
341
+ }
342
+
343
+ setStatus("starting");
344
+ setError(null);
345
+
346
+ try {
347
+ const stream = await navigator.mediaDevices.getUserMedia({
348
+ video: {
349
+ facingMode,
350
+ ...constraints,
351
+ },
352
+ });
353
+
354
+ if (!mountedRef.current) {
355
+ stream.getTracks().forEach((track) => track.stop());
356
+ return;
357
+ }
358
+
359
+ streamRef.current = stream;
360
+ if (videoRef.current) {
361
+ videoRef.current.srcObject = stream;
362
+ playPromiseRef.current = videoRef.current.play();
363
+ await playPromiseRef.current;
364
+ playPromiseRef.current = null;
365
+ }
366
+
367
+ if (!mountedRef.current) {
368
+ stopCamera();
369
+ return;
370
+ }
371
+
372
+ scanningRef.current = true;
373
+ setScanning(true);
374
+ setStatus("scanning");
375
+ onStartRef.current?.();
376
+ rafRef.current = requestAnimationFrame(scanFrame);
377
+ } catch (err) {
378
+ if (!mountedRef.current) return;
379
+ if (err instanceof DOMException && err.name === "AbortError") return;
380
+
381
+ const isNoCamera =
382
+ err instanceof DOMException &&
383
+ (err.name === "NotFoundError" ||
384
+ err.name === "NotAllowedError" ||
385
+ err.name === "NotReadableError");
386
+
387
+ setStatus(isNoCamera ? "no-camera" : "error");
388
+ setError(err instanceof Error ? err.message : String(err));
389
+ onErrorRef.current?.(err);
390
+ }
391
+ }, [facingMode, constraints, scanFrame, stopCamera]);
392
+
393
+ /**
394
+ * Main lifecycle effect: starts the camera on mount (if `autoStart` is
395
+ * true), and stops it on unmount.
396
+ *
397
+ * Captures `videoRef.current` at effect time to satisfy React's ref-stability
398
+ * lint rule. The cleanup follows the same race-condition-safe pattern as
399
+ * `stopCamera`: captures `streamRef.current` into `streamToStop` and guards
400
+ * the `doCleanup` callback with `video.srcObject === streamToStop`.
401
+ */
402
+ useEffect(() => {
403
+ const video = videoRef.current;
404
+ if (autoStart) {
405
+ startCamera();
406
+ }
407
+ return () => {
408
+ scanningRef.current = false;
409
+ cancelAnimationFrame(rafRef.current);
410
+
411
+ const streamToStop = streamRef.current;
412
+
413
+ const doCleanup = () => {
414
+ if (video && video.srcObject === streamToStop) {
415
+ video.pause();
416
+ video.srcObject = null;
417
+ }
418
+ if (streamToStop) {
419
+ streamToStop.getTracks().forEach((track) => track.stop());
420
+ }
421
+ };
422
+
423
+ if (playPromiseRef.current) {
424
+ playPromiseRef.current.then(doCleanup, doCleanup);
425
+ playPromiseRef.current = null;
426
+ } else {
427
+ doCleanup();
428
+ }
429
+ };
430
+ }, [autoStart, startCamera]);
431
+
432
+ // ---- Imperative handle ----
433
+
434
+ /**
435
+ * Exposes `start`, `stop`, `scanImage`, and `isScanning` to the parent
436
+ * via the `ref` prop.
437
+ *
438
+ * `scanImage` decodes a QR code from a static image file without involving
439
+ * the camera. It renders the file to an off-screen canvas via
440
+ * `createImageBitmap` + `ctx.drawImage`, then runs jsQR on the pixel data.
441
+ *
442
+ * The `ImageBitmap` is always closed in the `finally` block to prevent
443
+ * memory leaks (an `ImageBitmap` holds a reference to the underlying
444
+ * `ArrayBuffer` until explicitly closed).
445
+ */
446
+ useImperativeHandle(
447
+ ref,
448
+ () => ({
449
+ start: startCamera,
450
+ stop: stopCamera,
451
+ scanImage: async (file: File): Promise<string | null> => {
452
+ let bitmap: ImageBitmap | null = null;
453
+ try {
454
+ bitmap = await createImageBitmap(file);
455
+ const tempCanvas = document.createElement("canvas");
456
+ tempCanvas.width = bitmap.width;
457
+ tempCanvas.height = bitmap.height;
458
+ const ctx = tempCanvas.getContext("2d");
459
+ if (!ctx) return null;
460
+ ctx.drawImage(bitmap, 0, 0);
461
+ const imageData = ctx.getImageData(
462
+ 0,
463
+ 0,
464
+ tempCanvas.width,
465
+ tempCanvas.height,
466
+ );
467
+ const jsqr = await getJsqr();
468
+ const code = jsqr(imageData.data, imageData.width, imageData.height);
469
+ return code?.data ?? null;
470
+ } catch {
471
+ return null;
472
+ } finally {
473
+ bitmap?.close();
474
+ }
475
+ },
476
+ isScanning: scanning,
477
+ }),
478
+ [startCamera, stopCamera, scanning],
479
+ );
480
+
481
+ // ---- Status renderer ----
482
+
483
+ /**
484
+ * Returns a status `<span>` based on the current `status` value, or `null`
485
+ * when no status overlay is needed (i.e. the camera is working and the
486
+ * video feed is visible).
487
+ *
488
+ * The status text is customisable via the `loadingText`, `noCameraText`,
489
+ * and `fallbackText` props. Error messages come from the `error` state,
490
+ * which is set to `err.message` from the caught exception.
491
+ */
492
+ const renderStatus = () => {
493
+ if (status === "starting") {
494
+ return <span className={qrScannerStatusVariants()}>{loadingText}</span>;
495
+ }
496
+ if (status === "error" && error) {
497
+ return <span className={qrScannerStatusVariants()}>{error}</span>;
498
+ }
499
+ if (status === "no-camera") {
500
+ return <span className={qrScannerStatusVariants()}>{noCameraText}</span>;
501
+ }
502
+ return null;
503
+ };
504
+
505
+ // ---- Render branches ----
506
+
507
+ /**
508
+ * Fallback UI shown when the camera is unavailable or an error occurred.
509
+ *
510
+ * The outer `<div>` still carries the same `data-slot` and variant classes
511
+ * as the normal UI so that layout consistency is maintained.
512
+ */
513
+ if (status === "no-camera" || status === "error") {
514
+ return (
515
+ <div
516
+ data-slot="qr-scanner"
517
+ className={cn(qrScannerVariants({ appearance }), className)}
518
+ {...rest}
519
+ >
520
+ <div className={qrScannerFallbackVariants()}>
521
+ {fallbackText}
522
+ {renderStatus()}
523
+ </div>
524
+ </div>
525
+ );
526
+ }
527
+
528
+ /**
529
+ * Normal UI: video feed with a viewfinder overlay and optional status text.
530
+ *
531
+ * Nested structure:
532
+ * <div data-slot="qr-scanner"> ← root (className, appearance, ...rest)
533
+ * <video /> ← camera feed (playsInline, muted, autoPlay)
534
+ * <canvas className="hidden" /> ← off-screen canvas for frame capture
535
+ * <div> ← overlay container
536
+ * <div /> ← viewfinder corners
537
+ * </div>
538
+ * {renderStatus()} ← "Starting camera..." overlay text
539
+ * </div>
540
+ */
541
+ return (
542
+ <div
543
+ data-slot="qr-scanner"
544
+ className={cn(qrScannerVariants({ appearance }), className)}
545
+ {...rest}
546
+ >
547
+ <video
548
+ ref={videoRef}
549
+ data-slot="qr-scanner-video"
550
+ className={qrScannerVideoVariants()}
551
+ playsInline
552
+ muted
553
+ autoPlay
554
+ />
555
+ <canvas
556
+ ref={canvasRef}
557
+ className="hidden"
558
+ data-slot="qr-scanner-canvas"
559
+ />
560
+ <div className={qrScannerOverlayVariants()}>
561
+ <div className={qrScannerViewfinderVariants()} />
562
+ </div>
563
+ {renderStatus()}
564
+ </div>
565
+ );
566
+ }
567
+
568
+ QrScannerBase.displayName = "QrScanner";
@@ -0,0 +1,61 @@
1
+ import { createRef } from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { describe, expect, it, vi, beforeAll } from "vitest";
4
+
5
+ import { QrScanner } from "./qr-scanner";
6
+ import type { QrScannerRef } from "./types";
7
+
8
+ const mockGetUserMedia = vi.fn();
9
+
10
+ beforeAll(() => {
11
+ Object.defineProperty(globalThis.navigator, "mediaDevices", {
12
+ value: {
13
+ getUserMedia: mockGetUserMedia,
14
+ },
15
+ configurable: true,
16
+ });
17
+ });
18
+
19
+ describe("QrScanner", () => {
20
+ it("should expose displayName", () => {
21
+ expect(QrScanner.displayName).toBe("QrScanner");
22
+ });
23
+
24
+ it("should stamp data-slot", () => {
25
+ render(<QrScanner onResult={vi.fn()} autoStart={false} />);
26
+ const root = document.querySelector('[data-slot="qr-scanner"]');
27
+ expect(root).toBeTruthy();
28
+ expect(root?.getAttribute("data-slot")).toBe("qr-scanner");
29
+ });
30
+
31
+ it("should show fallback when camera fails", async () => {
32
+ mockGetUserMedia.mockRejectedValueOnce(
33
+ new DOMException("Camera not found", "NotFoundError"),
34
+ );
35
+ render(<QrScanner onResult={vi.fn()} autoStart={true} />);
36
+ const fallback = await screen.findByText("Camera not available");
37
+ expect(fallback).toBeInTheDocument();
38
+ });
39
+
40
+ it("should forward ref with imperative handle", () => {
41
+ const ref = createRef<QrScannerRef>();
42
+ render(<QrScanner onResult={vi.fn()} autoStart={false} ref={ref} />);
43
+ expect(ref.current).toBeTruthy();
44
+ expect(typeof ref.current?.start).toBe("function");
45
+ expect(typeof ref.current?.stop).toBe("function");
46
+ expect(typeof ref.current?.scanImage).toBe("function");
47
+ expect(typeof ref.current?.isScanning).toBe("boolean");
48
+ });
49
+
50
+ it("should apply custom className", () => {
51
+ const { container } = render(
52
+ <QrScanner
53
+ onResult={vi.fn()}
54
+ autoStart={false}
55
+ className="custom-class"
56
+ />,
57
+ );
58
+ const root = container.querySelector('[data-slot="qr-scanner"]');
59
+ expect(root?.className).toMatch(/custom-class/);
60
+ });
61
+ });
@@ -0,0 +1,2 @@
1
+ // qr-scanner.tsx — default static entry (no framer-motion)
2
+ export { QrScannerBase as QrScanner } from "./qr-scanner-base";
@@ -0,0 +1,32 @@
1
+ import type { VariantProps } from "class-variance-authority";
2
+ import type { ComponentPropsWithoutRef, ReactNode, Ref } from "react";
3
+
4
+ import type { qrScannerVariants } from "./variants";
5
+
6
+ export type QrScannerVariantProps = VariantProps<typeof qrScannerVariants>;
7
+
8
+ export interface QrScannerBaseProps extends ComponentPropsWithoutRef<"div"> {
9
+ ref?: Ref<QrScannerRef>;
10
+ onResult: (data: string) => void;
11
+ onError?: (error: unknown) => void;
12
+ onStart?: () => void;
13
+ onStop?: () => void;
14
+ facingMode?: "user" | "environment";
15
+ constraints?: MediaTrackConstraints;
16
+ scanDelay?: number;
17
+ continuous?: boolean;
18
+ fallbackText?: ReactNode;
19
+ loadingText?: ReactNode;
20
+ noCameraText?: ReactNode;
21
+ appearance?: QrScannerVariantProps["appearance"];
22
+ autoStart?: boolean;
23
+ }
24
+
25
+ export type QrScannerProps = QrScannerBaseProps;
26
+
27
+ export interface QrScannerRef {
28
+ start: () => Promise<void>;
29
+ stop: () => void;
30
+ scanImage: (file: File) => Promise<string | null>;
31
+ isScanning: boolean;
32
+ }
@@ -0,0 +1,26 @@
1
+ import { cva } from "class-variance-authority";
2
+
3
+ import {
4
+ zuiQrScannerAppearances,
5
+ zuiQrScannerBase,
6
+ zuiQrScannerFallbackBase,
7
+ zuiQrScannerOverlay,
8
+ zuiQrScannerStatusBase,
9
+ zuiQrScannerViewfinder,
10
+ zuiQrScannerVideo,
11
+ } from "../../design-system/qr-scanner";
12
+
13
+ export const qrScannerVariants = cva(zuiQrScannerBase, {
14
+ variants: {
15
+ appearance: zuiQrScannerAppearances,
16
+ },
17
+ defaultVariants: {
18
+ appearance: "default",
19
+ },
20
+ });
21
+
22
+ export const qrScannerVideoVariants = cva(zuiQrScannerVideo);
23
+ export const qrScannerOverlayVariants = cva(zuiQrScannerOverlay);
24
+ export const qrScannerViewfinderVariants = cva(zuiQrScannerViewfinder);
25
+ export const qrScannerStatusVariants = cva(zuiQrScannerStatusBase);
26
+ export const qrScannerFallbackVariants = cva(zuiQrScannerFallbackBase);
@@ -66,7 +66,10 @@ export function SecretRevealAnimated({
66
66
  )}
67
67
  <span
68
68
  data-slot="secret-reveal-value"
69
- className={cn(secretRevealValueVariants({ size }), "flex-1 truncate")}
69
+ className={cn(
70
+ secretRevealValueVariants({ size }),
71
+ "flex-1 min-w-0 truncate",
72
+ )}
70
73
  >
71
74
  <AnimatePresence mode="wait">
72
75
  <motion.span